"""Freshness check for stale docstrings via git diff and git blame.

Detects code changes that may have made docstrings stale. Diff mode maps
git diff hunks to AST symbols; drift mode uses git blame age comparison.
Implements Layer 4 of the docstring quality model.

Examples:
    Run the freshness diff check on a file::

        from docvet.checks import check_freshness_diff

        findings = check_freshness_diff(path, diff_text, source, tree)

See Also:
    `docvet.config`: ``FreshnessConfig`` dataclass for drift thresholds.
    `docvet.checks`: Package-level re-exports.
"""

from __future__ import annotations

import ast
import re
import time
from datetime import datetime, timezone
from typing import Literal

from docvet.ast_utils import Symbol, map_lines_to_symbols
from docvet.checks._finding import Finding
from docvet.config import FreshnessConfig

__all__ = ["check_freshness_diff", "check_freshness_drift"]

# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------

_HUNK_PATTERN = re.compile(r"^@@ .+\+(\d+)(?:,(\d+))? @@")

# Rule identifier string literals (for reference):
# "stale-signature"  — function signature changed, docstring not updated
# "stale-body"       — function body changed, docstring not updated
# "stale-import"     — import changed, docstring not updated

_CLASSIFICATION_MAP: dict[str, tuple[str, Literal["required", "recommended"]]] = {
    "signature": ("stale-signature", "required"),
    "body": ("stale-body", "recommended"),
    "import": ("stale-import", "recommended"),
}


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------


def _build_finding(
    file_path: str,
    symbol: Symbol,
    rule: str,
    message: str,
    category: Literal["required", "recommended"],
) -> Finding:
    """Construct a Finding from freshness detection results.

    Args:
        file_path: Source file path where the finding was detected.
        symbol: The symbol whose docstring is potentially stale.
        rule: Kebab-case rule identifier.
        message: Human-readable description of the issue.
        category: Severity classification.

    Returns:
        A Finding with fields mapped from the symbol and arguments.
    """
    return Finding(
        file=file_path,
        line=symbol.line,
        symbol=symbol.name,
        rule=rule,
        message=message,
        category=category,
    )


# ---------------------------------------------------------------------------
# Diff mode helpers
# ---------------------------------------------------------------------------


def _parse_diff_hunks(diff_output: str) -> set[int]:
    """Extract changed line numbers from unified diff output.

    Parses ``@@ ... +start,count @@`` hunk headers and expands them
    into a set of affected line numbers in the new file version.

    Args:
        diff_output: Raw unified diff output from ``git diff``.

    Returns:
        A set of 1-based line numbers changed in the new file version.
        Returns an empty set for new files, binary files, or empty input.
    """
    if not diff_output:
        return set()

    changed: set[int] = set()
    is_new_file = False

    for line in diff_output.splitlines():
        if line.startswith("--- /dev/null"):
            is_new_file = True
            continue
        if line.startswith("Binary files"):
            return set()
        match = _HUNK_PATTERN.match(line)
        if match:
            start = int(match.group(1))
            count = int(match.group(2) or "1")
            changed.update(range(start, start + count))

    if is_new_file:
        return set()
    return changed


def _classify_changed_lines(
    changed_lines: set[int],
    symbol: Symbol,
) -> str | None:
    """Classify changed lines relative to a symbol's ranges.

    Determines the highest-severity change type by checking whether
    changed lines overlap the symbol's docstring, signature, or body
    ranges. Docstring overlap suppresses findings entirely.

    Args:
        changed_lines: Set of 1-based line numbers that changed.
        symbol: The symbol to classify changes against.

    Returns:
        ``"signature"`` for HIGH severity, ``"body"`` for MEDIUM,
        ``"import"`` for LOW, or ``None`` if the docstring was updated
        (finding suppressed).
    """
    # 1. Docstring updated → suppress finding
    if symbol.docstring_range is not None:
        ds, de = symbol.docstring_range
        if any(ds <= line <= de for line in changed_lines):
            return None

    # 2. Signature changed → HIGH
    if symbol.signature_range is not None:
        ss, se = symbol.signature_range
        if any(ss <= line <= se for line in changed_lines):
            return "signature"

    # 3. Body changed → MEDIUM
    bs, be = symbol.body_range
    if any(bs <= line <= be for line in changed_lines):
        return "body"

    # 4. Else → LOW (import/formatting)
    return "import"


