"""
doc-hygiene scanner — deterministic, no model, no network.

Produces an intermediate artifact:
  {
    "project_root": str,
    "scope_globs":  [str, ...],
    "excluded_dirs": [str, ...],
    "files_scanned": int,
    "shortlist":    [str, ...],   # project-root-relative paths
    "signals":      { path: [{"name": str, "detail": str}, ...] }
  }

Signal shape matches entries[].signals in the frozen report schema.
"""

from __future__ import annotations

import fnmatch
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Iterator, List, Optional

from token_estimator import TokenEstimator, default_estimator

try:  # rulebook.py is optional-at-import (requires Python >= 3.13 for its
    # own glob.translate usage); the scanner itself has no hard dependency
    # on it — a Scanner constructed without a `rulebook=` argument behaves
    # exactly as before this change (design.md Decision 2).
    from rulebook import RuleMatch  # noqa: F401
except Exception:  # pragma: no cover - defensive, see comment above
    RuleMatch = object  # type: ignore


# ---------------------------------------------------------------------------
# Glob matching (Python 3.14-compatible)
# ---------------------------------------------------------------------------

def _file_matches_glob(rel_path_str: str, glob_pattern: str) -> bool:
    """Return True if *rel_path_str* matches *glob_pattern*.

    Works around a Python 3.14 behavior where ``Path('a.md').match('**/*.md')``
    returns False for root-level files (no parent component).  For ``**/X``
    patterns we fall back to matching just ``X`` against the filename so that
    root-level files are included.
    """
    p = Path(rel_path_str)
    # Try direct PurePath.match() first — handles nested files correctly
    if p.match(glob_pattern):
        return True
    # Fallback for '**/<pat>' patterns: match just the trailing pattern against
    # the filename, so root-level 'a.md' is caught by '**/*.md'.
    if glob_pattern.startswith("**/"):
        suffix_pat = glob_pattern[3:]
        if fnmatch.fnmatch(p.name, suffix_pat):
            return True
    return False


# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------

DEFAULT_SCOPE_GLOBS: List[str] = ["**/*.md"]
DEFAULT_MAX_LINES: int = 400
DEFAULT_MAX_TOKENS: int = 4000
DEFAULT_EXCLUDED_DIRS: List[str] = [
    "build",
    "vendor",
    "archive",
    "graphify-out",
    ".cc-os",
    ".dochygiene",  # legacy state dir (pre-ADR-027); still excluded for old trees
    # Test-fixture dirs intentionally contain stale/bloated docs as scanner
    # inputs.  Bare directory-*name* match (consistent with the entries above):
    # any child dir named "fixtures" at any depth is pruned — same breadth as
    # "archive"/"vendor".
    "fixtures",
    # Golden classifier inputs also contain deliberately-stale docs, but a bare
    # "golden" name-match would silently skip legitimate `golden/` dirs in
    # unrelated projects (doc-hygiene installs globally).  So this entry is
    # PATH-AWARE: an entry containing "/" is matched as a parent/child name pair
    # anywhere in the tree — here, a dir named "golden" whose immediate parent is
    # named "examples".  A root-level `golden/` with a different parent is still
    # scanned.  See Scanner._is_excluded_child for the matcher.
    "examples/golden",
]


# ---------------------------------------------------------------------------
# Helpers — frontmatter
# ---------------------------------------------------------------------------

def _parse_frontmatter_value(path: Path, key: str) -> Optional[str]:
    """Return the string value of *key* in YAML frontmatter, or None.

    Reads only the frontmatter block (between the first pair of ``---`` lines).
    Uses stdlib only (no PyYAML).
    """
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return None

    lines = text.splitlines()
    if not lines or lines[0].strip() != "---":
        return None

    in_block = True
    for line in lines[1:]:
        stripped = line.strip()
        if stripped == "---" or stripped == "...":
            break
        # Match: key: value  (simple scalar, no quoting)
        m = re.match(r"^(\w[\w\-]*):\s*(.+)$", stripped)
        if m and m.group(1) == key:
            return m.group(2).strip()

    return None


def _is_frozen(path: Path) -> bool:
    """Return True if the file has ``hygiene: frozen`` in its frontmatter."""
    return _parse_frontmatter_value(path, "hygiene") == "frozen"


