<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\Test;

use Closure;
use CodeIgniter\Exceptions\FrameworkException;
use CodeIgniter\I18n\Time;
use CodeIgniter\Model;
use Config\App;
use Faker\Factory;
use Faker\Generator;
use InvalidArgumentException;
use RuntimeException;

/**
 * Fabricator
 *
 * Bridge class for using Faker to create example data based on
 * model specifications.
 *
 * @see \CodeIgniter\Test\FabricatorTest
 */
class Fabricator
{
    /**
     * Array of counts for fabricated items
     *
     * @var array
     */
    protected static $tableCounts = [];

    /**
     * Locale-specific Faker instance
     *
     * @var Generator
     */
    protected $faker;

    /**
     * Model instance (can be non-framework if it follows framework design)
     *
     * @var Model|object
     */
    protected $model;

    /**
     * Locale used to initialize Faker
     *
     * @var string
     */
    protected $locale;

    /**
     * Map of properties and their formatter to use
     *
     * @var array|null
     */
    protected $formatters;

    /**
     * Date fields present in the model
     *
     * @var array
     */
    protected $dateFields = [];

    /**
     * Array of data to add or override faked versions
     *
     * @var array
     */
    protected $overrides = [];

    /**
     * Array of single-use data to override faked versions
     *
     * @var array|null
     */
    protected $tempOverrides;

    /**
     * Fields to be modified before applying any formatter.
     *
     * @var array{
     *   unique: array<non-empty-string, array{reset: bool, maxRetries: int}>,
     *   optional: array<non-empty-string, array{weight: float, default: mixed}>,
     *   valid: array<non-empty-string, array{validator: Closure(mixed): bool|null, maxRetries: int}>
     * }
     */
    private array $modifiedFields = ['unique' => [], 'optional' => [], 'valid' => []];

    /**
     * Default formatter to use when nothing is detected
     *
     * @var string
     */
    public $defaultFormatter = 'word';

    /**
     * Store the model instance and initialize Faker to the locale.
     *
     * @param object|string $model      Instance or classname of the model to use
     * @param array|null    $formatters Array of property => formatter
     * @param string|null   $locale     Locale for Faker provider
     *
     * @throws InvalidArgumentException
     */
    public function __construct($model, ?array $formatters = null, ?string $locale = null)
    {
        if (is_string($model)) {
            // Create a new model instance
            $model = model($model, false);
        }

        if (! is_object($model)) {
            throw new InvalidArgumentException(lang('Fabricator.invalidModel'));
        }

        $this->model = $model;

        // If no locale was specified then use the App default
        if ($locale === null) {
            $locale = config(App::class)->defaultLocale;
        }

        // There is no easy way to retrieve the locale from Faker so we will store it
        $this->locale = $locale;

        // Create the locale-specific Generator
        $this->faker = Factory::create($this->locale);

        // Determine eligible date fields
        foreach (['createdField', 'updatedField', 'deletedField'] as $field) {
            if (isset($this->model->{$field})) {
                $this->dateFields[] = $this->model->{$field};
            }
        }

        // Set the formatters
        $this->setFormatters($formatters);
    }

    /**
     * Reset internal counts
     */
    public static function resetCounts()
    {
        self::$tableCounts = [];
    }

    /**
     * Get the count for a specific table
     *
     * @param string $table Name of the target table
     */
    public static function getCount(string $table): int
    {
        return ! isset(self::$tableCounts[$table]) ? 0 : self::$tableCounts[$table];
    }

    /**
     * Set the count for a specific table
     *
     * @param string $table Name of the target table
     * @param int    $count Count value
     *
     * @return int The new count value
     */
    public static function setCount(string $table, int $count): int
    {
        self::$tableCounts[$table] = $count;

        return $count;
    }

    /**
     * Increment the count for a table
     *
     * @param string $table Name of the target table
     *
     * @return int The new count value
     */
    public static function upCount(string $table): int
    {
        return self::setCount($table, self::getCount($table) + 1);
    }

    /**
     * Decrement the count for a table
     *
     * @param string $table Name of the target table
     *
     * @return int The new count value
     */
    public static function downCount(string $table): int
    {
        return self::setCount($table, self::getCount($table) - 1);
    }

    /**
     * Returns the model instance
     *
     * @return object Framework or compatible model
     */
    public function getModel()
    {
        return $this->model;
    }

    /**
     * Returns the locale
     */
    public function getLocale(): string
    {
        return $this->locale;
    }

    /**
     * Returns the Faker generator
     */
    public function getFaker(): Generator
    {
        return $this->faker;
    }

