<?php

namespace NoahVet\Reef\Command\DataFixtures;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;

#[AsCommand(
    name: 'reef:data-fixture:sort',
    description: 'Sorts fixture YAML keys (id first, _ref last, others alphabetical) under the given path',
)]
class SortDataFixtureCommand extends Command implements SortDataFixtureCommandInterface
{
    #[\Override]
    protected function configure(): void
    {
        $this
            ->addArgument(
                'path',
                InputArgument::REQUIRED,
                'File or directory to process (e.g., config/, config/openapi.yaml)',
            )
            ->addOption(
                'dry-run',
                null,
                InputOption::VALUE_NONE,
                'Displays which files would be modified without actually writing them',
            )
        ;
    }

    #[\Override]
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $path = $input->getArgument('path');
        $dryRun = $input->getOption('dry-run');

        if (!\file_exists($path)) {
            $output->writeln("<error>The path '{$path}' does not exist.</error>");

            return Command::FAILURE;
        }

        $success = \is_file($path)
            ? $this->processFile($path, $dryRun, $output)
            : $this->processDirectory($path, $dryRun, $output);

        return $success ? Command::SUCCESS : Command::FAILURE;
    }

    protected function processDirectory(string $directory, bool $dryRun, OutputInterface $output): bool
    {
        $dirIterator = new \RecursiveDirectoryIterator($directory);
        $iterator = new \RecursiveIteratorIterator($dirIterator);
        $yamlFiles = new \RegexIterator($iterator, '/^.+\.(ya?ml)$/i', \RegexIterator::GET_MATCH);
        $allSucceeded = true;

        foreach ($yamlFiles as $files) {
            $file = $files[0];

            if (!$this->processFile($file, $dryRun, $output)) {
                $allSucceeded = false;
            }
        }

        return $allSucceeded;
    }

    protected function processFile(string $filePath, bool $dryRun, OutputInterface $output): bool
    {
        if (!\is_readable($filePath)) {
            $output->writeln("<error>Unable to read file: {$filePath}</error>");

            return false;
        }

        $originalContent = \file_get_contents($filePath);

        if (false === $originalContent) {
            $output->writeln("<error>Unable to read file: {$filePath}</error>");

            return false;
        }

        try {
            $data = Yaml::parseFile($filePath);
        } catch (ParseException $e) {
            $output->writeln("<error>YAML parse error in {$filePath}: {$e->getMessage()}</error>");

            return false;
        }

        // Fichiers vides / null : on ne touche pas
        if (null === $data) {
            return true;
        }

        $structurePreferences = $this->extractEmptyStructurePreferences($originalContent);

        $sorted = $this->sortKeysRecursive($data);

        // Dump YAML trié
        $newContent = Yaml::dump(
            $sorted,
            10,  // profondeur inline max
            2,   // indentation (2 espaces)
            Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE,
        );

        // 1) Fusion : "  -" + "\n" + "    id: ..."  =>  "  - id: ..."
        $lines = \explode("\n", $newContent);
        $fixedLines = [];
        $count = \count($lines);

        for ($i = 0; $i < $count; ++$i) {
            $line = $lines[$i];

            if (\preg_match('/^(\s*)-\s*$/', $line, $m) && isset($lines[$i + 1])) {
                $nextLine = $lines[$i + 1];
                $nextTrimmed = \ltrim($nextLine);
                $nextIndent = \strlen($nextLine) - \strlen($nextTrimmed);

                if (\str_starts_with($nextTrimmed, 'id:')) {
                    $afterId = \substr($nextTrimmed, \strlen('id:')); // " xxx" ou ""

                    $fixedLines[] = $m[1].'- id:'.$afterId;
                    ++$i; // on saute la ligne suivante fusionnée

                    continue;
                }

                if ($nextIndent > \strlen($m[1]) && $this->isMapEntryLine($nextLine)) {
                    $fixedLines[] = $m[1].'- '.$nextTrimmed;
                    ++$i;

                    continue;
                }

                if ($nextIndent > \strlen($m[1]) && $this->isPlainScalarLine($nextLine)) {
                    $fixedLines[] = $m[1].'- '.$nextTrimmed;
                    ++$i;

                    continue;
                }
            }

            $fixedLines[] = $line;
        }

        $newContent = \implode("\n", $fixedLines);

        $newContent = $this->restoreComments($originalContent, $newContent);
        $newContent = $this->addBlankLinesBeforeMapItems($newContent);
        $newContent = $this->removeEmptyLines($newContent);
        $newContent = $this->normalizeInlineEmptyStructures($newContent);
        $newContent = $this->applyEmptyStructureStyle(
            $newContent,
            $structurePreferences['sequences'],
            $structurePreferences['maps'],
        );

        if ($newContent === $originalContent) {
            return true;
        }

        if ($dryRun) {
            $output->writeln("<comment>[DRY-RUN]</comment> {$filePath} would be modified.");

            return true;
        }
        if (false === \file_put_contents($filePath, $newContent)) {
            $output->writeln("<error>Unable to write file: {$filePath}</error>");

            return false;
        }

        $output->writeln("<info>Sorted keys:</info> {$filePath}");

        return true;
    }

    protected function normalizeInlineEmptyStructures(string $content): string
    {
        $replacements = [
            ': []' => ': [ ]',
            ': {}' => ': { }',
            ': {  }' => ': { }',
        ];

        return \strtr($content, $replacements);
    }

    /**
     * @return array{sequences: array<string, bool>, maps: array<string, bool>}
     */
    protected function extractEmptyStructurePreferences(string $content): array
    {
        $sequenceKeys = [];
        $mapKeys = [];

        $lines = \explode("\n", $content);

        foreach ($lines as $line) {
            $trimmed = \trim($line);

            if ('' === $trimmed || $this->isCommentLine($trimmed)) {
                continue;
            }

            if (1 === \preg_match('/^[\\s-]*([\"\\\'][^\"\\\']+[\"\\\']|[^\\s:#]+):\\s*(\\[\\s*\\]|\\{\\s*\\})\\s*$/', $line, $m)) {
                $key = $this->normalizeKeyName($m[1]);
                $style = $m[2];

                if ('[]' === \str_replace(' ', '', $style)) {
                    $sequenceKeys[$key] = true;
                } else {
                    $mapKeys[$key] = true;
                }
            }
        }

        return [
            'sequences' => $sequenceKeys,
            'maps' => $mapKeys,
        ];
    }

    /**
     * @param array<string, bool> $sequenceKeys
     * @param array<string, bool> $mapKeys
     */
    protected function applyEmptyStructureStyle(string $content, array $sequenceKeys, array $mapKeys): string
    {
        $lines = \explode("\n", $content);

        foreach ($lines as &$line) {
            if (1 !== \preg_match('/^(\s*)([\"\']?[^\"\'\s:#]+[\"\']?):\s*(\{\s*\}|\[\s*\])\s*$/', $line, $m)) {
                continue;
            }

            $key = $this->normalizeKeyName($m[2]);

            if (isset($sequenceKeys[$key])) {
                $line = $m[1].$m[2].': [ ]';
            } elseif (isset($mapKeys[$key])) {
                $line = $m[1].$m[2].': { }';
            }
        }

        unset($line);

        return \implode("\n", $lines);
    }

    protected function sortKeysRecursive(mixed $value): mixed
    {
        if (!\is_array($value)) {
            return $value;
        }

        if ($this->isList($value)) {
            foreach ($value as $k => $v) {
                $value[$k] = $this->sortKeysRecursive($v);
            }

            return $value;
        }

        $keys = \array_keys($value);

        \usort(
            $keys,
            function (string|int $a, string|int $b): int {
                $sa = (string) $a;
                $sb = (string) $b;

                $pa = $this->getKeyPriority($sa);
                $pb = $this->getKeyPriority($sb);

                if ($pa !== $pb) {
                    return $pa <=> $pb;
                }

                return \strcasecmp($sa, $sb);
            },
        );

        $sorted = [];
        foreach ($keys as $key) {
            $normalizedKey = $this->normalizeKey($key, $value[$key]);
            $sorted[$normalizedKey] = $this->sortKeysRecursive($value[$key]);
        }

        return $sorted;
    }

    /**
     * @param array<mixed, mixed> $array
     */
    protected function isList(array $array): bool
    {
        return [] === $array || \array_is_list($array);
    }

    protected function getKeyPriority(string $key): int
    {
        $normalized = \strtolower($key);

        if ('id' === $normalized) {
            return 0;
        }

        if ('_ref' === $normalized) {
            return 3;
        }

        if ('' !== $normalized && '_' === $normalized[0]) {
            return 2;
        }

        return 1;
    }

    protected function normalizeKey(string|int $key, mixed $value): string|int
    {
        return $key;
    }

    protected function restoreComments(string $originalContent, string $sortedContent): string
    {
        $context = $this->extractCommentContext($originalContent);

        if ([] === $context['leading'] && [] === $context['map'] && [] === $context['trailing']) {
            return $sortedContent;
        }

        $hasTrailingNewLine = \str_ends_with($sortedContent, "\n");
        $sortedLines = \explode("\n", $sortedContent);

        $withComments = $context['leading'];

        foreach ($sortedLines as $line) {
            $signature = $this->getLineSignature($line);

            if (null !== $signature && isset($context['map'][$signature])) {
                $block = \array_shift($context['map'][$signature]);

                if (null === $block) {
                    $withComments[] = $line;

                    continue;
                }

                if ([] === $context['map'][$signature]) {
                    unset($context['map'][$signature]);
                }

                foreach ($block as $commentLine) {
                    $withComments[] = $commentLine;
                }
            }

            $withComments[] = $line;
        }

        if ([] !== $context['trailing']) {
            $withComments = \array_merge($withComments, $context['trailing']);
        }

        $result = \implode("\n", $withComments);

        if ($hasTrailingNewLine && !\str_ends_with($result, "\n")) {
            $result .= "\n";
        }

        return $result;
    }

    /**
     * @return array{
     *     leading: array<int, string>,
     *     map: array<string, list<list<string>>>,
     *     trailing: array<int, string>
     * }
     */
    protected function extractCommentContext(string $content): array
    {
        $lines = \explode("\n", $content);

        /** @var array<int, string> $leading */
        $leading = [];
        /** @var array<string, list<list<string>>> $commentMap */
        $commentMap = [];
        /** @var array<int, string> $trailing */
        $trailing = [];
        $commentBuffer = [];
        $hasSeenContent = false;
        $inBlockScalar = false;
        $blockIndent = 0;

        foreach ($lines as $line) {
            $indent = $this->countIndent($line);

            if ($inBlockScalar) {
                if ($indent > $blockIndent) {
                    continue;
                }

                $inBlockScalar = false;
            }

            $startsBlockScalar = $this->startsBlockScalar($line);

            if ($this->isCommentLine($line)) {
                $commentBuffer[] = $line;

                continue;
            }

            $trimmed = \trim($line);

            if ('' === $trimmed) {
                if (!empty($commentBuffer)) {
                    $commentBuffer[] = $line;
                }

                continue;
            }

            if (!empty($commentBuffer)) {
                $signature = $this->getLineSignature($line);

                if (null !== $signature) {
                    $commentMap[$signature][] = $commentBuffer;
                } elseif (!$hasSeenContent) {
                    $leading = \array_merge($leading, $commentBuffer);
                }

                $commentBuffer = [];
            }

            $hasSeenContent = true;

            if ($startsBlockScalar) {
                $inBlockScalar = true;
                $blockIndent = $indent;
            }
        }

        if (!empty($commentBuffer)) {
            if ($hasSeenContent) {
                $trailing = $commentBuffer;
            } else {
                $leading = \array_merge($leading, $commentBuffer);
            }
        }

        return [
            'leading' => $leading,
            'map' => $commentMap,
            'trailing' => $trailing,
        ];
    }

    protected function addBlankLinesBeforeMapItems(string $content): string
    {
        $lines = \explode("\n", $content);
        $spacedLines = [];
        /** @var array<int, bool> $seenMapItemAtIndent */
        $seenMapItemAtIndent = [];
        /** @var array<int, bool> $skipNextMapItemSpacing */
        $skipNextMapItemSpacing = [];
        $count = \count($lines);

        for ($i = 0; $i < $count; ++$i) {
            $line = $lines[$i];
            $trim = \ltrim($line);
            $indent = \strlen($line) - \strlen($trim);

            $isMapItem = $this->isMapListItemLine($line);
            $isLeadingCommentForMapItem = false;

            if ($this->isCommentLine($trim)) {
                $next = $this->findNextNonEmptyNonCommentLineIndex($lines, $i);

                if (null !== $next) {
                    $nextLine = $lines[$next];

                    if ($this->isMapListItemLine($nextLine) && $this->countIndent($nextLine) === $indent) {
                        $isLeadingCommentForMapItem = true;
                    }
                }
            }

            $skipSpacing = false;

            if ($isMapItem && !empty($skipNextMapItemSpacing[$indent])) {
                $skipSpacing = true;
                $skipNextMapItemSpacing[$indent] = false;
            }

            $shouldInsertSpacing = !$skipSpacing
                && ($isMapItem || $isLeadingCommentForMapItem)
                && !empty($seenMapItemAtIndent[$indent])
                && ($isLeadingCommentForMapItem || 2 >= $indent);

            if ($shouldInsertSpacing) {
                if (!empty($spacedLines)) {
                    $lastOut = \end($spacedLines);

                    if ('' !== \trim($lastOut)) {
                        $spacedLines[] = '';
                    }
                }
            }

            $spacedLines[] = $line;

            if ($isMapItem) {
                $seenMapItemAtIndent[$indent] = true;
            }

            if ($isLeadingCommentForMapItem) {
                $skipNextMapItemSpacing[$indent] = true;
            }
        }

        return \implode("\n", $spacedLines);
    }

    protected function removeEmptyLines(string $content): string
    {
        $hasTrailingNewLine = \str_ends_with($content, "\n");
        $lines = \explode("\n", $content);
        $cleaned = [];
        $inBlockScalar = false;
        $blockIndent = 0;

        $lineCount = \count($lines);

        for ($i = 0; $i < $lineCount; ++$i) {
            $line = $lines[$i];
            $indent = $this->countIndent($line);

            if ($inBlockScalar) {
                if ($indent > $blockIndent) {
                    $cleaned[] = $line;

                    continue;
                }

                $inBlockScalar = false;
            }

            if ($this->startsBlockScalar($line)) {
                $inBlockScalar = true;
                $blockIndent = $indent;
                $cleaned[] = $line;

                continue;
            }

            if ('' === \trim($line)) {
                if ($this->shouldRemoveEmptyLine($lines, $i)) {
                    continue;
                }
            }

            $cleaned[] = $line;
        }

        $result = \implode("\n", $cleaned);

        if ($hasTrailingNewLine && !\str_ends_with($result, "\n")) {
            $result .= "\n";
        }

        return $result;
    }

    /**
     * @param array<int, string> $lines
     */
    protected function findNextNonEmptyNonCommentLineIndex(array $lines, int $start): ?int
    {
        $count = \count($lines);

        for ($i = $start + 1; $i < $count; ++$i) {
            $trim = \trim($lines[$i]);

            if ('' === $trim) {
                continue;
            }

            if ($this->isCommentLine($trim)) {
                continue;
            }

            return $i;
        }

        return null;
    }

    /**
     * Removes empty lines only when they sit between scalar list items at the same indentation.
     *
     * @param array<int, string> $lines
     */
    protected function shouldRemoveEmptyLine(array $lines, int $index): bool
    {
        $prev = $this->findPreviousNonEmptyLineIndex($lines, $index);
        $next = $this->findNextNonEmptyLineIndex($lines, $index);

        if (null === $prev || null === $next) {
            return false;
        }

        $prevLine = $lines[$prev];
        $nextLine = $lines[$next];

        if (!$this->isScalarListItemLine($prevLine) || !$this->isScalarListItemLine($nextLine)) {
            return false;
        }

        return $this->countIndent($prevLine) === $this->countIndent($nextLine);
    }

    /**
     * @param array<int, string> $lines
     */
    protected function findPreviousNonEmptyLineIndex(array $lines, int $start): ?int
    {
        for ($i = $start - 1; 0 <= $i; --$i) {
            if ('' !== \trim($lines[$i])) {
                return $i;
            }
        }

        return null;
    }

    /**
     * @param array<int, string> $lines
     */
    protected function findNextNonEmptyLineIndex(array $lines, int $start): ?int
    {
        $count = \count($lines);

        for ($i = $start + 1; $i < $count; ++$i) {
            if ('' !== \trim($lines[$i])) {
                return $i;
            }
        }

        return null;
    }

    protected function isScalarListItemLine(string $line): bool
    {
        $trimmed = \ltrim($line);

        if (1 !== \preg_match('/^-\s+(.+)$/', $trimmed, $m)) {
            return false;
        }

        // A scalar list item is one that does not start an inline map.
        return !\str_contains($m[1], ':');
    }

    protected function isPlainScalarLine(string $line): bool
    {
        $trimmed = \ltrim($line);

        if ('' === $trimmed || $this->isCommentLine($trimmed)) {
            return false;
        }

        if ($this->startsBlockScalar($line)) {
            return false;
        }

        if (1 === \preg_match('/^-\s+/', $trimmed)) {
            return false;
        }

        if (1 === \preg_match('/^([\"\\\'][^\"\\\']+[\"\\\']|[^\\s:#]+)\s*:/', $trimmed)) {
            return false;
        }

        return true;
    }

    protected function isMapEntryLine(string $line): bool
    {
        $trimmed = \ltrim($line);

        if ('' === $trimmed || $this->isCommentLine($trimmed)) {
            return false;
        }

        return 1 === \preg_match('/^([\"\\\'][^\"\\\']+[\"\\\']|[^\\s:#]+)\s*:/', $trimmed);
    }

    protected function isMapListItemLine(string $line): bool
    {
        $trimmed = \ltrim($line);

        if (1 !== \preg_match('/^-\s+(.+)$/', $trimmed, $m)) {
            return false;
        }

        return \str_contains($m[1], ':');
    }

    protected function isCommentLine(string $line): bool
    {
        return 1 === \preg_match('/^\s*#/', $line);
    }

    protected function getLineSignature(string $line): ?string
    {
        $trimmed = \ltrim($line);

        if ('' === $trimmed) {
            return null;
        }

        $indent = $this->countIndent($line);

        if (1 === \preg_match('/^-\s*$/', $trimmed)) {
            return $indent.'|list-map';
        }

        if (1 === \preg_match('/^-\s+([\"\\\'][^\"\\\']+[\"\\\']|[^\\s:#]+)\s*:/', $trimmed)) {
            return $indent.'|list-map';
        }

        if (1 === \preg_match('/^([\"\\\'][^\"\\\']+[\"\\\']|[^\\s:#]+)\s*:/', $trimmed, $m)) {
            return $indent.'|map|'.$this->normalizeKeyName($m[1]);
        }

        if (1 === \preg_match('/^-\s+(.+)/', $trimmed, $m)) {
            return $indent.'|list|'.$this->normalizeKeyName($m[1]);
        }

        return null;
    }

    protected function normalizeKeyName(string $raw): string
    {
        return \trim($raw, " \t\n\r\0\x0B'\"");
    }

    protected function countIndent(string $line): int
    {
        return \strspn($line, ' ');
    }

    protected function startsBlockScalar(string $line): bool
    {
        $trimmed = \ltrim($line);

        if ('' === $trimmed || $this->isCommentLine($trimmed)) {
            return false;
        }

        return 1 === \preg_match('/:\s*[|>][-+]?\\s*$/', $trimmed);
    }
}