# ---------------------------------------------------------------------------
# Helpers — append-only detection (content-based, no git)
# ---------------------------------------------------------------------------

_APPEND_ONLY_MARKER_RE = re.compile(
    r"<!--\s*append[-_]only\s*-->|<!--\s*hygiene:\s*append[-_]only\s*-->",
    re.IGNORECASE,
)
# A dated section header: starts with a 4-digit year, or ISO date, or
# common changelog-style prefix e.g. "## 2026-06-18" or "# v1.2 (2026-06-18)"
_DATED_HEADER_RE = re.compile(
    r"^#{1,3}\s+(?:v?\d+\.\d|\d{4}[-/]\d{2}[-/]\d{2}|\d{4}-\d{2})"
)


def _is_append_only(path: Path) -> bool:
    """Heuristic: return True if the file looks like an append-only log.

    Two independent paths (either is sufficient):
      1. File contains an explicit ``<!-- append-only -->`` marker.
      2. Every non-blank, non-header line cluster lives under a dated section
         header, and the section count is ≥ 2 (a multi-entry changelog pattern).

    Deterministic: reads content only, no git, no network.
    """
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return False

    # Path 1: explicit marker anywhere in the file
    if _APPEND_ONLY_MARKER_RE.search(text):
        return True

    # Path 2: structural — look for ≥2 dated-section headers and NO prose
    # outside of dated sections (aside from a preamble).
    lines = text.splitlines()
    dated_header_count = 0
    in_dated_section = False
    non_section_content_found = False
    preamble_done = False  # first dated header ends the preamble

    for line in lines:
        stripped = line.strip()
        if not stripped:
            continue
        if _DATED_HEADER_RE.match(stripped):
            dated_header_count += 1
            in_dated_section = True
            preamble_done = True
        elif not preamble_done:
            # still in preamble (before first dated header) — allowed
            pass
        elif not in_dated_section:
            # content outside any dated section after preamble
            non_section_content_found = True
            break

    if dated_header_count >= 2 and not non_section_content_found:
        return True

    return False


# ---------------------------------------------------------------------------
# Helpers — .dochygiene-ignore
# ---------------------------------------------------------------------------

def _load_ignore_patterns(root: Path) -> List[str]:
    """Load patterns from ``<root>/.dochygiene-ignore``. One pattern per line."""
    ignore_file = root / ".dochygiene-ignore"
    try:
        text = ignore_file.read_text(encoding="utf-8", errors="replace")
        return [
            line.strip()
            for line in text.splitlines()
            if line.strip() and not line.strip().startswith("#")
        ]
    except OSError:
        return []


def _matches_ignore(rel_path: str, patterns: List[str]) -> bool:
    """Return True if *rel_path* matches any ignore pattern (fnmatch)."""
    for pat in patterns:
        if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(
            os.path.basename(rel_path), pat
        ):
            return True
    return False


# ---------------------------------------------------------------------------
# Signal computation
# ---------------------------------------------------------------------------

