#!/usr/bin/env python3
# Copyright: 2026, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Literal prefilter for license detection.

find_licenses() runs up to 512 regex searches per file; on real source almost all
fail (see the perf analysis). Every rule has a literal that MUST appear in any
match, so for a given file only the rules whose literal is present need to run.
This module extracts those necessary literals from the compiled rule patterns and
exposes candidate_indices(text): the set of rule indices worth running.

Optional dependency: the multi-literal scan uses pyahocorasick. Without it,
candidate_indices() returns None and the caller runs every rule - same result,
just slower. The extractor uses re's internal parser (re._parser); if that ever
changes, extraction degrades to "always run that rule", never to a wrong answer.
"""

from __future__ import annotations

import contextlib
from typing import Any

from .res import LICENSES_RES

try:  # internal API; a per-rule failure just marks the rule always-run (below)
    import re._parser as _sre  # type: ignore[import-not-found]  # ty: ignore[unresolved-import]
except ImportError:  # pragma: no cover - re._parser has existed since 3.11
    _sre = None  # type: ignore[assignment]

try:
    import ahocorasick  # type: ignore[import-not-found]  # ty: ignore[unresolved-import]
except ImportError:
    ahocorasick = None  # type: ignore[assignment]

# Shortest literal we trust as a prefilter anchor. All current rules have a
# necessary literal well above this; a shorter one just makes the rule always-run.
_MIN_LITERAL_LEN = 4


def _lit_runs(subpattern: Any) -> list[str]:  # noqa: ANN401
    """Runs of consecutively-required literals (casefolded), in order.

    Only unconditionally-required LITERALs contribute; an optional repeat,
    char-class, ANY or alternation breaks the run. Conservative by construction:
    it can shorten or drop a literal, never invent one that could be absent.
    """
    runs: list[str] = []
    cur: list[str] = []

    def flush() -> None:
        if cur:
            runs.append("".join(cur))
            cur.clear()

    for op, av in subpattern:
        if op.name == "LITERAL":
            cur.append(chr(av).casefold())
            continue
        flush()
        if op.name == "SUBPATTERN":
            runs.extend(_lit_runs(av[-1]))
        elif op.name in ("MAX_REPEAT", "MIN_REPEAT") and av[0] >= 1:
            runs.extend(_lit_runs(av[2]))
    flush()
    return runs


def _rule_literals(pattern: str, flags: int) -> list[str] | None:
    """Literals where at least one MUST be present for the rule to match.

    Returns None (-> the rule must always run) when no usable necessary literal
    exists: a top-level alternation whose every branch lacks one, or a sequence
    whose longest required literal is too short.
    """
    if _sre is None:
        return None
    parsed = _sre.parse(pattern, flags)
    data = list(parsed)
    if len(data) == 1 and data[0][0].name == "BRANCH":
        branch_lits = [
            max(_lit_runs(branch), key=len, default="") for branch in data[0][1][1]
        ]
        if all(len(lit) >= _MIN_LITERAL_LEN for lit in branch_lits):
            return branch_lits
        return None
    longest = max(_lit_runs(parsed), key=len, default="")
    return [longest] if len(longest) >= _MIN_LITERAL_LEN else None


class _LiteralPrefilter:
    """Each rule's necessary literal(s) -> its index, via one Aho-Corasick
    automaton, so candidate_indices() can name the rules worth running.

    Inactive (candidate_indices -> None) when pyahocorasick is absent or no rule
    yields a usable literal; the caller then runs every rule.
    """

    def __init__(self) -> None:
        always_run, lit_to_rules = self._extract_literals()
        self._always_run = always_run
        self._automaton = self._compile(lit_to_rules)

    @staticmethod
    def _extract_literals() -> tuple[frozenset[int], dict[str, set[int]]]:
        """Per rule, the literals that select it; or mark it always-run."""
        always_run: set[int] = set()
        lit_to_rules: dict[str, set[int]] = {}
        for index, rule in enumerate(LICENSES_RES):
            literals: list[str] | None = None
            with contextlib.suppress(Exception):
                literals = _rule_literals(rule.re, int(rule.re_flags))
            if literals is None:
                always_run.add(index)
                continue
            for literal in literals:
                lit_to_rules.setdefault(literal, set()).add(index)
        return frozenset(always_run), lit_to_rules

    @staticmethod
    def _compile(lit_to_rules: dict[str, set[int]]) -> Any:  # noqa: ANN401
        """The Aho-Corasick automaton over the literals, or None if unavailable."""
        if ahocorasick is None or not lit_to_rules:
            return None
        automaton = ahocorasick.Automaton()
        for literal, indices in lit_to_rules.items():
            automaton.add_word(literal, frozenset(indices))
        automaton.make_automaton()
        return automaton

    @property
    def available(self) -> bool:
        return self._automaton is not None

    def candidate_indices(self, text: str) -> frozenset[int] | None:
        if self._automaton is None:
            return None
        candidates = set(self._always_run)
        for _end, indices in self._automaton.iter(text.casefold()):
            candidates |= indices
        return frozenset(candidates)


_PREFILTER = _LiteralPrefilter()


def prefilter_available() -> bool:
    """True when the literal prefilter is active (pyahocorasick present)."""
    return _PREFILTER.available


def candidate_indices(text: str) -> frozenset[int] | None:
    """Rule indices worth running for `text`: those whose necessary literal is
    present, plus the always-run rules. None -> prefilter unavailable, run all.
    """
    return _PREFILTER.candidate_indices(text)
