<?php

namespace Ignite\Core\Models\Import;

use Ignite\Core\Entities\Import;
use Ignite\Core\Entities\Participant;
use Ignite\Core\Notifications\NotifyProgramManagerWithPostImportInfo;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use League\Csv\Exception;
use League\Csv\Reader;
use League\Csv\Statement;
use Throwable;

abstract class Importer
{
    public const RESULT_REJECTED = 'rejected';
    public const RESULT_IMPORTED = 'imported';
    public const RESULT_WAITING = 'waiting';
    public const RESULT_DUPLICATE = 'duplicate';

    /**
     * The required data that must be available in the file.
     *
     * @var array
     */
    protected $required = [];

    /**
     * The list of "known" fields.
     *
     * @var array
     */
    protected $fields = [];

    /**
     * The import record.
     *
     * @var Import
     */
    protected $record;

    /**
     * A cache of data to be stored when accessing this instance during a single request.
     *
     * @var array
     */
    protected $cache;

    /**
     * The cached headers.
     *
     * @var array
     */
    protected $headers;

    /**
     * @var string
     */
    protected $resultColumn;

    /**
     * Is true if we need to handle row as error if it could not save.
     *
     * @var bool
     */
    protected $isErrorOnFailedSave = true;

    /**
     * Prepare any dependencies for each iteration of the import process.
     *
     * @return void
     */
    public function prepare()
    {
    }

    /**
     * Runs any needed steps after import is complete.
     *
     * @param  array  $params
     * @return void
     */
    public function postImport(array $params)
    {
        if (!$params['isDryRun']) {
            $this->sendEmailToManager($params['import']);
        }
    }

    /**
     * Sends email to a program email configured in the mail config file. This  notification is to send
     * some of the details about the import activity to PMs.
     *
     * @param Import $import
     */
    protected function sendEmailToManager(Import $import)
    {
        $managerEmail = config('mail.from.address');

        $data['file_name'] = basename($import->file);
        $data['total_records'] = $import->records;
        $data['total_records_imported'] = $import->imported;
        $data['total_rejected'] = $import->rejected;
        $data['import_run_at'] = $import->run_at;

        Notification::route('mail', $managerEmail)
            ->notify(new NotifyProgramManagerWithPostImportInfo($data));
    }

    /**
     * Assign the import data record for this import.
     *
     * @param  Import $record
     *
     * @return self
     */
    public function record(Import $record)
    {
        $this->record = $record;

        return $this;
    }

    /**
     * The total number of records to be imported.
     *
     * @return int
     */
    public function count(): int
    {
        try {
            return $this->getCount();
        } catch (\Exception $e) {
            return 0;
        }
    }

    /**
     * Display the information text as html while previewing the data.
     *
     * @return string
     */
    public function html(): string
    {
        return '';
    }

    /**
     * Preview a single record at the given offset.
     *
     * @param  int $offset
     * @return array
     * @throws Exception
     */
    public function preview($offset = 1): array
    {
        $data = $this->getByOffset($offset);

        if (empty($data)) {
            throw new Exception("Record not found at line $offset");
        }

        return array_combine(
            $this->getHeaders(),
            $data
        );
    }

    /**
     * Process the file and return the iterator.
     *
     * @return iterable
     * @throws Exception
     */
    public function process(): iterable
    {
        return $this->getRecords();
    }

    /**
     * Return the column header in the csv file to use in order to identify the user.
     *
     * @return string
     */
    abstract protected function getUserIdentifier();

    /**
     * Create a new reader instance from the import file data.
     *
     * @return Reader
     */
    protected function createFromData()
    {
        return Reader::fromString($this->record->getFileData());
    }

    /**
     * Create a new reader instance from the import file as a stream resource.
     *
     * @return Reader
     */
    protected function createFromStream()
    {
        return Reader::from($this->record->getFileStream());
    }

    /**
     * Create a new reader instance from the import file as a stream resource.
     *
     * @return Reader
     */
    protected function createFromPath()
    {
        return Reader::from($this->record->getFilePath(), 'r');
    }

    /**
     * Create an instance of a Statement object.
     *
     * @return Statement
     */
    protected function createStatementInstance()
    {
        return new Statement();
    }