class SignalComputer:
    """Compute objective signals for a single file.

    Injected dependencies
    ----------------------
    git_log_fn : callable(path: Path) -> list[str]
        Returns git log lines for the file.  Production uses ``_git_log_real``;
        tests pass a fake.  Pass ``None`` to disable git-based signals.
    now_fn : callable() -> float
        Returns current time as a Unix timestamp.
    max_lines : int
        Line-count threshold for the ``file_length`` signal.
    max_tokens : int
        Estimated-token threshold for the ``file_length`` signal.
    token_estimator : TokenEstimator, optional
        Injectable estimator used for the ``file_length`` signal's token
        count.  Defaults to ``token_estimator.default_estimator()``. Tests
        can inject a deterministic stub.
    """

    def __init__(
        self,
        root: Path,
        git_log_fn: Optional[Callable[[Path], List[str]]] = None,
        now_fn: Optional[Callable[[], float]] = None,
        max_lines: int = DEFAULT_MAX_LINES,
        max_tokens: int = DEFAULT_MAX_TOKENS,
        token_estimator: Optional[TokenEstimator] = None,
    ) -> None:
        self._root = root
        self._git_log_fn = git_log_fn
        self._now_fn = now_fn or (lambda: __import__("time").time())
        self._max_lines = max_lines
        self._max_tokens = max_tokens
        self._token_estimator = token_estimator or default_estimator()

    # ------------------------------------------------------------------
    # Public
    # ------------------------------------------------------------------

    def compute(self, path: Path) -> List[dict]:
        """Return a list of signal dicts for *path*."""
        signals: List[dict] = []
        rel = str(path.relative_to(self._root))

        signals.extend(self._broken_references(path, rel))
        signals.extend(self._version_skew(path))
        signals.extend(self._edit_recency_vs_churn(path))
        signals.extend(self._location_signals(path, rel))
        signals.extend(self._archive_to_live_ratio(path))
        signals.extend(self._frontmatter_markers(path))
        signals.extend(self._file_length(path))

        return signals

    # ------------------------------------------------------------------
    # Individual signal detectors
    # ------------------------------------------------------------------

    def _broken_references(self, path: Path, rel: str) -> List[dict]:
        """Detect Markdown links/images whose targets do not exist on disk."""
        signals = []
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return signals

        # Match [text](target) — skip anchors, URLs, and mailto
        link_re = re.compile(r"\[(?:[^\[\]]*)\]\(([^)]+)\)")
        for m in link_re.finditer(text):
            target = m.group(1).split("#")[0].strip()  # strip fragment
            if not target:
                continue
            if re.match(r"https?://|mailto:", target):
                continue
            # Resolve relative to the file's directory
            if target.startswith("/"):
                resolved = self._root / target.lstrip("/")
            else:
                resolved = path.parent / target
            if not resolved.exists():
                signals.append(
                    {
                        "name": "broken_reference",
                        "detail": f"links to '{target}' which does not exist",
                    }
                )
        return signals

    def _version_skew(self, path: Path) -> List[dict]:
        """Detect explicit version declarations that differ from the repo version.

        Looks for patterns like ``version: X.Y.Z`` or ``# vX.Y.Z`` in the file
        and compares against a ``pyproject.toml`` or ``package.json`` at the
        project root, if present.  Objective fact only — no judgment.
        """
        signals = []
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return signals

        # Find declared versions in the doc (e.g. ``version: 1.2.3``)
        doc_versions = re.findall(r"\bversion[:\s]+['\"]?(\d+\.\d+[\.\d]*)['\"]?", text, re.IGNORECASE)
        if not doc_versions:
            return signals

        # Try to read authoritative version from pyproject.toml or package.json
        repo_version: Optional[str] = None
        pyproject = self._root / "pyproject.toml"
        if pyproject.exists():
            try:
                pp_text = pyproject.read_text(encoding="utf-8", errors="replace")
                m = re.search(r'^version\s*=\s*["\'](\d+\.\d+[\.\d]*)["\']', pp_text, re.MULTILINE)
                if m:
                    repo_version = m.group(1)
            except OSError:
                pass

        if repo_version is None:
            pkg_json = self._root / "package.json"
            if pkg_json.exists():
                try:
                    pkg = json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
                    repo_version = pkg.get("version")
                except (OSError, json.JSONDecodeError):
                    pass

        if repo_version is None:
            return signals

        for doc_ver in doc_versions:
            if doc_ver != repo_version:
                signals.append(
                    {
                        "name": "version_skew",
                        "detail": (
                            f"doc declares version {doc_ver!r} but repo version is {repo_version!r}"
                        ),
                    }
                )
                break  # one signal per file is sufficient

        return signals

    def _edit_recency_vs_churn(self, path: Path) -> List[dict]:
        """Flag files edited very recently relative to their historical git churn.

        Signal: file was modified within the last 7 days (mtime) but has a
        high commit frequency (≥ 5 commits in git history), suggesting it is
        actively evolving and may have outpaced its documentation.

        If no git_log_fn is provided this signal is skipped.
        """
        if self._git_log_fn is None:
            return []

        try:
            stat = path.stat()
        except OSError:
            return []

        age_days = (self._now_fn() - stat.st_mtime) / 86400.0
        if age_days > 7:
            return []  # not recently edited

        try:
            log_lines = self._git_log_fn(path)
        except Exception:
            return []

        commit_count = len([l for l in log_lines if l.strip()])
        if commit_count >= 5:
            return [
                {
                    "name": "edit_recency_vs_churn",
                    "detail": (
                        f"file modified {age_days:.1f} days ago and has {commit_count} commits"
                        " — actively changing, may need review"
                    ),
                }
            ]

        return []

    def _location_signals(self, path: Path, rel: str) -> List[dict]:
        """Flag files whose location suggests staleness risk.

        Detects:
        - Files named ``*old*``, ``*deprecated*``, ``*legacy*``, ``*obsolete*``
          that are NOT inside an archive/ dir (which is excluded).
        - Files in a ``docs/`` subdirectory that have no counterpart in the
          current codebase module they claim to document (heuristic: if the
          filename mentions a module that no longer exists).
        """
        signals = []
        basename = path.stem.lower()
        stale_names = {"old", "deprecated", "legacy", "obsolete", "archive"}
        for keyword in stale_names:
            if keyword in basename:
                signals.append(
                    {
                        "name": "stale_name_location",
                        "detail": (
                            f"filename '{path.name}' contains '{keyword}' — "
                            "may indicate superseded content"
                        ),
                    }
                )
                break
        return signals

    def _archive_to_live_ratio(self, path: Path) -> List[dict]:
        """Detect high proportion of 'archived' / resolved-problem content.

        Intra-doc heuristic: count lines under headings that contain
        'archive', 'resolved', 'completed', 'done', 'old', 'deprecated',
        'legacy' vs total non-blank lines.  If > 60% of content is under
        such headings, emit a signal.
        """
        signals = []
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return signals

        lines = text.splitlines()
        archive_heading_re = re.compile(
            r"^#{1,4}\s+.*\b(archive|resolved|completed|done|old|deprecated|legacy)\b",
            re.IGNORECASE,
        )
        in_archive_section = False
        archive_lines = 0
        total_lines = 0

        for line in lines:
            stripped = line.strip()
            if not stripped:
                continue
            total_lines += 1
            if re.match(r"^#{1,4}\s+", stripped):
                in_archive_section = bool(archive_heading_re.match(stripped))
            if in_archive_section:
                archive_lines += 1

        if total_lines > 0 and (archive_lines / total_lines) > 0.6:
            pct = int(100 * archive_lines / total_lines)
            signals.append(
                {
                    "name": "archive_to_live_ratio",
                    "detail": (
                        f"{pct}% of content is under archive/resolved/completed headings"
                    ),
                }
            )

        return signals

    def _frontmatter_markers(self, path: Path) -> List[dict]:
        """Emit signals for notable frontmatter keys that flag provisional content.

        Objective markers only: ``status: draft``, ``status: provisional``,
        ``status: wip``, ``draft: true``.  No classification — just fact.
        """
        signals = []
        for key, expected_values in [
            ("status", {"draft", "provisional", "wip", "todo"}),
            ("draft", {"true", "yes"}),
        ]:
            value = _parse_frontmatter_value(path, key)
            if value and value.lower() in expected_values:
                signals.append(
                    {
                        "name": "frontmatter_marker",
                        "detail": f"frontmatter has {key}: {value!r}",
                    }
                )
        return signals

    def _file_length(self, path: Path) -> List[dict]:
        """Flag files whose line count or estimated token count exceeds threshold.

        Objective fact only: reports the measured line count and estimated
        token count against the configured thresholds. Fires if EITHER
        threshold is exceeded. No classification.
        """
        signals = []
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return signals

        line_count = len(text.splitlines())
        token_count = self._token_estimator.estimate(text)

        if line_count > self._max_lines or token_count > self._max_tokens:
            token_count_k = token_count / 1000.0
            signals.append(
                {
                    "name": "file_length",
                    "detail": (
                        f"{line_count} lines (~{token_count_k:.1f}k tokens est), "
                        f"threshold {self._max_lines} lines / {self._max_tokens} tokens"
                    ),
                }
            )

        return signals


