<?php

declare(strict_types=1);

namespace Brick\VarExporter\Internal;

use Brick\VarExporter\ExportException;
use Brick\VarExporter\VarExporter;
use ReflectionObject;

use function array_keys;
use function array_merge;
use function array_unshift;
use function count;
use function gettype;
use function implode;
use function is_array;
use function is_object;
use function is_scalar;
use function range;
use function spl_object_id;
use function sprintf;
use function var_export;

/**
 * The main exporter implementation, that handles variables of any type.
 *
 * A GenericExporter is only intended to be used once per array/object graph (i.e. once per `VarExport::export()` call),
 * as it keeps an internal cache of visited objects; if it is ever going to be reused, just implement a reset method to
 * reset the visited objects.
 *
 * @internal This class is for internal use, and not part of the public API. It may change at any time without warning.
 */
final class GenericExporter
{
    /**
     * @psalm-readonly
     */
    public bool $addTypeHints;

    /**
     * @psalm-readonly
     */
    public bool $skipDynamicProperties;

    /**
     * @psalm-readonly
     */
    public bool $inlineArray;

    /**
     * @psalm-readonly
     */
    public bool $inlineScalarList;

    /**
     * @psalm-readonly
     */
    public bool $closureSnapshotUses;

    /**
     * @psalm-readonly
     */
    public bool $trailingCommaInArray;

    /**
     * @psalm-readonly
     */
    public int $indentLevel;

    /**
     * @var ObjectExporter[]
     */
    private array $objectExporters = [];

    /**
     * The visited objects, to detect circular references.
     *
     * This is a two-level map of parent object id => child object id => path where the object first appeared.
     *
     * @var array<int, array<int, string[]>>
     */
    private array $visitedObjects = [];

    public function __construct(int $options, int $indentLevel = 0)
    {
        $this->objectExporters[] = new ObjectExporter\StdClassExporter($this);

        if (($options & VarExporter::NO_CLOSURES) === 0) {
            $this->objectExporters[] = new ObjectExporter\ClosureExporter($this);
        }

        if (($options & VarExporter::NO_SET_STATE) === 0) {
            $this->objectExporters[] = new ObjectExporter\SetStateExporter($this);
        }

        $this->objectExporters[] = new ObjectExporter\InternalClassExporter($this);

        if (($options & VarExporter::NO_SERIALIZE) === 0) {
            $this->objectExporters[] = new ObjectExporter\SerializeExporter($this);
        }

        if (($options & VarExporter::NO_ENUMS) === 0) {
            $this->objectExporters[] = new ObjectExporter\EnumExporter($this);
        }

        if (($options & VarExporter::NOT_ANY_OBJECT) === 0) {
            $this->objectExporters[] = new ObjectExporter\AnyObjectExporter($this);
        }

        $this->addTypeHints = (bool) ($options & VarExporter::ADD_TYPE_HINTS);
        $this->skipDynamicProperties = (bool) ($options & VarExporter::SKIP_DYNAMIC_PROPERTIES);
        $this->inlineArray = (bool) ($options & VarExporter::INLINE_ARRAY);
        $this->inlineScalarList = (bool) ($options & VarExporter::INLINE_SCALAR_LIST);
        $this->closureSnapshotUses = (bool) ($options & VarExporter::CLOSURE_SNAPSHOT_USES);
        $this->trailingCommaInArray = (bool) ($options & VarExporter::TRAILING_COMMA_IN_ARRAY);

        $this->indentLevel = $indentLevel;
    }

    /**
     * @param mixed    $var       The variable to export.
     * @param string[] $path      The path to the current variable in the array/object graph.
     * @param int[]    $parentIds The ids of all objects higher in the graph.
     *
     * @return string[] The lines of code.
     *
     * @throws ExportException
     */
    public function export(mixed $var, array $path, array $parentIds): array
    {
        if ($var === null) {
            return ['null'];
        }

        // bool, int, float, string
        if (is_scalar($var)) {
            return [var_export($var, true)];
        }

        if (is_array($var)) {
            return $this->exportArray($var, $path, $parentIds);
        }

        if (is_object($var)) {
            return $this->exportObject($var, $path, $parentIds);
        }

        // resources
        throw new ExportException(sprintf('Type "%s" is not supported.', gettype($var)), $path);
    }

