"""The acknowledge / defer governor (SPEC-P4): the doctor still REPORTS an acked
finding (flagged, in --json) but the paging cron treats an all-acked run as
clean-for-paging. Expiry is load-bearing: an expired ack no longer suppresses.
"""

from __future__ import annotations

import time

import pytest

from mgf.secrets.acknowledgements import (
    AckError,
    Acknowledgement,
    Acknowledgements,
    load_acknowledgements,
)
from mgf.secrets.doctor import Doctor, DoctorReport, Finding
from mgf.secrets.registry import load
from mgf.secrets.watch import run_watch

# An active org secret that is MISSING live -> a C1 finding with id == the secret name.
ORG = """\
[s1]
family = "mgf"
kind = "api-token"
purpose = "p"
consumers = ["c"]
recovery = "r"
state = "active"
policy = { allowed_repos = "*", blast_radius = "b" }
rotation = { class = "token", staged = true }
verify = { probe = "auth" }
surfaces = [ { type = "woodpecker-org-secret", events = ["push"] } ]
"""


def _reg(tmp_path):
    p = tmp_path / "SECRETS.toml"
    p.write_text(ORG, encoding="utf-8")
    return load([p])


def _c1(tmp_path, acks):
    from tests._fakes import FakeWoodpecker

    wp = FakeWoodpecker(org_secrets={}, repos=[])  # s1 declared but missing -> C1
    return Doctor(_reg(tmp_path), fed_root=tmp_path, woodpecker=wp, acknowledgements=acks).run(
        ["woodpecker"]
    )


# --------------------------------------------------------------------------- #
# Unit: matching + expiry + loader.
# --------------------------------------------------------------------------- #


def test_ack_matches_check_id_and_optional_repo() -> None:
    a = Acknowledgement(check="C2", id="x", reason="r", repo="mgf-website")
    assert a.matches("C2", "x", "mgf-website")
    assert not a.matches("C2", "x", "other")  # repo pinned
    assert not a.matches("C4", "x", "mgf-website")  # wrong check
    anyrepo = Acknowledgement(check="C2", id="x", reason="r")
    assert anyrepo.matches("C2", "x", "whatever")  # repo-less matches any


def test_ack_expiry_is_load_bearing() -> None:
    now = time.time()
    assert Acknowledgement("C2", "x", "r").is_active(now)  # no expiry = indefinite
    assert not Acknowledgement("C2", "x", "r", expires="2000-01-01").is_active(now)  # past
    assert Acknowledgement("C2", "x", "r", expires="2999-01-01").is_active(now)  # future


def test_load_acknowledgements_and_malformed_fails_closed(tmp_path) -> None:
    good = tmp_path / "acknowledgements.toml"
    good.write_text(
        '[[acknowledgement]]\ncheck="C2"\nid="x"\nrepo="r"\nreason="deferred"\n', encoding="utf-8"
    )
    acks = load_acknowledgements([good])
    assert len(acks) == 1 and acks.acks[0].repo == "r"

    missing_reason = tmp_path / "bad.toml"
    missing_reason.write_text('[[acknowledgement]]\ncheck="C2"\nid="x"\n', encoding="utf-8")
    with pytest.raises(AckError):
        load_acknowledgements([missing_reason])

    bad_date = tmp_path / "bad2.toml"
    bad_date.write_text(
        '[[acknowledgement]]\ncheck="C2"\nid="x"\nreason="r"\nexpires="not-a-date"\n',
        encoding="utf-8",
    )
    with pytest.raises(AckError):
        load_acknowledgements([bad_date])


def test_absent_ack_files_are_empty_not_an_error(tmp_path) -> None:
    assert len(load_acknowledgements([tmp_path / "nope.toml"])) == 0


# --------------------------------------------------------------------------- #
# Doctor: acked findings are FLAGGED but STILL REPORTED (never silently dropped).
# --------------------------------------------------------------------------- #


def test_doctor_flags_acked_finding_and_keeps_it_in_json(tmp_path) -> None:
    acks = Acknowledgements((Acknowledgement(check="C1", id="s1", reason="D-5 deferred"),))
    report = _c1(tmp_path, acks)
    f = next(f for f in report.findings if f.id == "s1")
    assert f.acknowledged is True and f.ack_reason == "D-5 deferred"
    # still visible in --json with the flag (never silently dropped).
    jf = next(x for x in report.to_json()["findings"] if x["id"] == "s1")
    assert jf["acknowledged"] is True and jf["ack_reason"] == "D-5 deferred"
    # paging view excludes it; doctor exit is still 1 (there IS a finding).
    assert report.pageable_findings() == [] and len(report.acknowledged_findings()) == 1
    assert report.pageable() is False and report.exit_code() == 1