    /**
     * Create a new instance the validator.
     *
     * @param  array $data
     * @param  array $rules
     * @param  array $messages
     * @param  array $attributes
     *
     * @return \Illuminate\Contracts\Validation\Validator|\Illuminate\Validation\Validator
     */
    protected function createValidatorInstance(array $data, array $rules, array $messages = [], array $attributes = [])
    {
        return Validator::make($data, $rules, $messages, $attributes);
    }

    /**
     * Get all of the data rows.
     *
     * @return Reader
     * @throws Exception
     */
    protected function getData()
    {
        return $this->createFromStream()->setHeaderOffset(0);
    }

    /**
     * Count the number of records in the data file.
     *
     * @return int
     * @throws Exception
     */
    protected function getCount()
    {
        return $this->getData()->count();
    }

    /**
     * Fetch one line from the file by offset.
     *
     * @param  int $offset
     * @return array
     * @throws Exception
     */
    protected function getByOffset($offset)
    {
        return $this->getNthRecord($this->getData(), (int) $offset - 1);
    }

    /**
     * Fetch all records.
     *
     * @return \Iterator
     * @throws Exception
     */
    protected function getRecords()
    {
        return $this->getData()->getRecords($this->getHeaders());
    }

    /**
     * Chunk groups of records records.
     *
     * @param  $length
     * @return \Iterator
     * @throws Exception
     */
    protected function chunkRecords($length)
    {
        return $this->getData()->chunk($length);
    }

    /**
     * Get the first line from the csv file as the headers.
     *
     * @return array
     * @throws Exception
     */
    protected function fetchHeaders()
    {
        $records = $this->createStatementInstance()
            ->offset(0)
            ->limit(1)
            ->process($this->createFromStream());

        return $this->getNthRecord($records, 0);
    }

    /**
     * Fetch one record from a CSV reader or record set.
     *
     * @param  mixed  $records
     * @param  int  $offset
     * @return array
     */
    protected function getNthRecord($records, int $offset): array
    {
        return $records->nth($offset);
    }

    /**
     * Attempt to map the correct fields from the provided headers.
     *
     * @return array
     * @throws Exception
     */
    public function getHeaders(): array
    {
        if (is_null($this->headers)) {
            $this->headers = array_map(function ($header) {
                return $this->getHeader($header);
            }, $this->fetchHeaders());
        }

        return $this->headers;
    }

    /**
     * Get a single header, if the configured field header matches, return immediately.
     * Otherwise, try to find a configured synonym for the header. Else, return the original header.
     *
     * @param  string $header
     * @return string
     */
    protected function getHeader($header)
    {
        foreach ($this->fields as $field => $synonyms) {
            if (strtolower($header) === strtolower($field)) {
                return $field;
            }
            if ($this->matchSynonyms($header, $synonyms)) {
                return $field;
            }
        }

        return $header;
    }

    /**
     * Attempt to match the header against a list of synonyms.
     *
     * @param  string $header
     * @param  array  $synonyms
     * @return bool
     */
    protected function matchSynonyms($header, $synonyms)
    {
        if (empty($synonyms)) {
            return false;
        }

        $matches = array_filter($synonyms, function ($synonym) use ($header) {
            $transformed = snake_case(strtolower(str_replace('-', '', $synonym)));
            $header = snake_case(strtolower(str_replace('-', '', $header)));
            return ($header === $transformed);
        });

        return ! empty($matches);
    }

    /**
     * Cache the users in the system as key => value pairs.
     *
     * @todo   Possible that we should extract an `ImportingUserCache` class to encapsulate this or perhaps use a decorator.
     * @param  string $column
     * @param  string $key
     *
     * @return array
     * @throws Exception
     */
    protected function cacheUsers($column = 'user_id', $key = 'email')
    {
        if (! isset($this->cache['users']) || is_null($this->cache['users'])) {
            $keys = $this->getHeaders();

            $lines = $this->createStatementInstance()->offset(1)->process($this->createFromStream(), $keys);
            $userColumn = array_search($column, $keys);

            if (false === $userColumn) {
                throw new Exception('Unable to locate a header to find the user.');
            }

            $users = $this->preProcessCacheableUsers(iterator_to_array($lines->fetchColumn($userColumn)));
            $users = array_keys(array_flip($users));
            $users = $this->postProcessCacheableUsers($users);

            $this->cache['users'] = $this->getCacheableUsers($key, $users);
        }

        return $this->cache['users'];
    }