# ---------------------------------------------------------------------------
# Production git helper
# ---------------------------------------------------------------------------

def _git_log_real(path: Path) -> List[str]:
    """Return one line per commit touching *path* (commit hash only)."""
    try:
        result = subprocess.run(
            ["git", "log", "--oneline", "--follow", "--", str(path)],
            capture_output=True,
            text=True,
            timeout=10,
        )
        return result.stdout.splitlines()
    except Exception:
        return []


def _git_commit_time_real(path: Path) -> Optional[str]:
    """Return the ISO-8601 committer time of *path*'s most recent commit.

    Returns ``None`` when the path is untracked (no commit history) or on
    any subprocess failure — callers fall back to filesystem mtime.
    """
    try:
        result = subprocess.run(
            ["git", "log", "-1", "--format=%cI", "--", str(path)],
            capture_output=True,
            text=True,
            timeout=10,
        )
        out = result.stdout.strip()
        return out or None
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Lifecycle signal computation (design.md Decision 2 / 4)
# ---------------------------------------------------------------------------

_LIFECYCLE_SIGNAL_NAME = "lifecycle"


def _substitute_served_when_path(pattern: str, basename: str) -> str:
    """Substitute ``{id}``/``{name}`` in a served_when_path pattern with the
    matched entry's basename."""
    return pattern.replace("{id}", basename).replace("{name}", basename)