def check_freshness_diff(
    file_path: str,
    diff_output: str,
    tree: ast.Module,
) -> list[Finding]:
    """Check a file for stale docstrings using git diff output.

    Maps changed lines from the diff to AST symbols, classifies the
    change type, and produces findings for symbols whose docstrings
    were not updated alongside code changes.

    Args:
        file_path: Source file path for finding attribution.
        diff_output: Raw unified diff output from ``git diff``.
        tree: Parsed AST module from ``ast.parse()``.

    Returns:
        A list of findings for symbols with stale docstrings.
        Returns an empty list when the diff is empty or all changed
        symbols have updated docstrings.
    """
    changed_lines = _parse_diff_hunks(diff_output)
    if not changed_lines:
        return []

    line_map = map_lines_to_symbols(tree)

    # Invert: group changed lines by symbol
    symbol_changes: dict[Symbol, set[int]] = {}
    for line_num in changed_lines:
        sym = line_map.get(line_num)
        if sym is not None:
            if sym not in symbol_changes:
                symbol_changes[sym] = set()
            symbol_changes[sym].add(line_num)

    findings: list[Finding] = []
    for symbol, its_changed_lines in symbol_changes.items():
        if symbol.docstring_range is None:
            continue  # FR54: skip undocumented symbols

        classification = _classify_changed_lines(its_changed_lines, symbol)
        if classification is None:
            continue  # Docstring was updated

        rule, category = _CLASSIFICATION_MAP[classification]
        kind = symbol.kind.capitalize()
        message = (
            f"{kind} '{symbol.name}' {classification} changed but docstring not updated"
        )
        findings.append(_build_finding(file_path, symbol, rule, message, category))

    findings.sort(key=lambda f: f.line)
    return findings


# ---------------------------------------------------------------------------
# Drift mode helpers
# ---------------------------------------------------------------------------


def _classify_blame_line(line: str) -> tuple[str, int | None]:
    """Classify a single git blame porcelain line.

    Identifies whether a line is a SHA header, an ``author-time``
    field, a tab-prefixed content line, or other metadata.

    Args:
        line: A single line from ``git blame --line-porcelain`` output.

    Returns:
        A ``(kind, value)`` pair.  *kind* is ``"sha"`` (value = line
        number or *None* if malformed), ``"timestamp"`` (value = Unix
        timestamp or *None*), ``"content"`` (value = *None*, signals
        end of blame block), or ``"other"`` (value = *None*).
    """
    parts = line.split()
    if len(parts) >= 3 and len(parts[0]) == 40:
        try:
            return "sha", int(parts[2])
        except ValueError:
            return "sha", None

    if line.startswith("author-time "):
        try:
            return "timestamp", int(parts[1])
        except (ValueError, IndexError):
            return "timestamp", None

    if line.startswith("\t"):
        return "content", None

    return "other", None


def _parse_blame_timestamps(blame_output: str) -> dict[int, int]:
    """Extract per-line modification timestamps from git blame porcelain output.

    Parses ``git blame --line-porcelain`` output and builds a mapping from
    1-based line numbers to Unix timestamps extracted from ``author-time``
    fields.  Uses a simple state machine: each blame block starts with a
    SHA line (setting the current line number), accumulates an
    ``author-time`` value, and emits the mapping entry when a tab-prefixed
    content line is encountered.

    Args:
        blame_output: Raw output from ``git blame --line-porcelain``.

    Returns:
        A dict mapping 1-based line numbers to Unix timestamps.  Returns
        an empty dict for empty or whitespace-only input, and silently
        skips malformed or truncated blame blocks.
    """
    if not blame_output:
        return {}

    timestamps: dict[int, int] = {}
    current_line: int | None = None
    current_timestamp: int | None = None

    for line in blame_output.splitlines():
        kind, value = _classify_blame_line(line)
        if kind == "sha":
            current_line = value
            current_timestamp = None
        elif kind == "timestamp" and value is not None:
            current_timestamp = value
        elif kind == "content":
            if current_line is not None and current_timestamp is not None:
                timestamps[current_line] = current_timestamp
            current_line = None
            current_timestamp = None

    return timestamps


def _compute_drift(
    code_timestamps: list[int],
    doc_timestamps: list[int],
    threshold: int,
) -> bool:
    """Determine whether code has drifted ahead of its docstring.

    Compares the most recent code modification timestamp against the most
    recent docstring modification timestamp.  Drift exceeds the threshold
    when the difference is strictly greater than ``threshold * 86400``
    seconds.

    Args:
        code_timestamps: Unix timestamps for code lines of a symbol.
        doc_timestamps: Unix timestamps for docstring lines of a symbol.
        threshold: Maximum allowed drift in days.

    Returns:
        ``True`` if code is more than *threshold* days newer than the
        docstring, ``False`` otherwise or when either list is empty.
    """
    if not code_timestamps or not doc_timestamps:
        return False
    return max(code_timestamps) - max(doc_timestamps) > threshold * 86400


def _compute_age(
    doc_timestamps: list[int],
    now: int,
    threshold: int,
) -> bool:
    """Determine whether a docstring has aged past a threshold.

    Compares the current time against the most recent docstring
    modification timestamp.  Age exceeds the threshold when the
    difference is strictly greater than ``threshold * 86400`` seconds.

    Args:
        doc_timestamps: Unix timestamps for docstring lines of a symbol.
        now: Current time as a Unix timestamp.
        threshold: Maximum allowed age in days.

    Returns:
        ``True`` if the docstring is more than *threshold* days old,
        ``False`` otherwise or when the list is empty.
    """
    if not doc_timestamps:
        return False
    return now - max(doc_timestamps) > threshold * 86400