    /**
     * @param array    $array     The array to export.
     * @param string[] $path      The path to the current array in the array/object graph.
     * @param int[]    $parentIds The ids of all objects higher in the graph.
     *
     * @return string[] The lines of code.
     *
     * @throws ExportException
     *
     * @psalm-suppress MixedAssignment
     */
    public function exportArray(array $array, array $path, array $parentIds): array
    {
        if (! $array) {
            return ['[]'];
        }

        $result = [];

        $count = count($array);
        $isList = array_keys($array) === range(0, $count - 1);

        $current = 0;

        $inline = $this->inlineArray || ($this->inlineScalarList && $isList && $this->isScalarList($array));

        foreach ($array as $key => $value) {
            $isLast = (++$current === $count);

            $newPath = $path;
            $newPath[] = (string) $key;

            $exported = $this->export($value, $newPath, $parentIds);

            if ($inline) {
                $result[] = $isList ? $exported[0] : var_export($key, true) . ' => ' . $exported[0];
            } else {
                $prepend = '';
                $append = '';

                if (! $isList) {
                    $prepend = var_export($key, true) . ' => ';
                }

                if (! $isLast || $this->trailingCommaInArray) {
                    $append = ',';
                }

                $exported = $this->wrap($exported, $prepend, $append);
                $exported = $this->indent($exported);

                $result = array_merge($result, $exported);
            }
        }

        if ($inline) {
            return ['[' . implode(', ', $result) . ']'];
        }

        array_unshift($result, '[');
        $result[] = ']';

        return $result;
    }

    /**
     * @param object   $object    The object to export.
     * @param string[] $path      The path to the current object in the array/object graph.
     * @param int[]    $parentIds The ids of all objects higher in the graph.
     *
     * @return string[] The lines of code.
     *
     * @throws ExportException
     */
    public function exportObject(object $object, array $path, array $parentIds): array
    {
        $id = spl_object_id($object);

        foreach ($parentIds as $parentId) {
            if (isset($this->visitedObjects[$parentId][$id])) {
                throw new ExportException(sprintf(
                    'Object of class "%s" has a circular reference at %s. ' .
                    'Circular references are currently not supported.',
                    $object::class,
                    ExportException::pathToString($this->visitedObjects[$parentId][$id]),
                ), $path);
            }

            $this->visitedObjects[$parentId][$id] = $path;
        }

        $reflectionObject = new ReflectionObject($object);

        foreach ($this->objectExporters as $objectExporter) {
            if ($objectExporter->supports($reflectionObject)) {
                return $objectExporter->export($object, $reflectionObject, $path, $parentIds);
            }
        }

        // This may only happen when an option is given to disallow specific export methods.

        $className = $reflectionObject->getName();

        throw new ExportException('Class "' . $className . '" cannot be exported using the current options.', $path);
    }

    /**
     * Indents every non-empty line.
     *
     * @param string[] $lines The lines of code.
     *
     * @return string[] The indented lines of code.
     */
    public function indent(array $lines): array
    {
        foreach ($lines as &$value) {
            if ($value !== '') {
                $value = '    ' . $value;
            }
        }

        return $lines;
    }

    /**
     * @param string[] $lines   The lines of code.
     * @param string   $prepend The string to prepend to the first line.
     * @param string   $append  The string to append to the last line.
     *
     * @return string[]
     */
    public function wrap(array $lines, string $prepend, string $append): array
    {
        $lines[0] = $prepend . $lines[0];
        $lines[count($lines) - 1] .= $append;

        return $lines;
    }

    /**
     * Returns whether the given array only contains scalar values.
     *
     * Types considered scalar here are int, bool, float, string and null.
     * If the array is empty, this method returns true.
     */
    private function isScalarList(array $array): bool
    {
        foreach ($array as $value) {
            if ($value !== null && ! is_scalar($value)) {
                return false;
            }
        }

        return true;
    }
}