    /**
     * Pre process the users lookup before it gets cached.
     *
     * @param  array $users
     * @return array
     */
    protected function preProcessCacheableUsers($users)
    {
        return $users;
    }

    /**
     * Post process the users lookup before it gets cached.
     *
     * @param  array $users
     * @return array
     */
    protected function postProcessCacheableUsers($users)
    {
        return $users;
    }

    /**
     * Fetch the cacheable users from the database.
     *
     * @param  string $key
     * @param  array  $users
     * @return array
     */
    protected function getCacheableUsers($key, $users)
    {
        return Participant::whereIn($key, $users)->get()
            ->mapWithKeys(function ($user) use ($key) {
                return [
                    strtolower(trim($user->{$key})) => $user->getKey()
                ];
            })
            ->toArray();
    }

    /**
     * Attempt to transform a date for storage.
     *
     * @param  array  $line
     * @param  string $field
     * @param  string $format
     *
     * @return string
     */
    protected function transformDate($line, $field, $format = 'Y-m-d H:i:s')
    {
        if (! isset($line[$field]) || empty($line[$field])) {
            return now()->format($format);
        }

        if (strtotime($line[$field]) === false) {
            throw new \DomainException(sprintf(
                'Unable to import field `%s` because `%s` is not a valid date.',
                $field,
                $line[$field]
            ));
        }

        return date($format, strtotime($line[$field]));
    }

    /**
     * Transform the user identifier to ID but throw an exception with an unknown user.
     *
     * @param  array  $line
     * @param  string $key
     * @return int|null
     */
    protected function transformUser($line, $key)
    {
        if (! array_key_exists($key, $line)) {
            throw new \DomainException("Unable to locate the header with name: $key");
        }

        $users = $this->cache['users'];
        if (! array_key_exists($line[$key], $users)) {
            throw new \DomainException("Participant with identifier `{$line[$key]}` is not enrolled");
        }

        return $users[$line[$key]];
    }

    /**
     * Add an error message to the log file.
     *
     * @param  int  $line
     * @param  array  $data
     * @param  Throwable  $error
     * @return string
     */
    public function formatRejectMessage(int $line, array $data, Throwable $error): string
    {
        $message = $this->formatRejectError($error);

        return sprintf('Rejected line `%s` => %s', $line, $message);
    }

    /**
     * Format the error message.
     *
     * @param  Throwable  $error
     * @return string
     */
    protected function formatRejectError(Throwable $error): string
    {
        $message = $error->getMessage();
        $messages = explode("\n", $message);

        // if message is two lines, with the second line being a JSON string
        if (2 === count($messages)) {
            $errLines = json_decode($messages[1], true);
            if (null !== $errLines) {
                $newErrs = [];
                foreach ($errLines as $key => $values) {
                    foreach ($values as $s) {
                        // $newErrs[] = "{$key} - {$s}";
                        $newErrs[] = $s;
                    }
                }
                $message = $messages[0] . ' ' . implode(' | ', $newErrs);
            }
        }

        return $message;
    }

    /**
     * Process the data for a single line during dry-run.
     *
     * @param  array  $data
     * @return bool
     */
    public function drySave(array $data): bool
    {
        // logger()->debug('Dry saving: ', $data);
        return true;
    }

    /**
     * Returns true if we need to handle row as error if it could not save.
     *
     * @return boolean
     */
    public function isErrorOnFailedSave(): bool
    {
        return $this->isErrorOnFailedSave;
    }

    /**
     * Set the result string.
     *
     * @param string $value
     *
     * @return $this
     */
    public function setResultColumn($value)
    {
        $this->resultColumn = $value;

        return $this;
    }

    /**
     * The result string to be saved with the source file.
     *
     * @return string
     */
    public function getResultColumn()
    {
        return $this->resultColumn;
    }
}
