# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

from collections.abc import Iterable

from mirage.types import HiddenPaths, HiddenVars
from mirage.utils.fnmatch import fnmatch

# The characters that make a document entry a pattern rather than an
# exact name; the permissions document has one grammar for every path
# list (design 3.6), and this is the whole classification rule.
GLOB_CHARS = frozenset("*?[")


def is_glob(entry: str) -> bool:
    """Whether a document entry is a pattern (any of ``*``, ``?``, ``[``).

    Args:
        entry (str): one entry of a ``hide`` list or a rule's ``paths``.
    """
    return any(ch in GLOB_CHARS for ch in entry)


def anchor_depth(entry: str) -> int:
    """How specific a path entry is: the number of literal components
    before its first wildcard.

    The one measure the permissions document's path axis orders by.
    ``/repo/sealed/*`` is 2, ``/repo/*`` and the plain subtree
    ``/repo`` are 1, and a slashless name pattern like ``*.key`` is 0,
    since it anchors nothing. Every pattern the document allows has an
    answer, so two entries about one path are always comparable and
    nothing is ever guessed.

    It lives here beside :func:`is_glob` rather than in the policy
    layer because both gates need it: admission scores the rule that
    covers an operand, and the entry gate scores the rule that covers
    an entry reached mid-walk.

    Args:
        entry (str): a path entry as written in the document.
    """
    depth = 0
    for part in entry.strip("/").split("/"):
        if not part or is_glob(part):
            break
        depth += 1
    return depth


def classify_paths(entries: Iterable[str]) -> HiddenPaths | None:
    """Compile document path entries into the matcher's shape.

    Glob = pattern, plain = exact subtree, in the order written; the
    same split serves ``paths.hide`` and a ``CommandRule``'s ``paths``
    so both planes match through :func:`path_hidden`. None when there
    is nothing to match, which is what "unrestricted" reads as.

    Args:
        entries (Iterable[str]): the document's entries.
    """
    listed = tuple(entries)
    paths = tuple(e for e in listed if not is_glob(e))
    patterns = tuple(e for e in listed if is_glob(e))
    if not paths and not patterns:
        return None
    return HiddenPaths(paths=paths, patterns=patterns)


def classify_vars(entries: Iterable[str]) -> HiddenVars | None:
    """Compile document variable entries into the matcher's shape.

    Glob = pattern over names, plain = exact name. None when empty.

    Args:
        entries (Iterable[str]): the document's ``vars.hide`` entries.
    """
    listed = tuple(entries)
    names = tuple(e for e in listed if not is_glob(e))
    patterns = tuple(e for e in listed if is_glob(e))
    if not names and not patterns:
        return None
    return HiddenVars(names=names, patterns=patterns)


def _norm_abs(path: str) -> str:
    """One absolute spelling for a path, no trailing slash.

    Args:
        path (str): a virtual path or hidden-spec entry.
    """
    stripped = path.strip("/")
    return "/" + stripped if stripped else "/"


def path_hidden(hidden: HiddenPaths | None, virtual: str) -> bool:
    """Whether the session's spec hides this virtual path.

    The two planes of the spec, in the order they cost: an exact entry
    hides the path and its whole subtree (prefix containment, no
    globbing); a component pattern (no ``/``) hides any path carrying a
    matching name segment, which covers the subtree below a matching
    directory for free; an anchored pattern (contains ``/``) is tested
    against the path and each of its ancestors, so a directory the
    pattern hides keeps its descendants hidden too. Patterns match with
    the repo fnmatch dialect, ``*`` crossing slashes as GNU ``find
    -path`` does.

    Args:
        hidden (HiddenPaths | None): the session's spec, None means
            unrestricted.
        virtual (str): absolute virtual path to test.
    """
    if hidden is None or (not hidden.paths and not hidden.patterns):
        return False
    norm = _norm_abs(virtual)
    for entry in hidden.paths:
        p = _norm_abs(entry)
        if norm == p or norm.startswith(p + "/") or p == "/":
            return True
    if not hidden.patterns:
        return False
    component_pats = [p for p in hidden.patterns if "/" not in p]
    anchored_pats = [_norm_abs(p) for p in hidden.patterns if "/" in p]
    parts = [seg for seg in norm.split("/") if seg]
    if component_pats:
        for seg in parts:
            for pat in component_pats:
                if fnmatch(seg, pat):
                    return True
    if anchored_pats:
        prefix = ""
        for seg in parts:
            prefix = f"{prefix}/{seg}"
            for pat in anchored_pats:
                if fnmatch(prefix, pat):
                    return True
    return False


def _pattern_head(pattern: str) -> str:
    """The fixed directory above an anchored pattern's first glob
    segment, normalized (``/x/locked/*`` -> ``/x/locked``).

    Args:
        pattern (str): an anchored pattern (one with a ``/``).
    """
    fixed: list[str] = []
    for seg in _norm_abs(pattern).split("/"):
        if is_glob(seg):
            break
        fixed.append(seg)
    return _norm_abs("/".join(fixed))


def path_covers(hidden: HiddenPaths | None,
                virtual: str,
                ancestors: bool = True) -> bool:
    """Whether a spec has anything at or under this virtual path.

    Asked for an op that acts on a whole subtree (a rename of a
    directory, a recursive remove): ``/x/locked/*`` protects the
    children of ``/x/locked``, and moving or removing ``/x/locked`` or
    ``/x`` takes them along, so the op on the directory or an ancestor
    counts as touching the scope. Exact entries and the fixed head of
    an anchored pattern are tested; a component pattern (no ``/``)
    names no place, so only a walk could tell and it is not counted
    here. With ``ancestors`` False only the directory holding the
    scope counts, which is the question for a destination: moving
    into ``/x/locked`` lands in the scope, moving into ``/x`` does not.

    Args:
        hidden (HiddenPaths | None): the spec, None means unrestricted.
        virtual (str): absolute virtual path of the subtree op.
        ancestors (bool): whether an ancestor of the scope counts.
    """
    if hidden is None or (not hidden.paths and not hidden.patterns):
        return False
    norm = _norm_abs(virtual)
    heads = [_norm_abs(p) for p in hidden.paths]
    heads.extend(_pattern_head(p) for p in hidden.patterns if "/" in p)
    if any(head == norm for head in heads):
        return True
    return ancestors and any(norm == "/" or head.startswith(norm + "/")
                             for head in heads)


def var_hidden(hidden: HiddenVars | None, name: str) -> bool:
    """Whether the session's spec hides this variable name.

    Args:
        hidden (HiddenVars | None): the session's spec, None means
            unrestricted.
        name (str): variable name to test.
    """
    if hidden is None:
        return False
    if name in hidden.names:
        return True
    for pat in hidden.patterns:
        if fnmatch(name, pat):
            return True
    return False