class LifecycleSignalBuilder:
    """Builds lifecycle signal dicts from rulebook matches.

    Injected dependencies mirror ``SignalComputer``: a git-commit-time
    provider and a clock, both fakeable for tests. Age/retention are the
    only stateful parts — retention ranking requires all matches for a rule
    to be known first, so ``rank_temporary_matches`` is a separate pass run
    once the whole walk has completed.
    """

    def __init__(
        self,
        root: Path,
        git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
        now_fn: Optional[Callable[[], float]] = None,
    ) -> None:
        self._root = root
        self._git_commit_time_fn = git_commit_time_fn
        self._now_fn = now_fn or (lambda: __import__("time").time())

    # ------------------------------------------------------------------
    def age_days(self, rel_path: str, is_dir: bool) -> Optional[float]:
        """Age in days: git commit time, falling back to mtime for
        untracked paths. Untracked directories use one stat() on the
        directory inode itself (never a recursive max-mtime walk)."""
        abs_path = self._root / rel_path

        commit_iso: Optional[str] = None
        if self._git_commit_time_fn is not None:
            try:
                commit_iso = self._git_commit_time_fn(abs_path)
            except Exception:
                commit_iso = None

        if commit_iso:
            try:
                commit_dt = datetime.fromisoformat(commit_iso)
                commit_ts = commit_dt.timestamp()
                return (self._now_fn() - commit_ts) / 86400.0
            except ValueError:
                pass  # fall through to mtime

        # Untracked (or unparseable commit time): filesystem mtime. For a
        # directory this is a single stat() on the directory inode itself —
        # never a recursive walk over its contents (design.md Decision 4).
        try:
            stat = abs_path.stat()
        except OSError:
            return None
        return (self._now_fn() - stat.st_mtime) / 86400.0

    # ------------------------------------------------------------------
    def build(self, rel_path: str, match: "RuleMatch", is_dir: bool) -> dict:
        """Build the lifecycle signal dict for a matched (non-ignore) rule."""
        rule = match.rule
        lifetime = rule.get("lifetime")
        basename = Path(rel_path).name

        sig: dict = {
            "name": _LIFECYCLE_SIGNAL_NAME,
            "rule_ref": rule.get("glob"),
            "lifetime": lifetime,
            "detail": f"matched lifecycle rule {rule.get('glob')!r} (lifetime={lifetime})",
        }
        if rule.get("extract"):
            sig["extract"] = True

        served_when_path = rule.get("served_when_path")
        served_when = rule.get("served_when")
        if served_when_path:
            resolved = _substitute_served_when_path(served_when_path, basename)
            satisfied = (self._root / resolved).exists()
            sig["served"] = {
                "kind": "scanner-proven",
                "served_when_path": served_when_path,
                "resolved_path": resolved,
                "satisfied": satisfied,
            }
        elif served_when:
            sig["served"] = {
                "kind": "classifier-judged",
                "served_when": served_when,
            }

        if lifetime == "temporary":
            sig["age_days"] = self.age_days(rel_path, is_dir)
            sig["retain_recent"] = rule.get("retain_recent", 3)
            sig["max_age_days"] = rule.get("max_age_days", 3)

        return sig


