"""
calibrate_helpers.py — deterministic helpers for the `:calibrate` skill
(lifecycle-aware-doc-hygiene change, task 5.4).

Stdlib-only, no model. Three responsibilities, each a small
single-responsibility class taking injected inputs and returning
JSON-serializable output:

1. `ClusterSampler` — groups unmatched paths by path-shape (directory
   prefix + a filename "shape class" where digit runs -> `#` and hex-looking
   runs -> `~`) and emits capped representative samples per cluster.
2. `RuleReportBuilder` — given proposed rules (glob + lifetime) and the
   repo's file list, assembles the 5-element rule-report data: glob
   verbatim, matched paths (capped sample + total), near-miss boundary
   paths, lifetime/tier, and a `why` placeholder the caller fills in.
3. `RuleQualityChecker` — deterministic lint: `class_not_path` (flags globs
   whose filename segment looks instance-unique: long digit runs, hex
   hashes, bare timestamps, or a wildcard-free glob matching exactly one
   existing path) and `prefer_narrower` (compares two candidate globs by
   match-count on the actual tree; the narrower one wins ties).

None of these classes call an LLM, read history, or touch git. They operate
purely on lists of strings (paths) handed in by the orchestrating skill.
"""

from __future__ import annotations

import fnmatch
import re
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from typing import Optional

_DEFAULT_SAMPLE_CAP = 5

# --- shape-class helpers ---------------------------------------------------

_DIGIT_RUN = re.compile(r"\d+")
# hex run: 6+ consecutive hex chars (avoids false-triggering on short words)
_HEX_RUN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{6,}(?![0-9a-fA-F])")

# "instance-unique" detectors used by both shape-classing and rule-quality
# lints: a long digit run (8+, e.g. a timestamp or run-id), or a hex run
# (6+ chars, e.g. a git sha / hash).
_LONG_DIGIT_RUN = re.compile(r"\d{6,}")


def _filename_shape(name: str) -> str:
    """Collapse a filename into a shape class: digit runs -> '#', hex runs
    (6+ hex chars) -> '~'. Order matters: hex collapse first (a hex run may
    contain digits that would otherwise be independently collapsed)."""
    shaped = _HEX_RUN.sub("~", name)
    shaped = _DIGIT_RUN.sub("#", shaped)
    return shaped


def _dir_prefix(path: str) -> str:
    parts = PurePosixPath(path).parts[:-1]
    return "/".join(parts)


@dataclass
class Cluster:
    key: str  # "<dir_prefix>::<shape>"
    dir_prefix: str
    shape: str
    paths: list  # all paths in this cluster, sorted
    sample: list  # capped representative sample

    @property
    def total(self) -> int:
        return len(self.paths)

    def to_dict(self) -> dict:
        return {
            "key": self.key,
            "dir_prefix": self.dir_prefix,
            "shape": self.shape,
            "total": self.total,
            "sample": list(self.sample),
        }


class ClusterSampler:
    """Groups unmatched paths by path-shape (directory prefix + filename
    shape class) and emits per-cluster representative samples, capped."""

    def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
        self._cap = sample_cap

    def cluster(self, unmatched_paths: list) -> list:
        """Returns a list of Cluster, sorted by cluster key for determinism.
        Each cluster groups paths sharing the same directory prefix AND the
        same filename shape class."""
        groups: dict = {}
        for path in unmatched_paths:
            dir_prefix = _dir_prefix(path)
            filename = PurePosixPath(path).name
            shape = _filename_shape(filename)
            key = f"{dir_prefix}::{shape}"
            groups.setdefault(key, []).append(path)

        clusters = []
        for key in sorted(groups.keys()):
            paths = sorted(groups[key])
            dir_prefix, shape = key.split("::", 1)
            clusters.append(
                Cluster(
                    key=key,
                    dir_prefix=dir_prefix,
                    shape=shape,
                    paths=paths,
                    sample=paths[: self._cap],
                )
            )
        return clusters

    def cluster_to_dicts(self, unmatched_paths: list) -> list:
        return [c.to_dict() for c in self.cluster(unmatched_paths)]


# --- rule report assembly ---------------------------------------------------


def _glob_matches(glob_pattern: str, path: str) -> bool:
    return fnmatch.fnmatch(path, glob_pattern)


def _relax_glob(glob_pattern: str) -> Optional[str]:
    """Produce a strictly broader variant of a glob for near-miss detection.

    Widens every path segment that already contains a wildcard character to
    a bare '*' (fully generic for that segment), preserving fixed segments
    and the final segment's extension when present. This surfaces exactly
    the #45 boundary-bug class: a glob like `autoresearch/classic-*/` that
    silently misses a sibling `autoresearch/improve-*/` — relaxing the
    `classic-*` segment to `*` reveals the sibling as a near-miss.

    Returns None if no segment has a wildcard to relax (fully-fixed glob has
    no meaningful near-miss boundary)."""
    parts = glob_pattern.split("/")
    if not parts:
        return None

    relaxed_parts = list(parts)
    changed = False
    for i, part in enumerate(parts):
        if part in ("*", "**"):
            continue  # already fully generic
        if any(ch in part for ch in "*?[") :
            if "." in part:
                stem, _, ext = part.rpartition(".")
                relaxed = "*." + ext
            else:
                relaxed = "*"
            if relaxed != part:
                relaxed_parts[i] = relaxed
                changed = True

    if not changed:
        return None
    return "/".join(relaxed_parts)


