<?php

/*
 * This file is part of the Kimai time-tracking app.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace App\Command;

use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Importer\InvalidFieldsException;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\Duration;
use League\Csv\Reader;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

/**
 * This command can change anytime, don't rely on its API for the future!
 *
 * @internal
 * @codeCoverageIgnore
 */
class ImportTimesheetCommand extends Command
{
    protected static $defaultName = 'kimai:import:timesheet';

    // if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
    public const DEFAULT_BEGIN = '04:00';
    public const DEFAULT_CUSTOMER = 'Imported customer - %s';

    private static $supportedHeader = [
        'Date',
        'From',
        'To',
        'Duration',
        'Rate',
        'User',
        'Customer',
        'Project',
        'Activity',
        'Description',
        'Exported',
        'Tags',
        'Hourly rate',
        'Fixed rate',
    ];

    private $customers;
    private $projects;
    private $activities;
    private $users;
    private $tagRepository;
    private $timesheets;
    private $configuration;
    private $encoder;

    /**
     * @var Customer
     */
    private $customerFallback;
    /**
     * @var Customer[]
     */
    private $customerCache = [];
    /**
     * @var Project[]
     */
    private $projectCache = [];
    /**
     * @var User[]
     */
    private $userCache = [];
    /**
     * @var Tag[]
     */
    private $tagCache = [];
    /**
     * Comment that will be added to new customers, projects and activities.
     *
     * @var string
     */
    private $comment = '';
    /**
     * The datetime of this import as formatted string.
     *
     * @var string
     */
    private $dateTime = '';
    /**
     * @var string
     */
    private $begin = self::DEFAULT_BEGIN;
    // some statistics to display to the user
    private $createdProjects = 0;
    private $createdUsers = 0;
    private $createdCustomers = 0;
    private $createdActivities = 0;

    public function __construct(
        CustomerRepository $customers,
        ProjectRepository $projects,
        ActivityRepository $activities,
        UserRepository $users,
        TagRepository $tagRepository,
        TimesheetRepository $timesheets,
        SystemConfiguration $configuration,
        UserPasswordEncoderInterface $encoder
    ) {
        parent::__construct();
        $this->customers = $customers;
        $this->projects = $projects;
        $this->activities = $activities;
        $this->users = $users;
        $this->tagRepository = $tagRepository;
        $this->timesheets = $timesheets;
        $this->configuration = $configuration;
        $this->encoder = $encoder;
    }

    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this
            ->setName(self::$defaultName)
            ->setDescription('Import timesheets from CSV file')
            ->setHelp(
                'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
                'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
                'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
            )
            ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
            ->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
            ->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
            ->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
            ->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
            ->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Created by import at %s')
            ->addOption('create-users', null, InputOption::VALUE_NONE, 'If set, accounts for not found users will be created')
            ->addOption('ignore-errors', null, InputOption::VALUE_NONE, 'If set, invalid rows will be skipped')
            ->addOption('batch', null, InputOption::VALUE_NONE, 'If set, timesheets will be written in batches of 100')
            ->addOption('domain', null, InputOption::VALUE_OPTIONAL, 'Domain name used for email addresses of new created users. If provided usernames already include a domain, this option will be skipped.', 'example.com')
            ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password for new created users.', 'password')
            ->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
        ;
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int|null
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $io = new SymfonyStyle($input, $output);

        $io->title('Kimai importer: Timesheets');

        $csvFile = $input->getArgument('file');
        if (!file_exists($csvFile)) {
            $io->error('File not existing: ' . $csvFile);

            return 1;
        }

        if (!is_readable($csvFile)) {
            $io->error('File cannot be read: ' . $csvFile);

            return 2;
        }

        $this->dateTime = (new \DateTime())->format('Y.m.d H:i');
        $this->comment = sprintf($input->getOption('comment'), $this->dateTime);
        $this->begin = $input->getOption('begin');

        $timezone = $input->getOption('timezone');
        switch ($timezone) {
            case 'server':
                $timezone = new \DateTimeZone(date_default_timezone_get());
                break;

            case 'user':
                // null means fetch from user
                $timezone = null;
                break;

            default:
                try {
                    if (!\in_array($timezone, \DateTimeZone::listIdentifiers())) {
                        throw new \InvalidArgumentException('Not a known PHP timezone');
                    }
                    $timezone = new \DateTimeZone($timezone);
                } catch (\Exception $ex) {
                    $io->error('Invalid timezone given, import canceled.');

                    return 3;
                }
                break;
        }