def rank_temporary_matches(entries: List[dict]) -> None:
    """Mutate *entries*' lifecycle signal dicts in-place with retention info.

    *entries* is a list of ``{"rule": <rule dict>, "signal": <sig dict>}``
    covering every temporary-lifetime match produced during a single scan.
    Grouped by rule identity (the same rule dict object is shared across all
    its matches within one Rulebook), ranked newest-first by age_days: the
    top ``retain_recent`` are always kept; rank ``retain_recent + 1`` or
    older is flagged deletable once it exceeds ``max_age_days``.
    """
    groups: dict = {}
    for entry in entries:
        groups.setdefault(id(entry["rule"]), []).append(entry)

    for group in groups.values():
        # Newest first: smallest age_days first. None age sorts last.
        group.sort(key=lambda e: (e["signal"].get("age_days") is None, e["signal"].get("age_days") or 0.0))
        retain_recent = group[0]["signal"].get("retain_recent", 3) if group else 3
        max_age_days = group[0]["signal"].get("max_age_days", 3) if group else 3
        for rank, entry in enumerate(group, start=1):
            sig = entry["signal"]
            age = sig.get("age_days")
            kept_by_rank = rank <= retain_recent
            deletable = (not kept_by_rank) and (age is not None) and (age > max_age_days)
            sig["retention"] = {
                "rank": rank,
                "kept": kept_by_rank,
                "deletable": deletable,
            }


# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------