    /**
     * Return and reset tempOverrides
     */
    public function getOverrides(): array
    {
        $overrides = $this->tempOverrides ?? $this->overrides;

        $this->tempOverrides = $this->overrides;

        return $overrides;
    }

    /**
     * Set the overrides, once or persistent
     *
     * @param array $overrides Array of [field => value]
     * @param bool  $persist   Whether these overrides should persist through the next operation
     */
    public function setOverrides(array $overrides = [], $persist = true): self
    {
        if ($persist) {
            $this->overrides = $overrides;
        }

        $this->tempOverrides = $overrides;

        return $this;
    }

    /**
     * Set a field to be unique.
     *
     * @param bool $reset      If set to true, resets the list of existing values
     * @param int  $maxRetries Maximum number of retries to find a unique value,
     *                         After which an OverflowException is thrown.
     */
    public function setUnique(string $field, bool $reset = false, int $maxRetries = 10000): static
    {
        $this->modifiedFields['unique'][$field] = compact('reset', 'maxRetries');

        return $this;
    }

    /**
     * Set a field to be optional.
     *
     * @param float $weight A probability between 0 and 1, 0 means that we always get the default value.
     */
    public function setOptional(string $field, float $weight = 0.5, mixed $default = null): static
    {
        $this->modifiedFields['optional'][$field] = compact('weight', 'default');

        return $this;
    }

    /**
     * Set a field to be valid using a callback.
     *
     * @param Closure(mixed): bool|null $validator  A function returning true for valid values
     * @param int                       $maxRetries Maximum number of retries to find a valid value,
     *                                              After which an OverflowException is thrown.
     */
    public function setValid(string $field, ?Closure $validator = null, int $maxRetries = 10000): static
    {
        $this->modifiedFields['valid'][$field] = compact('validator', 'maxRetries');

        return $this;
    }

    /**
     * Returns the current formatters
     */
    public function getFormatters(): ?array
    {
        return $this->formatters;
    }

    /**
     * Set the formatters to use. Will attempt to autodetect if none are available.
     *
     * @param array|null $formatters Array of [field => formatter], or null to detect
     */
    public function setFormatters(?array $formatters = null): self
    {
        if ($formatters !== null) {
            $this->formatters = $formatters;
        } elseif (method_exists($this->model, 'fake')) {
            $this->formatters = null;
        } else {
            $this->detectFormatters();
        }

        return $this;
    }

    /**
     * Try to identify the appropriate Faker formatter for each field.
     */
    protected function detectFormatters(): self
    {
        $this->formatters = [];

        if (isset($this->model->allowedFields)) {
            foreach ($this->model->allowedFields as $field) {
                $this->formatters[$field] = $this->guessFormatter($field);
            }
        }

        return $this;
    }

    /**
     * Guess at the correct formatter to match a field name.
     *
     * @param string $field Name of the field
     *
     * @return string Name of the formatter
     */
    protected function guessFormatter($field): string
    {
        // First check for a Faker formatter of the same name - covers things like "email"
        try {
            $this->faker->getFormatter($field);

            return $field;
        } catch (InvalidArgumentException) {
            // No match, keep going
        }

        // Next look for known model fields
        if (in_array($field, $this->dateFields, true)) {
            switch ($this->model->dateFormat) {
                case 'datetime':
                case 'date':
                    return 'date';

                case 'int':
                    return 'unixTime';
            }
        } elseif ($field === $this->model->primaryKey) {
            return 'numberBetween';
        }

        // Check some common partials
        foreach (['email', 'name', 'title', 'text', 'date', 'url'] as $term) {
            if (stripos($field, $term) !== false) {
                return $term;
            }
        }

        if (stripos($field, 'phone') !== false) {
            return 'phoneNumber';
        }

        // Nothing left, use the default
        return $this->defaultFormatter;
    }

    /**
     * Generate new entities with faked data
     *
     * @param int|null $count Optional number to create a collection
     *
     * @return array|object An array or object (based on returnType), or an array of returnTypes
     */
    public function make(?int $count = null)
    {
        // If a singleton was requested then go straight to it
        if ($count === null) {
            return $this->model->returnType === 'array'
                ? $this->makeArray()
                : $this->makeObject();
        }

        $return = [];

        for ($i = 0; $i < $count; $i++) {
            $return[] = $this->model->returnType === 'array'
                ? $this->makeArray()
                : $this->makeObject();
        }

        return $return;
    }