@dataclass
class RuleReportEntry:
    glob: str
    lifetime: str
    tier: str
    matched_sample: list
    matched_total: int
    near_miss: list
    why: str = ""

    def to_dict(self) -> dict:
        return {
            "glob": self.glob,
            "lifetime": self.lifetime,
            "tier": self.tier,
            "matches": {
                "sample": list(self.matched_sample),
                "total": self.matched_total,
            },
            "near_miss": list(self.near_miss),
            "why": self.why,
        }


class RuleReportBuilder:
    """Given proposed rules and the repo's file list, produces the 5-element
    rule-report data per proposed rule: glob verbatim, matched paths (capped
    sample + total), near-miss boundary, lifetime + tier, and a why string
    (caller-supplied; this class does not author prose)."""

    def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
        self._cap = sample_cap

    def build(self, proposed_rule: dict, all_paths: list) -> RuleReportEntry:
        glob_pattern = proposed_rule["glob"]
        lifetime = proposed_rule.get("lifetime", "keep")
        tier = proposed_rule.get("tier", "confirm")
        why = proposed_rule.get("why", "")

        matched = sorted(p for p in all_paths if _glob_matches(glob_pattern, p))

        near_miss: list = []
        relaxed = _relax_glob(glob_pattern)
        if relaxed is not None:
            matched_set = set(matched)
            near_miss = sorted(
                p
                for p in all_paths
                if p not in matched_set and _glob_matches(relaxed, p)
            )

        return RuleReportEntry(
            glob=glob_pattern,
            lifetime=lifetime,
            tier=tier,
            matched_sample=matched[: self._cap],
            matched_total=len(matched),
            near_miss=near_miss,
            why=why,
        )

    def build_all(self, proposed_rules: list, all_paths: list) -> list:
        return [self.build(r, all_paths).to_dict() for r in proposed_rules]


# --- rule-quality checks -----------------------------------------------------


@dataclass
class QualityFinding:
    check: str  # "class_not_path" | "prefer_narrower"
    glob: str
    passed: bool
    reason: str

    def to_dict(self) -> dict:
        return {
            "check": self.check,
            "glob": self.glob,
            "passed": self.passed,
            "reason": self.reason,
        }


class RuleQualityChecker:
    """Deterministic lints applied to a proposed glob before persistence:

    - `class_not_path`: flags a glob whose final path segment looks
      instance-unique (a long digit run / bare timestamp, or a hex-looking
      hash run), OR a glob with zero wildcard characters that currently
      matches exactly one existing path (a rule that can, by construction,
      only ever match that one file — a failed generalization).
    - `prefer_narrower`: given two candidate globs for the same cluster,
      report which one is narrower by match-count against the actual tree
      (ties broken by raw pattern length, longer = more specific = narrower).
    """

    def class_not_path(self, glob_pattern: str, all_paths: Optional[list] = None) -> QualityFinding:
        last_segment = glob_pattern.split("/")[-1]

        if _LONG_DIGIT_RUN.search(last_segment):
            return QualityFinding(
                check="class_not_path",
                glob=glob_pattern,
                passed=False,
                reason=(
                    "glob's final segment contains a long digit run "
                    "(looks like a run-id or timestamp unique to one instance)"
                ),
            )

        if _HEX_RUN.search(last_segment):
            return QualityFinding(
                check="class_not_path",
                glob=glob_pattern,
                passed=False,
                reason=(
                    "glob's final segment contains a hex-looking run "
                    "(looks like a hash unique to one instance)"
                ),
            )

        has_wildcard = any(ch in glob_pattern for ch in "*?[")
        if not has_wildcard and all_paths is not None:
            matches = [p for p in all_paths if _glob_matches(glob_pattern, p)]
            if len(matches) <= 1:
                return QualityFinding(
                    check="class_not_path",
                    glob=glob_pattern,
                    passed=False,
                    reason=(
                        "glob has no wildcard and matches at most one existing "
                        "path — by construction it can never match a future "
                        "file (failed generalization), flagged loudly"
                    ),
                )

        return QualityFinding(
            check="class_not_path",
            glob=glob_pattern,
            passed=True,
            reason="glob names a recurring class, not a single instance",
        )

    def prefer_narrower(self, glob_a: str, glob_b: str, all_paths: list) -> dict:
        """Returns {"narrower": <glob>, "match_counts": {a: n, b: n}}."""
        count_a = sum(1 for p in all_paths if _glob_matches(glob_a, p))
        count_b = sum(1 for p in all_paths if _glob_matches(glob_b, p))

        if count_a != count_b:
            narrower = glob_a if count_a < count_b else glob_b
        else:
            # tie-break on identical match counts: fewer wildcard characters
            # is more specific/narrower; if that also ties, the longer raw
            # pattern is treated as more specific.
            def _wildcard_count(g: str) -> int:
                return sum(g.count(ch) for ch in "*?[")

            wc_a, wc_b = _wildcard_count(glob_a), _wildcard_count(glob_b)
            if wc_a != wc_b:
                narrower = glob_a if wc_a < wc_b else glob_b
            else:
                narrower = glob_a if len(glob_a) >= len(glob_b) else glob_b

        return {
            "narrower": narrower,
            "match_counts": {glob_a: count_a, glob_b: count_b},
        }