class Scanner:
    """Deterministic doc-hygiene scanner.

    Parameters
    ----------
    root : Path
        Resolved project root (absolute).  All output paths are relative to
        this directory.
    scope_globs : list[str]
        Glob patterns to match candidate files (relative to *root*).
    excluded_dirs : list[str]
        Entries to prune during walk.  A bare entry (no ``/``) is a directory
        *name* matched at any depth; an entry of the form ``parent/child`` is a
        path-aware parent/child name pair pruned only where the child's
        immediate parent matches ``parent`` (depth-independent).
    git_log_fn : callable, optional
        Injected git-log provider.  Default: real git subprocess.
    now_fn : callable, optional
        Injected clock.  Default: ``time.time``.
    max_lines : int
        Line-count threshold for the ``file_length`` signal. Default 400.
    max_tokens : int
        Estimated-token threshold for the ``file_length`` signal. Default 4000.
    token_estimator : TokenEstimator, optional
        Injectable estimator for the ``file_length`` signal. Default:
        ``token_estimator.default_estimator()``.
    """

    def __init__(
        self,
        root: Path,
        scope_globs: Optional[List[str]] = None,
        excluded_dirs: Optional[List[str]] = None,
        git_log_fn: Optional[Callable[[Path], List[str]]] = None,
        now_fn: Optional[Callable[[], float]] = None,
        max_lines: int = DEFAULT_MAX_LINES,
        max_tokens: int = DEFAULT_MAX_TOKENS,
        token_estimator: Optional[TokenEstimator] = None,
        rulebook: Optional["Any"] = None,
        git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
    ) -> None:
        self._root = root.resolve()
        self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
        # Always include .cc-os (canonical, ADR-027) and .dochygiene (legacy) in
        # excluded dirs (self-exclusion invariant #9). Preserve insertion order
        # so the artifact echoes the canonical default order (matches
        # valid_report.json golden fixture).
        base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS
        seen: set = set()
        deduped: List[str] = []
        for d in list(base_excluded) + [".cc-os", ".dochygiene"]:
            if d not in seen:
                seen.add(d)
                deduped.append(d)
        self._excluded_dirs: List[str] = deduped
        # Partition the exclude list into two matchers (both echoed verbatim in
        # the artifact's ``excluded_dirs``):
        #   * bare names (no "/")     → prune any child dir with that exact name.
        #   * "parent/child" pairs    → prune a child dir named ``child`` only
        #                               when its immediate parent is ``parent``,
        #                               anywhere in the tree (path-aware).
        # Only the trailing two segments of a "/"-entry are used as the pair; the
        # match is depth-independent (not anchored to the project root) so it
        # catches e.g. ``doc-hygiene/examples/golden`` in a monorepo.
        self._excluded_name_set: set = {d for d in deduped if "/" not in d}
        self._excluded_pairs: List[tuple] = []
        for d in deduped:
            if "/" in d:
                parent, child = d.rsplit("/", 1)
                parent_name = parent.rsplit("/", 1)[-1]  # trailing segment only
                self._excluded_pairs.append((parent_name, child))
        self._git_log_fn = git_log_fn
        self._now_fn = now_fn
        self._max_lines = max_lines
        self._max_tokens = max_tokens
        self._token_estimator = token_estimator
        self._ignore_patterns: List[str] = _load_ignore_patterns(self._root)
        self._rulebook = rulebook
        self._lifecycle_builder = LifecycleSignalBuilder(
            root=self._root,
            git_commit_time_fn=git_commit_time_fn,
            now_fn=now_fn,
        )
        # Directory-rule aggregate entries pruned during the walk (design.md
        # Decision 2): populated by _walk_scoped, consumed by run().
        self._pruned_dir_matches: List[tuple] = []  # (rel_path, RuleMatch)

    def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool:
        """Return True if walking would prune *child_name* under *parent_dirpath*.

        Bare-name excludes match on ``child_name`` alone; path-aware excludes
        (``parent/child``) match only when the child's immediate parent name
        equals ``parent``.
        """
        if child_name in self._excluded_name_set:
            return True
        parent_name = os.path.basename(parent_dirpath)
        for ex_parent, ex_child in self._excluded_pairs:
            if child_name == ex_child and parent_name == ex_parent:
                return True
        return False

    # ------------------------------------------------------------------
    # Public
    # ------------------------------------------------------------------

    def run(self) -> dict:
        """Execute the scan and return the intermediate artifact dict."""
        signal_computer = SignalComputer(
            root=self._root,
            git_log_fn=self._git_log_fn,
            now_fn=self._now_fn,
            max_lines=self._max_lines,
            max_tokens=self._max_tokens,
            token_estimator=self._token_estimator,
        )
        ignore_patterns = self._ignore_patterns

        files_scanned = 0
        shortlist: List[str] = []
        signals_map: dict = {}
        # Collected for the post-walk retention-ranking pass (design.md
        # Decision 4): every temporary-lifetime match, dir or file, in one
        # flat list so rank_temporary_matches can group by rule identity.
        temporary_entries: List[dict] = []

        for path in self._walk_scoped():
            files_scanned += 1
            rel = str(path.relative_to(self._root))

            # Exclusion pipeline (short-circuit, ordered per D7)
            # (1) dir-prune already applied by _walk_scoped at walk time
            # (2) ignore-match
            if _matches_ignore(rel, ignore_patterns):
                continue
            # (3) frozen frontmatter
            if _is_frozen(path):
                continue
            # (4) append-only
            if _is_append_only(path):
                continue

            # Survived exclusions — compute signals
            sigs = signal_computer.compute(path)

            # Rulebook file-rule lifecycle signal attachment (design.md
            # Decision 2): alongside any pre-existing objective signals.
            if self._rulebook is not None:
                match = self._rulebook.query(rel, is_dir=False)
                if match is not None and not match.is_ignore:
                    lifecycle_sig = self._lifecycle_builder.build(
                        rel, match, is_dir=False
                    )
                    sigs = list(sigs) + [lifecycle_sig]
                    if lifecycle_sig.get("lifetime") == "temporary":
                        temporary_entries.append(
                            {"rule": match.rule, "signal": lifecycle_sig}
                        )

            shortlist.append(rel)
            if sigs:
                signals_map[rel] = sigs

        # Directory-rule aggregate entries pruned during the walk: exactly
        # one shortlist/signal entry per matched directory (design.md
        # Decision 2) — never one entry per file beneath it (those files
        # were never opened).
        for dir_rel, match in self._pruned_dir_matches:
            lifecycle_sig = self._lifecycle_builder.build(
                dir_rel, match, is_dir=True
            )
            shortlist.append(dir_rel)
            signals_map[dir_rel] = [lifecycle_sig]
            if lifecycle_sig.get("lifetime") == "temporary":
                temporary_entries.append(
                    {"rule": match.rule, "signal": lifecycle_sig}
                )

        # Retention ranking requires every match for a rule to be known
        # first, so this runs once, after the full walk (design.md
        # Decision 4).
        if temporary_entries:
            rank_temporary_matches(temporary_entries)

        return {
            "project_root": str(self._root),
            "scope_globs": self._scope_globs,
            "excluded_dirs": self._excluded_dirs,
            "files_scanned": files_scanned,
            "shortlist": shortlist,
            "signals": signals_map,
        }

    # ------------------------------------------------------------------
    # Walk
    # ------------------------------------------------------------------

    def _walk_scoped(self) -> Iterator[Path]:
        """Yield files matching scope_globs, with excluded dirs pruned at walk time."""
        self._pruned_dir_matches = []
        for dirpath, dirnames, filenames in os.walk(str(self._root)):
            # Prune excluded dirs IN-PLACE so os.walk never descends them (D7).
            # Path-aware pairs (e.g. examples/golden) are matched against the
            # current dirpath's basename so only the right parent prunes them.
            dirnames[:] = [
                d for d in dirnames if not self._is_excluded_child(dirpath, d)
            ]

            # Rulebook-driven directory-rule pruning (design.md Decision 2).
            # Checked BEFORE opening any file beneath a matched directory —
            # a directory-rule match (including IGNORE-surface entries) is
            # pruned from dirnames so os.walk never descends into it.
            if self._rulebook is not None:
                survivors: List[str] = []
                for d in dirnames:
                    dir_abs = Path(dirpath) / d
                    dir_rel = str(dir_abs.relative_to(self._root))
                    match = self._rulebook.query(dir_rel, is_dir=True)
                    if match is None:
                        survivors.append(d)
                        continue
                    # Matched: prune regardless of is_ignore.
                    if not match.is_ignore:
                        self._pruned_dir_matches.append((dir_rel, match))
                    # IGNORE-surface matches: pruned with zero emission.
                dirnames[:] = survivors

            for filename in filenames:
                filepath = Path(dirpath) / filename
                rel = filepath.relative_to(self._root)
                rel_str = str(rel)
                for glob in self._scope_globs:
                    if _file_matches_glob(rel_str, glob):
                        yield filepath
                        break


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def _resolve_project_root(start: Path) -> Path:
    """Walk upward from *start* to find a .git directory; fallback to *start*."""
    current = start.resolve()
    for parent in [current, *current.parents]:
        if (parent / ".git").exists():
            return parent
    return current