def _group_timestamps_by_symbol(
    timestamps: dict[int, int],
    line_map: dict[int, Symbol],
) -> tuple[dict[Symbol, list[int]], dict[Symbol, list[int]]]:
    """Group per-line timestamps into code vs docstring buckets per symbol.

    For each line with a timestamp, looks up the owning symbol and
    classifies the line as docstring or code based on the symbol's
    ``docstring_range``.

    Args:
        timestamps: Mapping of 1-based line numbers to Unix timestamps.
        line_map: Mapping of line numbers to their owning symbols.

    Returns:
        A ``(code_ts, doc_ts)`` pair of dicts, each mapping symbols to
        their respective timestamp lists.
    """
    code_ts: dict[Symbol, list[int]] = {}
    doc_ts: dict[Symbol, list[int]] = {}
    for line_num, ts in timestamps.items():
        sym = line_map.get(line_num)
        if sym is None or sym.docstring_range is None:
            continue
        ds, de = sym.docstring_range
        if ds <= line_num <= de:
            doc_ts.setdefault(sym, []).append(ts)
        else:
            code_ts.setdefault(sym, []).append(ts)
    return code_ts, doc_ts


def _build_drift_finding(
    sym: Symbol,
    code_ts: list[int],
    doc_ts: list[int],
    file_path: str,
) -> Finding:
    """Construct a drift finding for a symbol whose code outpaced its docstring.

    Args:
        sym: The symbol with stale docstring.
        code_ts: Unix timestamps for the symbol's code lines.
        doc_ts: Unix timestamps for the symbol's docstring lines.
        file_path: Source file path for finding attribution.

    Returns:
        A drift finding with date details and day count.
    """
    code_max = max(code_ts)
    doc_max = max(doc_ts)
    code_date = datetime.fromtimestamp(code_max, tz=timezone.utc).date().isoformat()
    doc_date = datetime.fromtimestamp(doc_max, tz=timezone.utc).date().isoformat()
    days = (code_max - doc_max) // 86400
    kind = sym.kind.capitalize()
    message = (
        f"{kind} '{sym.name}' code modified {code_date}, "
        f"docstring last modified {doc_date} ({days} days drift)"
    )
    return _build_finding(file_path, sym, "stale-drift", message, "recommended")


def _build_age_finding(
    sym: Symbol,
    doc_ts: list[int],
    effective_now: int,
    file_path: str,
) -> Finding:
    """Construct an age finding for a symbol with an untouched docstring.

    Args:
        sym: The symbol with aged docstring.
        doc_ts: Unix timestamps for the symbol's docstring lines.
        effective_now: Current time as a Unix timestamp.
        file_path: Source file path for finding attribution.

    Returns:
        An age finding with the last-modified date and day count.
    """
    doc_max = max(doc_ts)
    doc_date = datetime.fromtimestamp(doc_max, tz=timezone.utc).date().isoformat()
    days = (effective_now - doc_max) // 86400
    kind = sym.kind.capitalize()
    message = f"{kind} '{sym.name}' docstring untouched since {doc_date} ({days} days)"
    return _build_finding(file_path, sym, "stale-age", message, "recommended")


def check_freshness_drift(
    file_path: str,
    blame_output: str,
    tree: ast.Module,
    config: FreshnessConfig,
    *,
    now: int | None = None,
) -> list[Finding]:
    """Check a file for stale docstrings using git blame timestamps.

    Parses blame output to extract per-line timestamps, groups them by
    AST symbol, and checks each symbol for drift (code newer than
    docstring) and age (docstring untouched too long).

    Args:
        file_path: Source file path for finding attribution.
        blame_output: Raw output from ``git blame --line-porcelain``.
        tree: Parsed AST module from ``ast.parse()``.
        config: Freshness configuration with threshold values.
        now: Current time as a Unix timestamp.  Defaults to
            ``int(time.time())`` when not provided.

    Returns:
        A list of findings for symbols with stale docstrings, sorted
        by line number.  Returns an empty list when blame output is
        empty or no symbols exceed thresholds.
    """
    if not blame_output:
        return []

    timestamps = _parse_blame_timestamps(blame_output)
    if not timestamps:
        return []

    line_map = map_lines_to_symbols(tree)
    symbol_code_ts, symbol_doc_ts = _group_timestamps_by_symbol(timestamps, line_map)

    effective_now = now if now is not None else int(time.time())

    findings: list[Finding] = []
    all_symbols = set(symbol_code_ts) | set(symbol_doc_ts)

    for sym in all_symbols:
        code_ts = symbol_code_ts.get(sym, [])
        doc_ts = symbol_doc_ts.get(sym, [])

        if _compute_drift(code_ts, doc_ts, config.drift_threshold):
            findings.append(_build_drift_finding(sym, code_ts, doc_ts, file_path))

        if _compute_age(doc_ts, effective_now, config.age_threshold):
            findings.append(_build_age_finding(sym, doc_ts, effective_now, file_path))

    findings.sort(key=lambda f: f.line)
    return findings
