"""Acknowledged / deferred doctor findings — the "page only on NEW drift" governor.

A committed ``acknowledgements.toml`` (beside ``SECRETS.toml`` in mgf-standard, and
optionally sooth-standard) records findings the founder has knowingly deferred (e.g.
the D-5 mgf-website deploy-path drift). The doctor still REPORTS an acknowledged
finding (flagged, never silently dropped — it stays in ``--json``), but the paging
cron treats an all-acknowledged run as *clean-for-paging*, so a known-deferred state
does not train the operator to ignore the pager (the C9-noise anti-pattern).

Values-free + git-audited: an ack carries only a ``check``, a target (``id`` +
optional ``repo``), a required ``reason``, and an optional ``expires``. An EXPIRED ack
is inactive — its finding pages again — so an ack can never hide drift forever.
"""

from __future__ import annotations

import os
import tomllib
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Sequence

#: (repo, filename) pairs loaded by default — mirrors the registry's sibling-file load.
DEFAULT_ACK_FILES = (
    ("mgf-standard", "acknowledgements.toml"),
    ("sooth-standard", "acknowledgements.toml"),
)


class AckError(Exception):
    """A malformed ``acknowledgements.toml`` (missing required field / bad date)."""

    def __init__(self, problems: list[str]) -> None:
        self.problems = problems
        super().__init__("; ".join(problems))


def _expiry_ts(expires: str) -> float:
    """Unix seconds of an ISO ``expires`` (date or datetime); naive is treated as UTC."""
    dt = datetime.fromisoformat(expires)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=UTC)
    return dt.timestamp()


@dataclass(frozen=True)
class Acknowledgement:
    """One acknowledged finding class. ``expires`` is the ISO instant the ack becomes
    INACTIVE (exclusive); ``None`` = indefinite (the reason must justify it)."""

    check: str
    id: str
    reason: str
    repo: str | None = None
    expires: str | None = None

    def is_active(self, now: float) -> bool:
        """An ack with no ``expires`` is always active; otherwise active while
        ``now`` precedes the expiry instant. An expired ack no longer suppresses."""
        if self.expires is None:
            return True
        return now < _expiry_ts(self.expires)

    def matches(self, check: str, finding_id: str, repo: str | None) -> bool:
        """Structural match (ignores activity): same ``check`` + ``id``, and — when the
        ack pins a ``repo`` — the same repo. A repo-less ack matches any repo."""
        if check != self.check or finding_id != self.id:
            return False
        return self.repo is None or self.repo == repo

    def to_json(self) -> dict[str, Any]:
        return {
            "check": self.check,
            "id": self.id,
            "repo": self.repo,
            "reason": self.reason,
            "expires": self.expires,
        }


@dataclass(frozen=True)
class Acknowledgements:
    """The loaded ack set. ``active_match`` returns the first ACTIVE ack matching a
    finding; ``expired_match`` returns a matching-but-EXPIRED ack (for the audit note)."""

    acks: tuple[Acknowledgement, ...] = ()

    def __len__(self) -> int:
        return len(self.acks)

    def active_match(
        self, check: str, finding_id: str, repo: str | None, *, now: float
    ) -> Acknowledgement | None:
        for a in self.acks:
            if a.matches(check, finding_id, repo) and a.is_active(now):
                return a
        return None

    def expired_match(
        self, check: str, finding_id: str, repo: str | None, *, now: float
    ) -> Acknowledgement | None:
        for a in self.acks:
            if a.matches(check, finding_id, repo) and not a.is_active(now):
                return a
        return None


def fed_root(explicit: str | Path | None = None) -> Path:
    if explicit is not None:
        return Path(explicit)
    env = os.environ.get("MGF_ROOT")
    if env:
        return Path(env)
    return Path.home() / "PycharmProjects"


def default_ack_paths(root: str | Path | None = None) -> list[Path]:
    base = fed_root(root)
    return [base / repo / name for repo, name in DEFAULT_ACK_FILES]


def _load_one(path: Path, problems: list[str]) -> list[Acknowledgement]:
    try:
        raw = tomllib.loads(path.read_text(encoding="utf-8"))
    except (OSError, tomllib.TOMLDecodeError) as exc:
        problems.append(f"{path}: cannot parse ({exc})")
        return []
    out: list[Acknowledgement] = []
    for i, entry in enumerate(raw.get("acknowledgement", []) or []):
        where = f"{path.name}[{i}]"
        if not isinstance(entry, dict):
            problems.append(f"{where}: not a table")
            continue
        check = entry.get("check")
        fid = entry.get("id")
        reason = entry.get("reason")
        missing = [k for k, v in (("check", check), ("id", fid), ("reason", reason)) if not v]
        if missing:
            problems.append(f"{where}: missing required field(s) {missing}")
            continue
        expires = entry.get("expires")
        if expires is not None:
            try:
                _expiry_ts(str(expires))
            except ValueError:
                problems.append(f"{where}: invalid `expires` {expires!r} (want ISO date/datetime)")
                continue
        out.append(
            Acknowledgement(
                check=str(check),
                id=str(fid),
                reason=str(reason),
                repo=str(entry["repo"]) if entry.get("repo") else None,
                expires=str(expires) if expires is not None else None,
            )
        )
    return out


def load_acknowledgements(
    paths: Sequence[str | Path] | None = None, *, root: str | Path | None = None
) -> Acknowledgements:
    """Load acks from the sibling standards files (absent files → empty, no error).

    Fails closed (:class:`AckError`) only on a PRESENT-but-malformed file, so a typo
    can never silently turn into an ack that hides drift — but a missing file simply
    means "nothing deferred yet"."""
    candidates = [Path(p) for p in paths] if paths is not None else default_ack_paths(root)
    problems: list[str] = []
    acks: list[Acknowledgement] = []
    for p in candidates:
        if Path(p).is_file():
            acks.extend(_load_one(Path(p), problems))
    if problems:
        raise AckError(problems)
    return Acknowledgements(acks=tuple(acks))
