<?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;

use RegexParser\Internal\PatternParser;
use RegexParser\Node\AlternationNode;
use RegexParser\Node\CharClassNode;
use RegexParser\Node\ClassOperationNode;
use RegexParser\Node\ConditionalNode;
use RegexParser\Node\DefineNode;
use RegexParser\Node\GroupNode;
use RegexParser\Node\GroupType;
use RegexParser\Node\NodeInterface;
use RegexParser\Node\QuantifierNode;
use RegexParser\Node\QuantifierType;
use RegexParser\Node\RangeNode;
use RegexParser\Node\RegexNode;
use RegexParser\Node\SequenceNode;
use RegexParser\NodeVisitor\CompilerNodeVisitor;
use RegexParser\NodeVisitor\ConsoleHighlighterVisitor;
use RegexParser\NodeVisitor\LinterNodeVisitor;
use RegexParser\NodeVisitor\OptimizerNodeVisitor;
use RegexParser\OptimizationResult;
use RegexParser\ReDoS\ReDoSAnalysis;
use RegexParser\ReDoS\ReDoSConfirmOptions;
use RegexParser\ReDoS\ReDoSMode;
use RegexParser\ReDoS\ReDoSSeverity;
use RegexParser\Regex;
use RegexParser\ValidationErrorCategory;
use RegexParser\ValidationResult;

/**
 * Handles regex-related analysis and transformations.
 */