def main(argv: Optional[List[str]] = None) -> int:
    import argparse

    parser = argparse.ArgumentParser(
        description="doc-hygiene scanner — emits intermediate artifact JSON"
    )
    parser.add_argument(
        "--root",
        default=None,
        help="Project root (default: auto-resolved from cwd)",
    )
    parser.add_argument(
        "--globs",
        nargs="*",
        default=None,
        help="Scope globs (default: **/*.md)",
    )
    parser.add_argument(
        "--excluded-dirs",
        nargs="*",
        default=None,
        help="Directory names to exclude",
    )
    parser.add_argument(
        "--max-lines",
        type=int,
        default=DEFAULT_MAX_LINES,
        help=f"Line-count threshold for the file_length signal (default: {DEFAULT_MAX_LINES})",
    )
    parser.add_argument(
        "--max-tokens",
        type=int,
        default=DEFAULT_MAX_TOKENS,
        help=f"Estimated-token threshold for the file_length signal (default: {DEFAULT_MAX_TOKENS})",
    )
    args = parser.parse_args(argv)

    root = Path(args.root) if args.root else _resolve_project_root(Path.cwd())

    scanner = Scanner(
        root=root,
        scope_globs=args.globs,
        excluded_dirs=args.excluded_dirs,
        git_log_fn=_git_log_real,
        git_commit_time_fn=_git_commit_time_real,
        max_lines=args.max_lines,
        max_tokens=args.max_tokens,
    )
    artifact = scanner.run()
    print(json.dumps(artifact, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())
