"""Reconciler authorization gate for post-merge moot-lease cleanup (#745).

``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase:
released`` lease marker — a real, durable mutation of the PR lease ledger.
Before #745 it was gated on permissions alone (``gitea.read`` to enter,
``gitea.pr.comment`` to apply) with no canonical task and no role binding, so
any profile carrying ``gitea.pr.comment`` reached the mutation path while the
reconciler could not satisfy the operator-required resolve-exact-task ->
mutation sequence.

This module holds the pure half of that gate:

* the canonical task name and its tool-name alias;
* an **append-only** in-process ledger of read-only dry-run assessments;
* ``assess_apply_authorization``, which decides whether an apply may proceed.

Apply is authorized only when all of the following hold:

* the session resolved exactly the cleanup task (no other task substitutes);
* the active profile role is ``reconciler``;
* a prior dry run in this session recorded ``lease_moot`` and
  ``cleanup_allowed`` for the *same* repository, PR, lease session, candidate
  head and lease marker id;
* the live assessment still agrees with that evidence, so a lease superseded
  between the dry run and the apply fails closed;
* any caller-supplied expectations match the live lease exactly.

Everything else fails closed. The ledger is only ever appended to — a
superseded dry run stays visible as history instead of being rewritten — which
keeps the cleanup audit trail append-only end to end.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

CLEANUP_TASK = "cleanup_post_merge_moot_lease"
CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease"
REQUIRED_ROLE = "reconciler"
REQUIRED_PERMISSION = "gitea.pr.comment"

# The read-only assessment stays reachable under gitea.read for every role —
# the convention shared with cleanup_stale_review_decision_lock and
# cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a
# stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE.
ASSESSMENT_PERMISSION = "gitea.read"

_DRY_RUN_LEDGER: list[dict[str, Any]] = []


def _norm(value: Any) -> str:
    return str(value or "").strip()


def _norm_comment_id(value: Any) -> int | None:
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def record_dry_run(
    *,
    pr_number: int,
    repository_slug: str | None,
    lease_moot: bool,
    cleanup_allowed: bool,
    session_id: str | None,
    candidate_head: str | None,
    lease_comment_id: Any,
    recorded_at: datetime | None = None,
) -> dict[str, Any]:
    """Append one read-only assessment to the dry-run ledger.

    Never rewrites or removes a prior entry: repeated dry runs accumulate and
    ``latest_dry_run`` returns the newest matching one.
    """
    entry = {
        "task": CLEANUP_TASK,
        "pr_number": int(pr_number),
        "repository_slug": _norm(repository_slug) or None,
        "lease_moot": bool(lease_moot),
        "cleanup_allowed": bool(cleanup_allowed),
        "session_id": _norm(session_id) or None,
        "candidate_head": _norm(candidate_head) or None,
        "lease_comment_id": _norm_comment_id(lease_comment_id),
        "recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(),
    }
    _DRY_RUN_LEDGER.append(entry)
    return dict(entry)


def dry_run_history() -> tuple[dict[str, Any], ...]:
    """Immutable view of every recorded dry run, oldest first."""
    return tuple(dict(entry) for entry in _DRY_RUN_LEDGER)


def latest_dry_run(
    *, pr_number: int, repository_slug: str | None
) -> dict[str, Any] | None:
    """Newest dry-run evidence for this repository + PR, or None."""
    wanted_repo = _norm(repository_slug)
    for entry in reversed(_DRY_RUN_LEDGER):
        if entry["pr_number"] != int(pr_number):
            continue
        if _norm(entry.get("repository_slug")) != wanted_repo:
            continue
        return dict(entry)
    return None


def _reset_for_testing() -> None:
    """Drop ledger state between tests. Never called by production paths."""
    _DRY_RUN_LEDGER.clear()


def assess_apply_authorization(
    *,
    pr_number: int,
    repository_slug: str | None,
    resolved_task: str | None,
    active_role_kind: str | None,
    assessment: dict[str, Any],
    evidence: dict[str, Any] | None,
    expected_session_id: str | None = None,
    expected_candidate_head: str | None = None,
    expected_lease_comment_id: Any = None,
) -> dict[str, Any]:
    """Decide whether a moot-lease cleanup apply is authorized (fail closed).

    Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``.
    ``allowed`` is True only when every check passes; each failure contributes a
    reason so the caller can report all of them together.
    """
    reasons: list[str] = []
    blocker_kind: str | None = None

    def _block(kind: str, reason: str) -> None:
        nonlocal blocker_kind
        reasons.append(reason)
        if blocker_kind is None:
            blocker_kind = kind

    # 1. Exact resolved cleanup task. Resolving any other task — including a
    #    sibling reconciler task — does not authorize this mutation.
    if _norm(resolved_task) != CLEANUP_TASK:
        _block(
            "unresolved_cleanup_task",
            "post-merge moot-lease cleanup requires the session to resolve "
            f"task '{CLEANUP_TASK}' immediately before apply; resolved task is "
            f"{resolved_task!r} (fail closed)",
        )

    # 2. Dedicated reconciler role, enforced independently of the permission.
    if _norm(active_role_kind) != REQUIRED_ROLE:
        _block(
            "wrong_role",
            f"profile role {active_role_kind!r} cannot apply post-merge "
            f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when "
            f"{REQUIRED_PERMISSION} is present (fail closed)",
        )

    # 3. Canonical repository identity must be established, never inferred from
    #    request parameters.
    if not _norm(repository_slug):
        _block(
            "repository_binding",
            "no canonical repository identity could be established for the "
            "cleanup target (fail closed)",
        )

    # 4. The live safety assessment must still say the lease is moot/cleanable.
    if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"):
        _block(
            "lease_not_moot",
            "live assessment does not report a moot, cleanable lease on PR "
            f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, "
            f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) "
            "(fail closed)",
        )

    live = assessment.get("active_lease") or {}
    live_session = _norm(live.get("session_id"))
    live_head = _norm(live.get("candidate_head"))
    live_comment_id = _norm_comment_id(live.get("comment_id"))

    # 5. A lease missing identifying fields is malformed and unsafe to act on.
    if not live_session or not live_head or live_comment_id is None:
        _block(
            "malformed_lease",
            "active lease is malformed: session_id / candidate_head / "
            "comment_id must all be present to authorize cleanup "
            f"(session_id={live.get('session_id')!r}, "
            f"candidate_head={live.get('candidate_head')!r}, "
            f"comment_id={live.get('comment_id')!r}) (fail closed)",
        )

    # 6. Caller expectations, when supplied, must match the live lease exactly.
    if expected_session_id is not None and _norm(expected_session_id) != live_session:
        _block(
            "lease_mismatch",
            f"expected lease session {expected_session_id!r} does not match the "
            f"live lease session {live.get('session_id')!r} (fail closed)",
        )
    if (
        expected_candidate_head is not None
        and _norm(expected_candidate_head) != live_head
    ):
        _block(
            "lease_mismatch",
            f"expected candidate head {expected_candidate_head!r} does not "
            f"match the live lease head {live.get('candidate_head')!r} "
            "(fail closed)",
        )
    if expected_lease_comment_id is not None and (
        _norm_comment_id(expected_lease_comment_id) != live_comment_id
    ):
        _block(
            "lease_mismatch",
            f"expected lease marker {expected_lease_comment_id!r} does not "
            f"match the live lease marker {live.get('comment_id')!r} "
            "(fail closed)",
        )

    # 7. Matching dry-run evidence recorded earlier in this session.
    evidence_matched = False
    if evidence is None:
        _block(
            "missing_dry_run_evidence",
            "no read-only dry run recorded for this repository and PR; run the "
            "tool with apply=false and confirm lease_moot / cleanup_allowed "
            "before applying (fail closed)",
        )
    elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"):
        _block(
            "dry_run_not_allowed",
            "recorded dry run did not report an allowed cleanup "
            f"(lease_moot={bool(evidence.get('lease_moot'))}, "
            f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) "
            "(fail closed)",
        )
    elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm(
        evidence.get("repository_slug")
    ) != _norm(repository_slug):
        _block(
            "dry_run_mismatch",
            "recorded dry run targets a different repository or PR "
            f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs "
            f"{repository_slug}#{pr_number}) (fail closed)",
        )
    elif (
        _norm(evidence.get("session_id")) != live_session
        or _norm(evidence.get("candidate_head")) != live_head
        or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id
    ):
        _block(
            "superseded_lease",
            "the lease changed after the recorded dry run (dry run: "
            f"session={evidence.get('session_id')!r}, "
            f"head={evidence.get('candidate_head')!r}, "
            f"marker={evidence.get('lease_comment_id')!r}; live: "
            f"session={live.get('session_id')!r}, "
            f"head={live.get('candidate_head')!r}, "
            f"marker={live.get('comment_id')!r}); re-run the dry run "
            "(fail closed)",
        )
    else:
        evidence_matched = True

    return {
        "allowed": not reasons,
        "reasons": reasons,
        "blocker_kind": blocker_kind,
        "evidence_matched": evidence_matched,
        "required_task": CLEANUP_TASK,
        "required_role_kind": REQUIRED_ROLE,
        "required_permission": REQUIRED_PERMISSION,
    }
