<?php

declare(strict_types=1);

/*
 * This file is part of the RegexParser package.
 *
 * (c) Younes ENNAJI <younes.ennaji.pro@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace RegexParser\Lint;

/**
 * Extracts regex patterns from PHP source files using configured extractor.
 *
 * This class is responsible for discovering and filtering PHP files,
 * then delegating pattern extraction to configured strategy.
 *
 * @internal
 */
final readonly class RegexPatternExtractor
{
    private const WORKER_ALLOWED_CLASSES = [
        RegexPatternOccurrence::class,
    ];

    private const SUPPRESSION_MARKERS = [
        '@regex-ignore-next-line' => 'next',
        '@regex-ignore' => 'same',
        '@regex-suppress' => 'same',
    ];
    /**
     * Template file suffixes to exclude by default.
     * These files often contain template syntax that can be confused with regex quantifiers.
     */
    private const TEMPLATE_SUFFIXES = [
        '.tpl.php',
        '.blade.php',
        '.twig.php',
    ];

    public function __construct(private ExtractorInterface $extractor) {}

    public static function supportsParallel(): bool
    {
        return \PHP_SAPI === 'cli'
            && \function_exists('pcntl_fork')
            && \function_exists('pcntl_waitpid');
    }

    /**
     * Extract regex patterns from the given paths.
     *
     * @param array<string>                 $paths        Paths to scan for PHP files
     * @param array<string>|null            $excludePaths Optional paths to exclude (falls back to ['vendor'])
     * @param callable(int, int): void|null $progress     Reports collection progress as (current, total)
     * @param int                           $workers      Number of worker processes to use when supported
     *
     * @return array<RegexPatternOccurrence>
     */
    public function extract(array $paths, ?array $excludePaths = null, ?callable $progress = null, int $workers = 1): array
    {
        $excludePaths ??= ['vendor'];
        $phpFiles = $this->collectPhpFiles($paths, $excludePaths);

        $total = \count($phpFiles);
        if (0 === $total) {
            if (null !== $progress) {
                $progress(0, 0);
            }

            return [];
        }

        if ($workers > 1 && $total > 1 && self::supportsParallel()) {
            return $this->applyInlineIgnores($this->extractParallel($phpFiles, $workers, $progress));
        }

        return $this->applyInlineIgnores($this->extractSerial($phpFiles, $progress));
    }

    /**
     * @param array<string>                 $phpFiles
     * @param callable(int, int): void|null $progress
     *
     * @return array<RegexPatternOccurrence>
     */
    private function extractSerial(array $phpFiles, ?callable $progress = null): array
    {
        if (null === $progress) {
            return $this->extractor->extract($phpFiles);
        }

        $total = \count($phpFiles);
        $progress(0, $total);

        $occurrences = [];
        $current = 0;

        foreach ($phpFiles as $file) {
            foreach ($this->extractor->extract([$file]) as $occurrence) {
                $occurrences[] = $occurrence;
            }
            $current++;
            $progress($current, $total);
        }

        return $occurrences;
    }

    /**
     * @param array<string>                 $phpFiles
     * @param callable(int, int): void|null $progress
     *
     * @return array<RegexPatternOccurrence>
     */
    private function extractParallel(array $phpFiles, int $workers, ?callable $progress = null): array
    {
        $total = \count($phpFiles);
        if (null !== $progress) {
            $progress(0, $total);
        }

        $workerCount = max(1, min($workers, $total));
        $chunkSize = max(1, (int) ceil($total / $workerCount));
        $chunks = array_chunk($phpFiles, $chunkSize);
        $children = [];
        $failed = false;

        foreach ($chunks as $index => $chunk) {
            $tmpFile = tempnam(sys_get_temp_dir(), 'regexparser_extract_');
            if (false === $tmpFile) {
                $failed = true;

                break;
            }

            $pid = pcntl_fork();
            if (-1 === $pid) {
                $failed = true;

                break;
            }

            if (0 === $pid) {
                $payload = null;

                try {
                    $payload = ['ok' => true, 'result' => $this->extractor->extract($chunk)];
                } catch (\Throwable $e) {
                    $payload = [
                        'ok' => false,
                        'error' => [
                            'message' => $e->getMessage(),
                            'class' => $e::class,
                        ],
                    ];
                }

                $this->writeWorkerPayload($tmpFile, $payload);
                exit($payload['ok'] ? 0 : 1);
            }

            $children[$pid] = [
                'file' => $tmpFile,
                'index' => $index,
                'count' => \count($chunk),
            ];
        }

        if ($failed) {
            foreach ($children as $pid => $meta) {
                pcntl_waitpid($pid, $status);
                @unlink($meta['file']);
            }

            return $this->extractSerial($phpFiles, $progress);
        }

        $resultsByIndex = [];
        $processed = 0;

        foreach ($children as $pid => $meta) {
            pcntl_waitpid($pid, $status);
            $payload = $this->readWorkerPayload($meta['file']);
            @unlink($meta['file']);

            if (!($payload['ok'] ?? false)) {
                $error = $payload['error'] ?? ['message' => 'Unknown worker failure.', 'class' => \RuntimeException::class];
                $errorClass = \is_array($error) && isset($error['class']) && \is_string($error['class']) ? $error['class'] : \RuntimeException::class;
                $errorMessage = \is_array($error) && isset($error['message']) && \is_string($error['message']) ? $error['message'] : 'Unknown worker failure.';

                throw new \RuntimeException(\sprintf('Parallel collection failed: %s: %s', $errorClass, $errorMessage));
            }

            $resultsByIndex[$meta['index']] = $payload['result'] ?? [];
            if (null !== $progress) {
                $processed += $meta['count'];
                $progress($processed, $total);
            }
        }

        ksort($resultsByIndex);
        /** @var array<RegexPatternOccurrence> $results */
        $results = [];
        foreach ($resultsByIndex as $chunkResults) {
            if (!\is_array($chunkResults)) {
                continue;
            }

            /** @var RegexPatternOccurrence $item */
            foreach ($chunkResults as $item) {
                $results[] = $item;
            }
        }

        return $results;
    }

    /**
     * @param array<string> $paths
     * @param array<string> $excludePaths
     *
     * @return array<string>
     */
    private function collectPhpFiles(array $paths, array $excludePaths): array
    {
        $normalizedExcludePaths = [];
        foreach ($excludePaths as $excludePath) {
            $excludePath = trim($excludePath, '/\\');
            if ('' !== $excludePath) {
                $normalizedExcludePaths[] = $excludePath;
            }
        }

        $files = [];
        foreach ($paths as $path) {
            if ('' === $path) {
                continue;
            }

            if (is_file($path) && str_ends_with($path, '.php') && !$this->isTemplateFile($path)) {
                $files[] = $path;

                continue;
            }

            if (!is_dir($path)) {
                continue;
            }

            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
            );

            /** @var \SplFileInfo $file */
            foreach ($iterator as $file) {
                if (!$file->isFile() || 'php' !== $file->getExtension()) {
                    continue;
                }

                $filePath = $file->getPathname();

                // Skip template files by default
                if ($this->isTemplateFile($filePath)) {
                    continue;
                }

                // Skip excluded directories
                $excluded = false;
                foreach ($normalizedExcludePaths as $excludePath) {
                    if (str_contains($filePath, \DIRECTORY_SEPARATOR.$excludePath.\DIRECTORY_SEPARATOR) || str_starts_with($filePath, $excludePath.\DIRECTORY_SEPARATOR)) {
                        $excluded = true;

                        break;
                    }
                }

                if (!$excluded) {
                    $files[] = $filePath;
                }
            }
        }

        return $files;
    }

    /**
     * @param array{ok: bool, result?: mixed, error?: array{message: string, class: string}} $payload
     */
    private function writeWorkerPayload(string $path, array $payload): void
    {
        $serialized = serialize($payload);
        @file_put_contents($path, $serialized);
    }

    /**
     * @return array{ok: bool, result?: mixed, error?: array{message: string, class: string}}
     */
    private function readWorkerPayload(string $path): array
    {
        $data = @file_get_contents($path);
        if (false === $data) {
            return [
                'ok' => false,
                'error' => [
                    'message' => 'Failed to read worker output.',
                    'class' => \RuntimeException::class,
                ],
            ];
        }

        $payload = @unserialize($data, ['allowed_classes' => self::WORKER_ALLOWED_CLASSES]);
        if (!\is_array($payload) || !\array_key_exists('ok', $payload) || !\is_bool($payload['ok'])) {
            return [
                'ok' => false,
                'error' => [
                    'message' => 'Invalid worker output.',
                    'class' => \RuntimeException::class,
                ],
            ];
        }

        if (false === $payload['ok']) {
            $error = $payload['error'] ?? null;
            if (!\is_array($error) || !isset($error['message'], $error['class']) || !\is_string($error['message']) || !\is_string($error['class'])) {
                return [
                    'ok' => false,
                    'error' => [
                        'message' => 'Invalid worker error payload.',
                        'class' => \RuntimeException::class,
                    ],
                ];
            }

            return [
                'ok' => false,
                'error' => [
                    'message' => $error['message'],
                    'class' => $error['class'],
                ],
            ];
        }

        return [
            'ok' => true,
            'result' => $payload['result'] ?? null,
        ];
    }

    /**
     * @param array<RegexPatternOccurrence> $occurrences
     *
     * @return array<RegexPatternOccurrence>
     */
    private function applyInlineIgnores(array $occurrences): array
    {
        if ([] === $occurrences) {
            return [];
        }

        $suppressedCache = [];
        $result = [];
        foreach ($occurrences as $occurrence) {
            if (!$occurrence instanceof RegexPatternOccurrence) {
                $result[] = $occurrence;

                continue;
            }

            $file = $occurrence->file;
            if (!\array_key_exists($file, $suppressedCache)) {
                $suppressedCache[$file] = $this->collectSuppressedLines($file);
            }

            if (isset($suppressedCache[$file][$occurrence->line])) {
                $result[] = new RegexPatternOccurrence(
                    $occurrence->pattern,
                    $occurrence->file,
                    $occurrence->line,
                    $occurrence->source,
                    $occurrence->displayPattern,
                    $occurrence->location,
                    true,
                    $occurrence->column,
                    $occurrence->fileOffset,
                );
            } else {
                $result[] = $occurrence;
            }
        }

        return $result;
    }

    /**
     * @return array<int, bool>
     */
    private function collectSuppressedLines(string $file): array
    {
        $content = @file_get_contents($file);
        if (false === $content || '' === $content) {
            return [];
        }

        $tokens = token_get_all($content);
        $suppressed = [];

        foreach ($tokens as $token) {
            if (!\is_array($token)) {
                continue;
            }

            if (\T_COMMENT !== $token[0] && \T_DOC_COMMENT !== $token[0]) {
                continue;
            }

            $mode = $this->suppressionMode($token[1]);
            if (null === $mode) {
                continue;
            }

            $startLine = (int) $token[2];
            $endLine = $startLine + substr_count($token[1], "\n");
            $targetLine = 'next' === $mode ? $endLine + 1 : $endLine;

            $suppressed[$targetLine] = true;
        }

        return $suppressed;
    }

    private function suppressionMode(string $comment): ?string
    {
        foreach (self::SUPPRESSION_MARKERS as $marker => $mode) {
            if (str_contains($comment, $marker)) {
                return $mode;
            }
        }

        return null;
    }

    /**
     * Check if a file is a template file that should be excluded.
     */
    private function isTemplateFile(string $filePath): bool
    {
        foreach (self::TEMPLATE_SUFFIXES as $suffix) {
            if (str_ends_with($filePath, $suffix)) {
                return true;
            }
        }

        return false;
    }
}