    /**
     * Generate an array of faked data
     *
     * @return array An array of faked data
     *
     * @throws RuntimeException
     */
    public function makeArray()
    {
        if ($this->formatters !== null) {
            $result = [];

            foreach ($this->formatters as $field => $formatter) {
                $faker = $this->faker;

                if (isset($this->modifiedFields['unique'][$field])) {
                    $faker = $faker->unique(
                        $this->modifiedFields['unique'][$field]['reset'],
                        $this->modifiedFields['unique'][$field]['maxRetries']
                    );
                }

                if (isset($this->modifiedFields['optional'][$field])) {
                    $faker = $faker->optional(
                        $this->modifiedFields['optional'][$field]['weight'],
                        $this->modifiedFields['optional'][$field]['default']
                    );
                }

                if (isset($this->modifiedFields['valid'][$field])) {
                    $faker = $faker->valid(
                        $this->modifiedFields['valid'][$field]['validator'],
                        $this->modifiedFields['valid'][$field]['maxRetries']
                    );
                }

                $result[$field] = $faker->format($formatter);
            }
        }
        // If no formatters were defined then look for a model fake() method
        elseif (method_exists($this->model, 'fake')) {
            $result = $this->model->fake($this->faker);

            $result = is_object($result) && method_exists($result, 'toArray')
                // This should cover entities
                ? $result->toArray()
                // Try to cast it
                : (array) $result;
        }
        // Nothing left to do but give up
        else {
            throw new RuntimeException(lang('Fabricator.missingFormatters'));
        }

        // Replace overridden fields
        return array_merge($result, $this->getOverrides());
    }

    /**
     * Generate an object of faked data
     *
     * @param string|null $className Class name of the object to create; null to use model default
     *
     * @return object An instance of the class with faked data
     *
     * @throws RuntimeException
     */
    public function makeObject(?string $className = null): object
    {
        if ($className === null) {
            if ($this->model->returnType === 'object' || $this->model->returnType === 'array') {
                $className = 'stdClass';
            } else {
                $className = $this->model->returnType;
            }
        }

        // If using the model's fake() method then check it for the correct return type
        if ($this->formatters === null && method_exists($this->model, 'fake')) {
            $result = $this->model->fake($this->faker);

            if ($result instanceof $className) {
                // Set overrides manually
                foreach ($this->getOverrides() as $key => $value) {
                    $result->{$key} = $value;
                }

                return $result;
            }
        }

        // Get the array values and apply them to the object
        $array  = $this->makeArray();
        $object = new $className();

        // Check for the entity method
        if (method_exists($object, 'fill')) {
            $object->fill($array);
        } else {
            foreach ($array as $key => $value) {
                $object->{$key} = $value;
            }
        }

        return $object;
    }

    /**
     * Generate new entities from the database
     *
     * @param int|null $count Optional number to create a collection
     * @param bool     $mock  Whether to execute or mock the insertion
     *
     * @return array|object An array or object (based on returnType), or an array of returnTypes
     *
     * @throws FrameworkException
     */
    public function create(?int $count = null, bool $mock = false)
    {
        // Intercept mock requests
        if ($mock) {
            return $this->createMock($count);
        }

        $ids = [];

        // Iterate over new entities and insert each one, storing insert IDs
        foreach ($this->make($count ?? 1) as $result) {
            if ($id = $this->model->insert($result, true)) {
                $ids[] = $id;
                self::upCount($this->model->table);

                continue;
            }

            throw FrameworkException::forFabricatorCreateFailed($this->model->table, implode(' ', $this->model->errors() ?? []));
        }

        // If the model defines a "withDeleted" method for handling soft deletes then use it
        if (method_exists($this->model, 'withDeleted')) {
            $this->model->withDeleted();
        }

        return $this->model->find($count === null ? reset($ids) : $ids);
    }

    /**
     * Generate new database entities without actually inserting them
     *
     * @param int|null $count Optional number to create a collection
     *
     * @return array|object An array or object (based on returnType), or an array of returnTypes
     */
    protected function createMock(?int $count = null)
    {
        $datetime = match ($this->model->dateFormat) {
            'datetime' => date('Y-m-d H:i:s'),
            'date'     => date('Y-m-d'),
            default    => Time::now()->getTimestamp(),
        };

        // Determine which fields we will need
        $fields = [];

        if ($this->model->useTimestamps) {
            $fields[$this->model->createdField] = $datetime;
            $fields[$this->model->updatedField] = $datetime;
        }

        if ($this->model->useSoftDeletes) {
            $fields[$this->model->deletedField] = null;
        }

        // Iterate over new entities and add the necessary fields
        $return = [];

        foreach ($this->make($count ?? 1) as $i => $result) {
            // Set the ID
            $fields[$this->model->primaryKey] = $i;

            // Merge fields
            if (is_array($result)) {
                $result = array_merge($result, $fields);
            } else {
                foreach ($fields as $key => $value) {
                    $result->{$key} = $value;
                }
            }

            $return[] = $result;
        }

        return $count === null ? reset($return) : $return;
    }
}