        $activityType = $input->getOption('activity');
        $allowedActivityTypes = ['project', 'global'];
        if (!\in_array($activityType, $allowedActivityTypes)) {
            $io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));

            return 4;
        }

        $csv = Reader::createFromPath($csvFile, 'r');
        $csv->setDelimiter($input->getOption('delimiter'));
        $csv->setHeaderOffset(0);
        $header = $csv->getHeader();
        if (!$this->validateHeader($header)) {
            $io->error(
                sprintf(
                    'Found invalid CSV. The header: ' . PHP_EOL .
                    '%s' . PHP_EOL .
                    'did not match the expected structure: ' . PHP_EOL .
                    '%s',
                    implode(', ', $header),
                    implode(', ', self::$supportedHeader)
                )
            );

            return 5;
        }

        $all = $csv->getRecords();
        $total = iterator_count($all);

        $io->text(sprintf('Found %s timesheets to import, pre-validating now', $total));

        $records = [];
        $doImport = true;
        $row = 1;
        $errors = 0;

        $createUsers = $input->getOption('create-users');
        $ignoreErrors = $input->getOption('ignore-errors');

        // ======================= validate rows =======================
        $progressBar = new ProgressBar($output, $total);

        $countAll = 0;
        foreach ($all as $record) {
            $this->convertRow($record);
            try {
                $this->validateRow($record);
            } catch (InvalidFieldsException $ex) {
                $io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
                $doImport = false;
                $errors++;
            }

            if (!$createUsers) {
                if (null === $this->getUser($record['User'])) {
                    if (!$ignoreErrors) {
                        $io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
                    }
                    $doImport = false;
                    $errors++;
                }
            }

            $row++;

            if ($doImport) {
                $records[] = $record;
            }
            $countAll++;
            $progressBar->advance();
        }
        $progressBar->finish();
        $io->writeln('');

        if (!$ignoreErrors && !$doImport) {
            $io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));

            return 5;
        }

        $io->writeln('');
        $io->text(sprintf('Processing %s of %s rows, skipping %s with pre-validation errors.', \count($records), iterator_count($all), $errors));

        // values for new users
        $password = $input->getOption('password');
        $domain = $input->getOption('domain');

        $progressBar = new ProgressBar($output, \count($records));

        $durationParser = new Duration();
        $row = 0;
        $imported = 0;
        $failed = 0;

        $isBatchUpdate = $input->getOption('batch');
        $batches = [];

        foreach ($records as $record) {
            $row++;
            try {
                $project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
                $activity = $this->getActivity($record['Activity'], $project, $activityType);

                $user = $this->getUser($record['User']);
                if (null === $user) {
                    $user = $this->createUser($record['User'], $domain, $password);
                }

                $begin = null;
                $end = null;
                $duration = 0;

                if (!empty($record['Duration'])) {
                    if (\is_int($record['Duration'])) {
                        $duration = $record['Duration'];
                    } else {
                        $duration = $durationParser->parseDurationString($record['Duration']);
                    }
                }

                if (null === $timezone) {
                    $timezone = new \DateTimeZone($user->getTimezone());
                }

                if (empty($record['From']) && empty($record['To'])) {
                    $begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
                    $end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
                } elseif (empty($record['From'])) {
                    $end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
                    $begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
                } elseif (empty($record['To'])) {
                    $begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
                    $end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
                } else {
                    $begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
                    $end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);

                    // fix dates, which are running over midnight
                    if ($end < $begin) {
                        if ($duration > 0) {
                            $end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
                        } else {
                            $end->add(new \DateInterval('P1D'));
                        }
                    }
                }

                $timesheet = new Timesheet();
                $timesheet->setActivity($activity);
                $timesheet->setProject($project);
                $timesheet->setBegin($begin);
                $timesheet->setEnd($end);
                $timesheet->setUser($user);
                $timesheet->setDescription($record['Description']);
                $timesheet->setExported((bool) $record['Exported']);

                if (!empty($record['Tags'])) {
                    foreach (explode(',', $record['Tags']) as $tagName) {
                        if (empty($tagName)) {
                            continue;
                        }

                        $tag = $this->getTag($tagName);

                        $timesheet->addTag($tag);
                    }
                }

                if (!empty($record['Rate'])) {
                    $timesheet->setRate($record['Rate']);
                }
                if (!empty($record['Hourly rate'])) {
                    $timesheet->setHourlyRate($record['Hourly rate']);
                }
                if (!empty($record['Fixed rate'])) {
                    $timesheet->setFixedRate($record['Fixed rate']);
                }

                if ($isBatchUpdate) {
                    $batches[] = $timesheet;

                    if ($row % 100 === 0) {
                        $this->timesheets->saveMultiple($batches);
                        $batches = [];
                    }
                } else {
                    $this->timesheets->save($timesheet);
                }

                $imported++;
            } catch (\Exception $ex) {
                $io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
                $failed++;
            }

            $progressBar->advance();
        }

        if ($isBatchUpdate && \count($batches) > 0) {
            $this->timesheets->saveMultiple($batches);
        }

        $progressBar->finish();

        $io->writeln('');
        $io->writeln('');

        if ($this->createdUsers > 0) {
            $io->success(sprintf('Created %s users', $this->createdUsers));
        }
        if ($this->createdCustomers > 0) {
            $io->success(sprintf('Created %s customers', $this->createdCustomers));
        }
        if ($this->createdProjects > 0) {
            $io->success(sprintf('Created %s projects', $this->createdProjects));
        }
        if ($this->createdActivities > 0) {
            $io->success(sprintf('Created %s activities', $this->createdActivities));
        }

        if ($failed > 0) {
            $io->warning(sprintf('Failed validating %s rows', $failed));
        }

        if ($imported > 0) {
            $io->success(sprintf('Imported %s rows', $imported));
        }

        return 0;
    }

    private function createUser($username, $domain, $password): User
    {
        $user = new User();
        $user->setUsername($username);
        if (stripos($username, '@') === false) {
            $email = preg_replace('/[[:^print:]]/', '', $username) . '@' . $domain;
            $email = strtolower($email);
        } else {
            $email = $username;
        }
        $user->setEmail($email);
        $user->setPassword($this->encoder->encodePassword($user, $password));

        $this->users->saveUser($user);
        $this->createdUsers++;

        $this->userCache[$username] = $user;

        return $user;
    }

    private function getUser($user): ?User
    {
        if (!\array_key_exists($user, $this->userCache)) {
            $tmpUser = $this->users->findOneBy(['username' => $user]);
            if (null === $tmpUser) {
                $tmpUser = $this->users->findOneBy(['email' => $user]);
                if (null === $tmpUser) {
                    return null;
                }
            }
            $this->userCache[$user] = $tmpUser;
        }

        return $this->userCache[$user];
    }

    private function getTag(string $tagName): Tag
    {
        if (\array_key_exists($tagName, $this->tagCache)) {
            return $this->tagCache[$tagName];
        }

        $tag = $this->tagRepository->findTagByName($tagName);

        if ($tag === null) {
            $tag = (new Tag())->setName($tagName);
        }

        $this->tagCache[$tagName] = $tag;

        return $this->tagCache[$tagName];
    }

    private function getActivity($activity, Project $project, $activityType): Activity
    {
        $tmpActivity = null;

        $tmpActivities = $this->activities->findBy(['project' => $project, 'name' => $activity]);

        if (\count($tmpActivities) === 0) {
            $tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
        } elseif (\count($tmpActivities) === 1) {
            $tmpActivity = $tmpActivities[0];
        }

        if (null === $tmpActivity) {
            $tmpActivity = new Activity();
            $tmpActivity->setName($activity);
            $tmpActivity->setComment($this->comment);
            if ($activityType === 'project') {
                $tmpActivity->setProject($project);
            }
            $this->activities->saveActivity($tmpActivity);
            $this->createdActivities++;
        }

        return $tmpActivity;
    }

    private function getProject($project, $customer, $fallbackCustomer): Project
    {
        $cacheKey = $project . '_____' . $customer;

        if (!\array_key_exists($cacheKey, $this->projectCache)) {
            $tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
            /** @var Project $tmpProject */
            $tmpProject = null;
            /** @var Project[] $tmpProjects */
            $tmpProjects = $this->projects->findBy(['name' => $project]);

            if (\count($tmpProjects) > 1) {
                /** @var Project $prj */
                foreach ($tmpProjects as $prj) {
                    if (strcasecmp($prj->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
                        continue;
                    }
                    $tmpProject = $prj;
                    break;
                }
            } elseif (\count($tmpProjects) === 1) {
                $tmpProject = $tmpProjects[0];
            }

            if (null !== $tmpProject) {
                if (strcasecmp($tmpProject->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
                    $tmpProject = null;
                }
            }

            if ($tmpProject === null) {
                $tmpProject = new Project();
                $tmpProject->setName($project);
                $tmpProject->setComment($this->comment);
                $tmpProject->setCustomer($tmpCustomer);
                $this->projects->saveProject($tmpProject);
                $this->createdProjects++;
            }

            $this->projectCache[$cacheKey] = $tmpProject;
        }

        return $this->projectCache[$cacheKey];
    }

    private function getCustomer($customer, $fallback): Customer
    {
        if (!empty($customer)) {
            if (!\array_key_exists($customer, $this->customerCache)) {
                $tmpCustomer = $this->customers->findBy(['name' => $customer]);
                if (\count($tmpCustomer) > 1) {
                    throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
                } elseif (\count($tmpCustomer) === 1) {
                    $tmpCustomer = $tmpCustomer[0];
                }

                if ($tmpCustomer instanceof Customer) {
                    $this->customerCache[$customer] = $tmpCustomer;
                }
            }

            if (\array_key_exists($customer, $this->customerCache)) {
                return $this->customerCache[$customer];
            }
        }

        if (null !== $this->customerFallback && !empty($fallback)) {
            return $this->customerFallback;
        }

        $tmpFallback = null;

        if (!empty($fallback)) {
            if (is_numeric($fallback)) {
                $tmpFallback = $this->customers->find((int) $fallback);
            } else {
                /** @var Customer|null $tmpFallback */
                $tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
            }
        }

        if (null === $tmpFallback) {
            $newName = $customer;
            if (empty($customer)) {
                $newName = self::DEFAULT_CUSTOMER;
                if (!empty($fallback) && \is_string($fallback)) {
                    $newName = $fallback;
                }
            }
            $tmpFallback = new Customer();
            $tmpFallback->setName(sprintf($newName, $this->dateTime));
            $tmpFallback->setComment($this->comment);
            $tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
            $timezone = date_default_timezone_get();
            if (null !== $this->configuration->getCustomerDefaultTimezone()) {
                $timezone = $this->configuration->getCustomerDefaultTimezone();
            }
            $tmpFallback->setTimezone($timezone);
            $this->customers->saveCustomer($tmpFallback);
            $this->createdCustomers++;
        }

        $this->customerFallback = $tmpFallback;

        return $this->customerFallback;
    }

    /**
     * @param array $row
     * @return bool
     * @throws InvalidFieldsException
     */
    private function validateRow(array $row)
    {
        $fields = [];

        if (empty($row['Project'])) {
            $fields[] = 'Project';
        }

        if (empty($row['Activity'])) {
            $fields[] = 'Activity';
        }

        if (empty($row['Date'])) {
            $fields[] = 'Date';
        }

        if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
            $fields[] = 'Duration';
        }

        if (!empty($fields)) {
            throw new InvalidFieldsException($fields);
        }

        return true;
    }

    private function validateHeader(array $header)
    {
        $result = array_diff(self::$supportedHeader, $header);

        return empty($result);
    }

    /**
     * Add project specific conversion logic here
     *
     * @param array $row
     */
    private function convertRow(array &$row)
    {
        // negative durations
        if ($row['Duration'][0] === '-') {
            $row['Duration'] = substr($row['Duration'], 1);
        }

        if (!\array_key_exists('Tags', $row)) {
            $row['Tags'] = null;
        }
        if (empty($row['Date'])) {
            $row['Date'] = '1970-01-01';
        }
        if (!\array_key_exists('Exported', $row)) {
            $row['Exported'] = false;
        }
        if (!\array_key_exists('Rate', $row)) {
            $row['Rate'] = null;
        }
        if (!\array_key_exists('Hourly rate', $row)) {
            $row['Hourly rate'] = null;
        }
        if (!\array_key_exists('Fixed rate', $row)) {
            $row['Fixed rate'] = null;
        }
        if (!empty($row['From'])) {
            $len = \strlen($row['From']);
            if ($len === 1) {
                $row['From'] = '0' . $row['From'] . ':00';
            } elseif ($len == 2) {
                $row['From'] = $row['From'] . ':00';
            }
        }
        if (!empty($row['To'])) {
            $len = \strlen($row['To']);
            if ($len === 1) {
                $row['To'] = '0' . $row['To'] . ':00';
            } elseif ($len == 2) {
                $row['To'] = $row['To'] . ':00';
            }
        }
    }
}