final readonly class RegexAnalysisService
{
    private const PATTERN_DELIMITERS = ['/', '#', '~', '%'];
    private const ISSUE_ID_COMPLEXITY = 'regex.lint.complexity';
    private const ISSUE_ID_REDOS = 'regex.lint.redos';
    private const RISK_LINT_ISSUE_IDS = [
        'regex.lint.quantifier.nested' => true,
        'regex.lint.dotstar.nested' => true,
    ];

    private ReDoSSeverity $redosSeverityThreshold;

    /**
     * @var array<string>
     */
    private array $ignoredPatterns;

    private ReDoSMode $redosMode;

    /**
     * @param array<string> $ignoredPatterns
     * @param array<string> $redosIgnoredPatterns
     */
    public function __construct(
        private Regex $regex,
        private ?RegexPatternExtractor $extractor = null,
        private int $warningThreshold = 50,
        string $redosThreshold = ReDoSSeverity::HIGH->value,
        array $ignoredPatterns = [],
        array $redosIgnoredPatterns = [],
        private bool $ignoreParseErrors = false,
        ReDoSMode|string $redosMode = ReDoSMode::THEORETICAL,
        private ?ReDoSConfirmOptions $redosConfirmOptions = null,
    ) {
        $this->redosSeverityThreshold = ReDoSSeverity::tryFrom(strtolower($redosThreshold)) ?? ReDoSSeverity::HIGH;
        $this->ignoredPatterns = $this->buildIgnoredPatterns($ignoredPatterns, $redosIgnoredPatterns);
        $this->redosMode = $redosMode instanceof ReDoSMode
            ? $redosMode
            : (ReDoSMode::tryFrom(strtolower((string) $redosMode)) ?? ReDoSMode::THEORETICAL);
    }

    public function getRegex(): Regex
    {
        return $this->regex;
    }

    /**
     * @param array<string> $paths
     * @param array<string> $excludePaths
     *
     * @return array<RegexPatternOccurrence>
     */
    public function scan(array $paths, array $excludePaths): array
    {
        $extractor = $this->extractor ?? new RegexPatternExtractor(
            new TokenBasedExtractionStrategy(),
        );

        return $extractor->extract($paths, $excludePaths);
    }

    /**
     * @param array<RegexPatternOccurrence> $patterns
     *
     * @return array<array{type: string, file: string, line: int, column: int, fileOffset?: int|null, position?: int, message: string, issueId?: string, hint?: string|null, suggestedPattern?: string, source?: string, analysis?: ReDoSAnalysis, validation?: ValidationResult}>
     */
    public function lint(array $patterns, ?callable $progress = null, int $workers = 1): array
    {
        if ($workers <= 1 || \count($patterns) <= 1 || !$this->canRunInParallel()) {
            return $this->lintChunk($patterns, $progress);
        }

        return $this->runInParallel(
            $patterns,
            $workers,
            fn (array $chunk): array => $this->lintChunk($chunk),
            $progress,
        );
    }

    /**
     * @param array<RegexPatternOccurrence> $patterns
     *
     * @return array<array{file: string, line: int, column?: int, fileOffset?: int|null, analysis: ReDoSAnalysis}>
     */
    public function analyzeRedos(array $patterns, ReDoSSeverity $threshold, int $workers = 1): array
    {
        if ($workers <= 1 || \count($patterns) <= 1 || !$this->canRunInParallel()) {
            return $this->analyzeRedosChunk($patterns, $threshold);
        }

        return $this->runInParallel(
            $patterns,
            $workers,
            fn (array $chunk): array => $this->analyzeRedosChunk($chunk, $threshold),
        );
    }

    /**
     * @param array<RegexPatternOccurrence>                                                                                                                                           $patterns
     * @param array{digits?: bool, word?: bool, ranges?: bool, canonicalizeCharClasses?: bool, autoPossessify?: bool, allowAlternationFactorization?: bool, minQuantifierCount?: int} $optimizationConfig
     *
     * @return array<array{
     *     file: string,
     *     line: int,
     *     column?: int,
     *     fileOffset?: int|null,
     *     optimization: OptimizationResult,
     *     savings: int,
     *     source?: string
     * }>
     */
    public function suggestOptimizations(array $patterns, int $minSavings, array $optimizationConfig = [], int $workers = 1): array
    {
        if ($workers <= 1 || \count($patterns) <= 1 || !$this->canRunInParallel()) {
            return $this->suggestOptimizationsChunk($patterns, $minSavings, $optimizationConfig);
        }

        return $this->runInParallel(
            $patterns,
            $workers,
            fn (array $chunk): array => $this->suggestOptimizationsChunk($chunk, $minSavings, $optimizationConfig),
        );
    }

    public function highlight(string $pattern): string
    {
        $ast = $this->regex->parse($pattern);

        return $ast->accept(new ConsoleHighlighterVisitor());
    }

    public function highlightBody(string $body, string $flags = '', string $delimiter = '/'): string
    {
        $ast = $this->regex->parsePattern($body, $flags, $delimiter);

        return $ast->accept(new ConsoleHighlighterVisitor());
    }

    /**
     * @param array<RegexPatternOccurrence> $patterns
     *
     * @return array<array{type: string, file: string, line: int, column: int, position?: int, message: string, issueId?: string, hint?: string|null, suggestedPattern?: string, source?: string, analysis?: ReDoSAnalysis, validation?: ValidationResult}>
     */
    private function lintChunk(array $patterns, ?callable $progress = null): array
    {
        $issues = [];

        foreach ($patterns as $occurrence) {
            if ($occurrence->isIgnored) {
                if ($progress) {
                    $progress();
                }

                continue;
            }

            $validation = $this->regex->validate($occurrence->pattern);
            $source = $occurrence->source;
            if (!$validation->isValid) {
                $message = $validation->error ?? 'Invalid regex.';
                if ($this->ignoreParseErrors && $this->isLikelyPartialRegexError($message)) {
                    if ($progress) {
                        $progress();
                    }

                    continue;
                }

                $issues[] = [
                    'type' => 'error',
                    'file' => $occurrence->file,
                    'line' => $occurrence->line,
                    'column' => $this->resolveColumn($occurrence),
                    'fileOffset' => $occurrence->fileOffset,
                    'position' => $validation->offset,
                    'message' => $message,
                    'source' => $source,
                    'validation' => $validation,
                    'tip' => $this->getTipForValidationError($message, $occurrence->pattern, $validation),
                ];

                if ($progress) {
                    $progress();
                }

                continue;
            }

            $ast = $this->regex->parse($occurrence->pattern);
            $linter = new LinterNodeVisitor();
            $ast->accept($linter);
            $skipRiskAnalysis = $this->shouldSkipRiskAnalysis($occurrence);

            foreach ($linter->getIssues() as $issue) {
                if ($skipRiskAnalysis && isset(self::RISK_LINT_ISSUE_IDS[$issue->id])) {
                    continue;
                }

                $suggestedPattern = null;
                if (isset(self::RISK_LINT_ISSUE_IDS[$issue->id]) && \is_int($issue->offset)) {
                    $suggestedPattern = $this->buildAtomicGroupSuggestion($occurrence->pattern, $ast, $issue->offset);
                }

                $issueEntry = [
                    'type' => 'warning',
                    'file' => $occurrence->file,
                    'line' => $occurrence->line,
                    'column' => $this->resolveColumn($occurrence),
                    'fileOffset' => $occurrence->fileOffset,
                    'position' => $issue->offset,
                    'issueId' => $issue->id,
                    'message' => $issue->message,
                    'hint' => $issue->hint,
                    'source' => $source,
                ];

                if (null !== $suggestedPattern && $suggestedPattern !== $occurrence->pattern) {
                    $issueEntry['suggestedPattern'] = $suggestedPattern;
                }

                $issues[] = $issueEntry;
            }

            if (!$skipRiskAnalysis) {
                if ($validation->complexityScore >= $this->warningThreshold) {
                    $issues[] = [
                        'type' => 'warning',
                        'file' => $occurrence->file,
                        'line' => $occurrence->line,
                        'column' => $this->resolveColumn($occurrence),
                        'fileOffset' => $occurrence->fileOffset,
                        'issueId' => self::ISSUE_ID_COMPLEXITY,
                        'message' => \sprintf('Pattern is complex (score: %d).', $validation->complexityScore),
                        'source' => $source,
                    ];
                }

                $redos = $this->regex->redos(
                    $occurrence->pattern,
                    $this->redosSeverityThreshold,
                    $this->redosMode,
                    $this->redosConfirmOptions,
                );
                if ($redos->exceedsThreshold($this->redosSeverityThreshold)) {
                    $issues[] = [
                        'type' => $this->resolveRedosIssueType($redos),
                        'file' => $occurrence->file,
                        'line' => $occurrence->line,
                        'column' => $this->resolveColumn($occurrence),
                        'fileOffset' => $occurrence->fileOffset,
                        'issueId' => self::ISSUE_ID_REDOS,
                        'message' => $this->formatRedosMessage($redos),
                        'hint' => $this->getReDoSHint($redos, $occurrence->pattern),
                        'source' => $source,
                        'analysis' => $redos,
                    ];
                }
            }

            if ($progress) {
                $progress();
            }
        }

        return $issues;
    }

    private function resolveColumn(RegexPatternOccurrence $occurrence): int
    {
        return $occurrence->column ?? 1;
    }

    private function buildAtomicGroupSuggestion(string $pattern, RegexNode $ast, int $offset): ?string
    {
        $quantifier = $this->findQuantifierByOffset($ast->pattern, $offset);
        if (null === $quantifier) {
            return null;
        }

        if (QuantifierType::T_POSSESSIVE === $quantifier->type) {
            return null;
        }

        if ($quantifier->node instanceof GroupNode && GroupType::T_GROUP_ATOMIC === $quantifier->node->type) {
            return null;
        }

        $target = $quantifier->node;
        $start = $target->getStartPosition();
        $end = $target->getEndPosition();

        try {
            [$body, $flags, $delimiter] = PatternParser::extractPatternAndFlags($pattern);
        } catch (\Throwable) {
            return null;
        }

        $length = \strlen($body);
        if ($start < 0 || $end <= $start || $end > $length) {
            return null;
        }

        $suggestedBody = substr($body, 0, $start).'(?>'.substr($body, $start, $end - $start).')'.substr($body, $end);

        try {
            $this->regex->parsePattern($suggestedBody, $flags, $delimiter);
        } catch (\Throwable) {
            return null;
        }

        $closingDelimiter = PatternParser::closingDelimiter($delimiter);

        return $delimiter.$suggestedBody.$closingDelimiter.$flags;
    }

    private function findQuantifierByOffset(NodeInterface $node, int $offset): ?QuantifierNode
    {
        if ($node instanceof QuantifierNode) {
            if ($node->getStartPosition() === $offset) {
                return $node;
            }

            return $this->findQuantifierByOffset($node->node, $offset);
        }

        if ($node instanceof RegexNode) {
            return $this->findQuantifierByOffset($node->pattern, $offset);
        }

        if ($node instanceof GroupNode) {
            return $this->findQuantifierByOffset($node->child, $offset);
        }

        if ($node instanceof SequenceNode) {
            foreach ($node->children as $child) {
                $found = $this->findQuantifierByOffset($child, $offset);
                if (null !== $found) {
                    return $found;
                }
            }

            return null;
        }

        if ($node instanceof AlternationNode) {
            foreach ($node->alternatives as $alt) {
                $found = $this->findQuantifierByOffset($alt, $offset);
                if (null !== $found) {
                    return $found;
                }
            }

            return null;
        }

        if ($node instanceof ConditionalNode) {
            foreach ([$node->condition, $node->yes, $node->no] as $child) {
                $found = $this->findQuantifierByOffset($child, $offset);
                if (null !== $found) {
                    return $found;
                }
            }

            return null;
        }

        if ($node instanceof DefineNode) {
            return $this->findQuantifierByOffset($node->content, $offset);
        }

        if ($node instanceof CharClassNode) {
            return $this->findQuantifierByOffset($node->expression, $offset);
        }

        if ($node instanceof ClassOperationNode) {
            $found = $this->findQuantifierByOffset($node->left, $offset);
            if (null !== $found) {
                return $found;
            }

            return $this->findQuantifierByOffset($node->right, $offset);
        }

        if ($node instanceof RangeNode) {
            $found = $this->findQuantifierByOffset($node->start, $offset);
            if (null !== $found) {
                return $found;
            }

            return $this->findQuantifierByOffset($node->end, $offset);
        }

        return null;
    }

    /**
     * @param array<RegexPatternOccurrence> $patterns
     *
     * @return array<array{file: string, line: int, analysis: ReDoSAnalysis}>
     */
    private function analyzeRedosChunk(array $patterns, ReDoSSeverity $threshold): array
    {
        $issues = [];

        foreach ($patterns as $occurrence) {
            if ($occurrence->isIgnored) {
                continue;
            }

            $validation = $this->regex->validate($occurrence->pattern);
            if (!$validation->isValid) {
                continue;
            }

            $analysis = $this->regex->redos(
                $occurrence->pattern,
                $threshold,
                $this->redosMode,
                $this->redosConfirmOptions,
            );
            if (!$analysis->exceedsThreshold($threshold)) {
                continue;
            }

            $issues[] = [
                'file' => $occurrence->file,
                'line' => $occurrence->line,
                'column' => $this->resolveColumn($occurrence),
                'fileOffset' => $occurrence->fileOffset,
                'analysis' => $analysis,
            ];
        }

        return $issues;
    }

    /**
     * @param array<RegexPatternOccurrence>                                                                                                                                           $patterns
     * @param array{digits?: bool, word?: bool, ranges?: bool, canonicalizeCharClasses?: bool, autoPossessify?: bool, allowAlternationFactorization?: bool, minQuantifierCount?: int} $optimizationConfig
     *
     * @return array<array{
     *     file: string,
     *     line: int,
     *     optimization: OptimizationResult,
     *     savings: int,
     *     source?: string
     * }>
     */
    private function suggestOptimizationsChunk(array $patterns, int $minSavings, array $optimizationConfig = []): array
    {
        $suggestions = [];

        foreach ($patterns as $occurrence) {
            if ($occurrence->isIgnored) {
                continue;
            }

            $validation = $this->regex->validate($occurrence->pattern);
            $source = $occurrence->source;
            if (!$validation->isValid) {
                continue;
            }

            $isExtended = $this->usesExtendedMode($occurrence->pattern);

            try {
                if ($isExtended) {
                    $optimizer = new OptimizerNodeVisitor(
                        optimizeDigits: (bool) ($optimizationConfig['digits'] ?? true),
                        optimizeWord: (bool) ($optimizationConfig['word'] ?? true),
                        ranges: (bool) ($optimizationConfig['ranges'] ?? true),
                        canonicalizeCharClasses: (bool) ($optimizationConfig['canonicalizeCharClasses'] ?? true),
                        autoPossessify: (bool) ($optimizationConfig['autoPossessify'] ?? false),
                        allowAlternationFactorization: false,
                        minQuantifierCount: (int) ($optimizationConfig['minQuantifierCount'] ?? 4),
                    );

                    $ast = $this->regex->parse($occurrence->pattern);
                    $pretty = str_contains($ast->flags, 'x');
                    $baseline = $ast->accept(new CompilerNodeVisitor($pretty));

                    $optimizedAst = $ast->accept($optimizer);
                    $optimizedPattern = $optimizedAst->accept(new CompilerNodeVisitor($pretty));

                    if ($baseline === $optimizedPattern) {
                        continue;
                    }

                    $optimization = new OptimizationResult($baseline, $optimizedPattern, ['Optimized pattern.']);
                } else {
                    $optimization = $this->regex->optimize($occurrence->pattern, $optimizationConfig);
                }
            } catch (\Throwable) {
                continue;
            }

            if (!$optimization->isChanged()) {
                continue;
            }

            $savings = \strlen($optimization->original) - \strlen($optimization->optimized);
            if ($savings < $minSavings) {
                continue;
            }

            $suggestions[] = [
                'file' => $occurrence->file,
                'line' => $occurrence->line,
                'column' => $this->resolveColumn($occurrence),
                'fileOffset' => $occurrence->fileOffset,
                'optimization' => $optimization,
                'savings' => $savings,
                'source' => $source,
            ];
        }

        return $suggestions;
    }

    /**
     * @template T
     *
     * @param array<RegexPatternOccurrence>                     $patterns
     * @param callable(array<RegexPatternOccurrence>): array<T> $worker
     *
     * @return array<T>
     */
    private function runInParallel(array $patterns, int $workers, callable $worker, ?callable $progress = null): array
    {
        $patternCount = \count($patterns);
        if (0 === $patternCount) {
            return [];
        }

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

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

                break;
            }

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

                break;
            }

            // @codeCoverageIgnoreStart
            if (0 === $pid) {
                $payload = null;

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

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

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

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

            $results = $worker($patterns);
            if ($progress) {
                for ($i = 0; $i < $patternCount; $i++) {
                    $progress();
                }
            }

            return $results;
        }

        $resultsByIndex = [];
        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 analysis failed: %s: %s', $errorClass, $errorMessage));
            }

            $resultsByIndex[$meta['index']] = $payload['result'] ?? [];
            if ($progress) {
                for ($i = 0; $i < $meta['count']; $i++) {
                    $progress();
                }
            }
        }

        ksort($resultsByIndex);
        $results = [];
        foreach ($resultsByIndex as $chunkResults) {
            if (!\is_array($chunkResults)) {
                continue;
            }

            foreach ($chunkResults as $item) {
                $results[] = $item;
            }
        }

        return $results;
    }

    private function canRunInParallel(): bool
    {
        return \PHP_SAPI === 'cli'
            && \function_exists('pcntl_fork')
            && \function_exists('pcntl_waitpid');
    }

    /**
     * @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::allowedWorkerClasses()]);
        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,
        ];
    }

    /**
     * @return array<string>
     */
    private static function allowedWorkerClasses(): array
    {
        /** @var array<string>|null $allowed */
        static $allowed = null;
        if (null !== $allowed) {
            return $allowed;
        }

        $allowed = [
            ValidationResult::class,
            ValidationErrorCategory::class,
            OptimizationResult::class,
            ReDoSAnalysis::class,
        ];

        $allowed = array_merge(
            $allowed,
            self::classNamesFromDir(__DIR__.'/../Node', 'RegexParser\\Node\\'),
            self::classNamesFromDir(__DIR__.'/../ReDoS', 'RegexParser\\ReDoS\\'),
        );

        $allowed = array_values(array_unique($allowed));

        return $allowed;
    }

    /**
     * @return array<string>
     */
    private static function classNamesFromDir(string $dir, string $namespace): array
    {
        $paths = glob($dir.'/*.php') ?: [];
        $classes = [];

        foreach ($paths as $path) {
            $classes[] = $namespace.basename($path, '.php');
        }

        return $classes;
    }

    private function shouldSkipRiskAnalysis(RegexPatternOccurrence $occurrence): bool
    {
        $rawPattern = $occurrence->displayPattern ?? $occurrence->pattern;
        $fragment = $this->extractFragment($rawPattern);
        $body = $this->trimPatternBody($occurrence->pattern);

        return $this->isIgnored($fragment)
            || $this->isIgnored($body)
            || $this->isTriviallySafe($fragment)
            || $this->isTriviallySafe($body);
    }

    private function extractFragment(string $pattern): string
    {
        if ('' === $pattern) {
            return '';
        }

        $first = $pattern[0];
        $last = $pattern[-1];

        if ($first === $last && \in_array($first, self::PATTERN_DELIMITERS, true)) {
            $pattern = substr($pattern, 1, -1);
        }

        if (str_starts_with($pattern, '^')) {
            $pattern = substr($pattern, 1);
        }

        if (str_ends_with($pattern, '$')) {
            $pattern = substr($pattern, 0, -1);
        }

        return $pattern;
    }

    private function trimPatternBody(string $pattern): string
    {
        if ('' === $pattern) {
            return '';
        }

        $first = $pattern[0];
        $last = $pattern[-1];

        if ($first === $last) {
            $pattern = substr($pattern, 1, -1);
        }

        if (str_starts_with($pattern, '^')) {
            $pattern = substr($pattern, 1);
        }

        if (str_ends_with($pattern, '$')) {
            $pattern = substr($pattern, 0, -1);
        }

        return $pattern;
    }

    private function isIgnored(string $body): bool
    {
        if ('' === $body) {
            return false;
        }

        return \in_array($body, $this->ignoredPatterns, true);
    }

    private function isTriviallySafe(string $body): bool
    {
        if ('' === $body) {
            return false;
        }

        $parts = explode('|', $body);
        if (\count($parts) < 2) {
            return false;
        }

        foreach ($parts as $part) {
            if (!preg_match('#^[A-Za-z0-9._-]+$#', $part)) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<string> $userIgnored
     * @param array<string> $redosIgnored
     *
     * @return array<string>
     */
    private function buildIgnoredPatterns(array $userIgnored, array $redosIgnored): array
    {
        return array_values(array_unique([...$redosIgnored, ...$userIgnored]));
    }

    /**
     * Detect whether a pattern uses extended (/x) mode, where whitespace and
     * inline comments are significant for readability. We treat these patterns
     * specially to preserve comments and pretty formatting when suggesting
     * optimizations.
     */
    private function usesExtendedMode(string $pattern): bool
    {
        // Fast path: if there is no trailing flag block, there's no /x.
        $pattern = ltrim($pattern);
        if ('' === $pattern) {
            return false;
        }

        try {
            /** @var array{0: string, 1: string, 2: string} $parts */
            $parts = PatternParser::extractPatternAndFlags($pattern);
        } catch (\Throwable) {
            // If we cannot reliably extract flags, fall back to not treating it
            // as extended mode to avoid false positives.
            return false;
        }

        $flags = $parts[1] ?? '';

        return \is_string($flags) && str_contains($flags, 'x');
    }

    private function getTipForValidationError(string $message, string $pattern, ValidationResult $validation): ?string
    {
        // Try to provide intelligent, pattern-specific tips
        $intelligentTip = $this->generateIntelligentTip($message, $pattern, $validation);
        if (null !== $intelligentTip) {
            return $intelligentTip;
        }

        // Fallback to generic tips
        return $this->getGenericTipForValidationError($message);
    }

    private function generateIntelligentTip(string $message, string $pattern, ValidationResult $validation): ?string
    {
        if (str_contains($message, 'No closing delimiter')) {
            return $this->suggestDelimiterFix($pattern);
        }

        if (str_contains($message, 'Unclosed character class')) {
            return $this->suggestCharacterClassFix($pattern, $validation);
        }

        if (str_contains($message, 'Invalid quantifier range')) {
            return $this->suggestQuantifierRangeFix($pattern, $validation);
        }

        if (str_contains($message, 'Backreference to non-existent group')) {
            return $this->suggestBackreferenceFix($pattern, $validation);
        }

        if (str_contains($message, 'Lookbehind is unbounded')) {
            return $this->suggestLookbehindFix($pattern, $validation);
        }

        return null;
    }

    private function suggestDelimiterFix(string $pattern): string
    {
        // Find the delimiter used
        if (!preg_match('/^([#~\-%@!])(.*)$/', $pattern, $matches)) {
            $matches = ['', '/', $pattern];
        }

        $delimiter = $matches[1];
        $content = $matches[2];

        // Check if delimiter appears in content
        if (str_contains($content, $delimiter)) {
            $escaped = preg_quote($delimiter, '/');

            return "Your pattern contains the delimiter '$delimiter' inside. Either escape it as \\$delimiter or use a different delimiter like #pattern#.";
        }

        // Missing closing delimiter
        $suggested = $pattern.$delimiter;

        return "Add the missing closing delimiter: $suggested";
    }

    private function suggestCharacterClassFix(string $pattern, ValidationResult $validation): ?string
    {
        // For patterns like /[a-z/ we need to add ] before the final delimiter
        if (str_contains($pattern, '[') && !str_contains($pattern, ']')) {
            // Find the last delimiter
            $lastDelimiterPos = strrpos($pattern, '/');
            if (false !== $lastDelimiterPos) {
                $suggested = substr_replace($pattern, ']', $lastDelimiterPos, 0);

                return "Add missing closing bracket: $suggested";
            }
        }

        return null;
    }

    private function suggestQuantifierRangeFix(string $pattern, ValidationResult $validation): ?string
    {
        // Look for quantifier ranges in the pattern
        if (preg_match('/\{(\d+),(\d+)\}/', $pattern, $matches, \PREG_OFFSET_CAPTURE)) {
            $min = (int) $matches[1][0];
            $max = (int) $matches[2][0];
            $offset = $matches[0][1];

            if ($min > $max) {
                $fixed = '{'.$max.','.$min.'}';
                $suggested = str_replace($matches[0][0], $fixed, $pattern);

                return "Swap min and max values: $suggested";
            }
        }

        return null;
    }

    private function suggestBackreferenceFix(string $pattern, ValidationResult $validation): ?string
    {
        // Find all backreferences in the pattern
        if (preg_match_all('/\\\\(\d+)/', $pattern, $matches, \PREG_OFFSET_CAPTURE)) {
            // Count opening parentheses (capturing groups)
            $openCount = substr_count($pattern, '(');

            foreach ($matches[1] as $match) {
                $refNum = (int) $match[0];
                if ($refNum > $openCount) {
                    return "Backreference \\$refNum refers to group $refNum, but only $openCount capturing groups exist in the pattern. Valid backreferences are \\1 through \\$openCount.";
                }
            }
        }

        return null;
    }

    private function suggestLookbehindFix(string $pattern, ValidationResult $validation): ?string
    {
        $offset = $validation->offset ?? 0;

        // Find the lookbehind content
        $before = substr($pattern, 0, $offset);
        $lookbehindStart = strrpos($before, '(?<=');

        if (false === $lookbehindStart) {
            $lookbehindStart = strrpos($before, '(?<!');
        }

        if (false === $lookbehindStart) {
            return null;
        }

        $lookbehindContent = substr($pattern, $lookbehindStart, $offset - $lookbehindStart);

        // Check for unbounded quantifiers in lookbehind
        if (preg_match('/[+*][?]?/', $lookbehindContent)) {
            return "Replace unbounded quantifiers in lookbehind with fixed-length alternatives. For example, change (?<=\w*) to (?<=\w{0,10}) with an appropriate maximum length.";
        }

        return null;
    }

    private function getGenericTipForValidationError(string $message): ?string
    {
        if (str_contains($message, 'No closing delimiter')) {
            return 'Escape "/" inside the pattern (\/) or use a different delimiter, e.g. #pattern#.';
        }

        if (str_contains($message, 'Unclosed character class')) {
            return 'Character classes must be closed with "]". Check for missing or extra "[".';
        }

        if (str_contains($message, 'Invalid quantifier range')) {
            return 'Quantifier ranges must have min <= max. For example, {3,2} is invalid; use {2,3} or {2} instead.';
        }

        if (str_contains($message, 'Unknown regex flag')) {
            return 'Only valid PCRE flags are: i (case-insensitive), m (multiline), s (dot matches newline), x (extended), U (ungreedy), J (duplicate names).';
        }

        if (str_contains($message, 'Backreference to non-existent group')) {
            return 'Backreferences like \\1 refer to capturing groups. Make sure the group number exists.';
        }

        if (str_contains($message, 'Lookbehind is unbounded')) {
            return 'Variable-length lookbehinds are not allowed in PCRE. Use fixed-length alternatives like (?<=\w{3}) instead of (?<=\w*).';
        }

        if (str_contains($message, 'Invalid conditional construct')) {
            return 'Conditionals need a valid condition: group reference (?(1)...), lookaround (?(?=...)...), or (?(DEFINE)...).';
        }

        return null;
    }

    private function getReDoSHint(ReDoSAnalysis $analysis, string $pattern): string
    {
        $hints = [];

        if (!empty($analysis->recommendations)) {
            $hints = array_merge($hints, $analysis->recommendations);
        }

        if (null !== $analysis->vulnerableSubpattern) {
            $hints[] = \sprintf('The risky part is: %s', $analysis->vulnerableSubpattern);
        }

        $hotspot = $analysis->getPrimaryHotspot();
        if (null !== $hotspot) {
            $hints[] = \sprintf('Hotspot offsets: %d-%d', $hotspot->start, $hotspot->end);
        }

        // Try to suggest specific fixes based on the pattern
        $patternHints = $this->suggestReDoSFixes($pattern, $analysis);
        if (!empty($patternHints)) {
            $hints = array_merge($hints, $patternHints);
        }

        if (empty($hints)) {
            $hints[] = 'Use possessive quantifiers (*+ instead of *, ++ instead of +, or {m,n}+ instead of {m,n}) to prevent ReDoS.';
        }

        if (ReDoSMode::CONFIRMED === $analysis->mode && null !== $analysis->confirmation) {
            if ($analysis->confirmation->confirmed) {
                $hints[] = 'Confirmation: bounded runtime checks observed evidence of excessive backtracking.';
            } else {
                $hints[] = 'Confirmation: bounded runtime checks found no evidence within limits.';
            }
        }

        $hints[] = 'Test with adversarial inputs like repeated strings followed by a non-matching character.';

        return implode(' ', $hints);
    }

    /**
     * @return array<string>
     */
    private function suggestReDoSFixes(string $pattern, ReDoSAnalysis $analysis): array
    {
        $hints = [];

        // Look for common vulnerable patterns and suggest fixes
        if (preg_match('/\((?:(?:[^()][^)]*)?\)\+|\([^)]*(?:\+\)\)|\)\+))/', $pattern)) {
            $hints[] = 'Suggested (verify behavior): replace nested quantifiers like (a+)+ with atomic groups (?>a+) or possessive quantifiers a++.';
        }

        if (str_contains($pattern, '.*') && str_contains($pattern, '+')) {
            $hints[] = 'Suggested (verify behavior): use possessive quantifiers .*+ instead of .* to prevent backtracking.';
        }

        if (preg_match('/\([^)]*\*\)/', $pattern)) {
            $hints[] = 'Suggested (verify behavior): replace * with *+ in groups to prevent backtracking or wrap in (?>...).';
        }

        // If we have a vulnerable subpattern, try to suggest a specific fix
        if (null !== $analysis->vulnerableSubpattern) {
            $vulnerable = $analysis->vulnerableSubpattern;
            if (preg_match('/(\w+)\+(\)\+)/', $vulnerable, $matches)) {
                $char = $matches[1];
                $hints[] = "Suggested (verify behavior): replace ($char+)+ with atomic group (?>$char+) or possessive $char++.";
            }
        }

        return $hints;
    }

    private function resolveRedosIssueType(ReDoSAnalysis $analysis): string
    {
        if ($analysis->isConfirmed() && $analysis->exceedsThreshold(ReDoSSeverity::HIGH)) {
            return 'error';
        }

        return 'warning';
    }

    private function formatRedosMessage(ReDoSAnalysis $analysis): string
    {
        $severity = strtoupper($analysis->severity->value);
        $confidence = strtoupper($analysis->confidenceLevel()->value);

        if ($analysis->isConfirmed()) {
            $evidence = $analysis->confirmation?->evidence;
            $evidenceLabel = null !== $evidence ? 'evidence: '.$evidence : 'evidence present';

            return \sprintf(
                'Confirmed ReDoS risk (%s, severity: %s, confidence: %s).',
                $evidenceLabel,
                $severity,
                $confidence,
            );
        }

        return \sprintf(
            'Potential ReDoS risk (theoretical, severity: %s, confidence: %s).',
            $severity,
            $confidence,
        );
    }

    private function isLikelyPartialRegexError(string $errorMessage): bool
    {
        $indicators = [
            'No closing delimiter',
            'Regex too short',
            'Unknown modifier',
            'Unexpected end',
        ];

        foreach ($indicators as $indicator) {
            if (false !== stripos($errorMessage, (string) $indicator)) {
                return true;
            }
        }

        return false;
    }
}