def test_unacked_finding_is_pageable(tmp_path) -> None:
    report = _c1(tmp_path, Acknowledgements(()))  # no acks
    f = next(f for f in report.findings if f.id == "s1")
    assert f.acknowledged is False
    assert report.pageable() is True and report.pageable_findings()


def test_expired_ack_pages_again_with_audit_note(tmp_path) -> None:
    acks = Acknowledgements(
        (Acknowledgement(check="C1", id="s1", reason="lapsed", expires="2000-01-01"),)
    )
    report = _c1(tmp_path, acks)
    f = next(f for f in report.findings if f.id == "s1")
    assert f.acknowledged is False  # expired -> NOT suppressed
    assert report.pageable() is True
    assert any("EXPIRED" in s and "s1" in s for s in report.skipped)


def test_repo_pinned_ack_does_not_suppress_other_repos(tmp_path) -> None:
    # The C1 finding has no repo (org secret) -> a repo-pinned ack must NOT match it.
    acks = Acknowledgements((Acknowledgement(check="C1", id="s1", reason="r", repo="some-repo"),))
    report = _c1(tmp_path, acks)
    assert report.pageable() is True  # not suppressed (repo mismatch)


# --------------------------------------------------------------------------- #
# Watch: page only on UN-acknowledged findings.
# --------------------------------------------------------------------------- #


class _Pager:
    def __init__(self, *, existing: bool = True) -> None:
        self.reports: list[dict] = []
        self.resolves: list[dict] = []
        self._open = existing

    def report(self, **kw) -> str:
        self.reports.append(kw)
        return "filed"

    def resolve(self, **kw) -> str:
        self.resolves.append(kw)
        return "closed" if self._open else "none"


class _HB:
    def __init__(self) -> None:
        self.beats: list[str] = []

    def beat(self, job: str) -> bool:
        self.beats.append(job)
        return True


def _rep(findings) -> DoctorReport:
    return DoctorReport(surfaces=["woodpecker", "forge"], findings=list(findings))


def test_watch_does_not_page_when_all_findings_acked() -> None:
    acked = Finding("s1", "C1", "high", "missing", acknowledged=True, ack_reason="D-5")
    p, h = _Pager(existing=True), _HB()
    out = run_watch(_rep([acked]), pager=p, heartbeat=h)
    assert out.clean is True and out.paged is False and out.acknowledged == 1
    assert p.resolves and not p.reports  # closed the standing issue, filed nothing
    assert h.beats == ["secret-doctor"]  # heartbeat still fires (cron is alive)


def test_watch_pages_when_any_finding_unacked() -> None:
    acked = Finding("s1", "C1", "high", "acked msg", acknowledged=True, ack_reason="D-5")
    unacked = Finding("s2", "C2", "high", "unacked ghost drift")
    p, h = _Pager(), _HB()
    out = run_watch(_rep([acked, unacked]), pager=p, heartbeat=h)
    assert out.paged is True and out.acknowledged == 1
    body = p.reports[0]["body"]
    assert "s2" in body  # the pageable finding is in the summary
    assert "acked msg" not in body  # the acked one is not paged


def test_watch_summary_is_value_free_with_acks() -> None:
    canary = "s3cr3t-ack-canary"
    acked = Finding("s1", "C1", "high", "m", {"leak": canary}, acknowledged=True, ack_reason=canary)
    unacked = Finding("s2", "C2", "high", "drift")
    out = run_watch(_rep([acked, unacked]), pager=None, heartbeat=None, page=False)
    assert canary not in out.body  # neither evidence nor the ack reason leaks


# --------------------------------------------------------------------------- #
# CLI ack verb (list + add round-trip).
# --------------------------------------------------------------------------- #


def test_cli_ack_add_then_list_and_suppress(tmp_path, capsys) -> None:
    import json

    from mgf.secrets.cli import main

    (tmp_path / "mgf-standard").mkdir()
    (tmp_path / "mgf-standard" / "SECRETS.toml").write_text(ORG, encoding="utf-8")

    code = main(
        [
            "--root",
            str(tmp_path),
            "ack",
            "C1",
            "s1",
            "--repo",
            "mgf-website",
            "--reason",
            'D-5 deferred ("deploy path")',
        ]
    )
    assert code == 0
    ack_file = tmp_path / "mgf-standard" / "acknowledgements.toml"
    assert ack_file.is_file()
    # It round-trips through the loader (the reason's quotes are escaped correctly).
    assert len(load_acknowledgements([ack_file])) == 1

    capsys.readouterr()
    assert main(["--root", str(tmp_path), "ack", "--list", "--json"]) == 0
    rows = json.loads(capsys.readouterr().out)
    assert any(r["id"] == "s1" and r["active"] for r in rows)

    # Adding without --reason is refused (exit 2).
    assert main(["--root", str(tmp_path), "ack", "C1", "s2"]) == 2
