"""Controller-owned work allocator policy (#600).

Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``).

Workers must not self-select exclusive work under the standard multi-LLM
workflow. They call ``gitea_allocate_next_work`` which:

1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents).
2. Applies ADR routing policy (role, terminal path, leases, blocked, deps).
3. Atomically assigns + leases the selected item in one DB transaction.

This module is pure selection + substrate orchestration. Gitea I/O for live
inventory lives in the MCP tool wrapper so tests can inject candidates.

#612 remains downstream: bridge-created Gitea issues become candidates only
after they exist as normal issues; this module never assigns incidents.
"""

from __future__ import annotations

import os
import uuid
from dataclasses import dataclass, field
from typing import Any, Sequence

from control_plane_db import (
    ControlPlaneDB,
    ControlPlaneError,
    InvalidWorkKindError,
    LeaseRequiredError,
    WORK_KINDS,
)

# Outcomes required by #600 / ADR §5.
OUTCOME_ASSIGNED = "assigned_work"
OUTCOME_WAIT = "wait"
OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path"
OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease"
OUTCOME_NEEDS_CONTROLLER = "needs_controller"
OUTCOME_NO_SAFE = "no_safe_work"
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
OUTCOME_PREVIEW = "preview"  # dry-run only (apply=false)

# Human-readable statement of how a winner is chosen (#758 AC10). Reported
# alongside allocator results so the flat status:ready tier and its
# oldest-number tie-break are explicit rather than incidental.
SELECTION_POLICY = (
    "rank complete inventory by (priority desc, PRs before issues, "
    "number asc); status:ready issues share priority 20, so the oldest "
    "eligible number wins ties; result limits never affect selection"
)

ROLE_AUTHOR = "author"
ROLE_REVIEWER = "reviewer"
ROLE_MERGER = "merger"
ROLE_RECONCILER = "reconciler"
ROLE_CONTROLLER = "controller"

VALID_ROLES = frozenset(
    {ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
)

# Default action matrices by role (mutation gate will re-check).
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
    ROLE_AUTHOR: (
        ("implement", "comment", "push", "create_pr"),
        ("approve", "merge", "request_changes", "self_select_without_assignment"),
    ),
    ROLE_REVIEWER: (
        ("review", "comment", "approve", "request_changes"),
        ("merge", "push", "create_pr", "self_select_without_assignment"),
    ),
    ROLE_MERGER: (
        ("merge", "comment"),
        ("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"),
    ),
    ROLE_RECONCILER: (
        ("comment", "diagnose", "cleanup"),
        ("approve", "merge", "push", "create_pr", "self_select_without_assignment"),
    ),
    ROLE_CONTROLLER: (
        ("comment", "diagnose", "allocate"),
        ("approve", "merge", "push", "create_pr"),
    ),
}


@dataclass
class WorkCandidate:
    """One assignable Gitea issue or PR presented to the allocator."""

    kind: str  # issue | pr
    number: int
    state: str = "open"
    labels: tuple[str, ...] = ()
    title: str = ""
    priority: int = 0
    head_sha: str | None = None
    # Routing signals (callers derive from Gitea / review feedback).
    request_changes_current_head: bool = False
    approval_on_current_head: bool = False
    approval_stale: bool = False
    approval_contaminated: bool = False
    mergeable: bool = False
    blocked: bool = False
    dependency_unmet: bool = False
    dependency_reason: str | None = None
    already_claimed_elsewhere: bool = False

    def __post_init__(self) -> None:
        self.kind = (self.kind or "").strip().lower()
        self.state = (self.state or "open").strip().lower()
        self.labels = tuple(
            str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
        )
        if self.kind not in WORK_KINDS:
            raise InvalidWorkKindError(
                f"candidate kind '{self.kind}' is not assignable; only "
                f"{sorted(WORK_KINDS)} (never raw incidents)"
            )

    def as_dict(self) -> dict[str, Any]:
        return {
            "kind": self.kind,
            "number": self.number,
            "state": self.state,
            "labels": list(self.labels),
            "title": self.title,
            "priority": self.priority,
            "head_sha": self.head_sha,
            "request_changes_current_head": self.request_changes_current_head,
            "approval_on_current_head": self.approval_on_current_head,
            "approval_stale": self.approval_stale,
            "approval_contaminated": self.approval_contaminated,
            "mergeable": self.mergeable,
            "blocked": self.blocked,
            "dependency_unmet": self.dependency_unmet,
            "dependency_reason": self.dependency_reason,
        }


@dataclass
class SkipRecord:
    kind: str
    number: int
    reason: str

    def as_dict(self) -> dict[str, Any]:
        return {"kind": self.kind, "number": self.number, "reason": self.reason}


def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
    """Map profile/role strings to a canonical allocator role."""
    raw = (role or "").strip().lower()
    if raw in VALID_ROLES:
        return raw
    prof = (profile_name or "").strip().lower()
    for token in VALID_ROLES:
        if token in prof or prof.endswith(f"-{token}"):
            return token
    if "author" in raw:
        return ROLE_AUTHOR
    if "review" in raw:
        return ROLE_REVIEWER
    if "merg" in raw:
        return ROLE_MERGER
    if "reconcil" in raw:
        return ROLE_RECONCILER
    if "control" in raw:
        return ROLE_CONTROLLER
    raise ControlPlaneError(
        f"unknown allocator role '{role}' (profile={profile_name!r}); "
        f"expected one of {sorted(VALID_ROLES)}"
    )


def expected_role_for_candidate(c: WorkCandidate) -> str:
    """ADR §5.3 routing: which role should take this work next."""
    if c.kind == "pr":
        if c.approval_contaminated:
            return ROLE_RECONCILER
        if c.request_changes_current_head:
            return ROLE_AUTHOR
        if c.approval_stale:
            return ROLE_REVIEWER
        if c.approval_on_current_head and c.mergeable:
            return ROLE_MERGER
        # Open PR without terminal verdict → reviewer
        return ROLE_REVIEWER
    # Issues: ready work → author by default; blocked stays controller/none
    labels = set(c.labels)
    if "status:blocked" in labels or c.blocked:
        return ROLE_CONTROLLER
    return ROLE_AUTHOR


def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
    return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR])


def classify_skip(
    c: WorkCandidate,
    *,
    role: str,
    terminal_pr: int | None,
) -> str | None:
    """Return skip reason, or None if candidate is selectable for *role*."""
    if c.state in ("merged", "closed"):
        return f"{c.kind}#{c.number} is {c.state}; never assign"
    if c.blocked or "status:blocked" in c.labels:
        return f"{c.kind}#{c.number} is blocked"
    if c.dependency_unmet:
        return (
            c.dependency_reason
            or f"{c.kind}#{c.number} has unmet dependencies"
        )
    if c.already_claimed_elsewhere:
        return f"{c.kind}#{c.number} already claimed elsewhere"
    if c.kind == "pr" and not (c.head_sha or "").strip():
        return f"pr#{c.number} missing head_sha pin"

    # Terminal path first: when an active terminal PR exists, only that PR
    # (or controller diagnosis) is assignable for review-path roles.
    if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
        if role in (ROLE_REVIEWER, ROLE_MERGER):
            return (
                f"pr#{c.number} skipped: active terminal-review lock on "
                f"PR #{terminal_pr} must be resolved first"
            )

    expected = expected_role_for_candidate(c)
    if role == ROLE_CONTROLLER:
        # Controller may inspect anything but only assigns diagnosis targets
        # when contaminated / blocked.
        if expected == ROLE_RECONCILER or c.blocked:
            return None
        return f"{c.kind}#{c.number} does not require controller (expected {expected})"

    if role != expected:
        return (
            f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
        )

    # Ready-gate for issues: prefer status:ready when labels present.
    if c.kind == "issue" and c.labels:
        if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
            # Allow unlabeled open issues; only skip explicit non-ready states.
            if any(l.startswith("status:") for l in c.labels):
                return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
    return None


def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
    """Rank candidates deterministically (#758 AC10).

    Ordering key, in precedence order:

    1. ``priority`` descending — the loader scores ``status:ready`` issues at
       20 and everything else at 1, so the ready queue ties at a single value
       by design;
    2. PRs before issues — in-flight review work drains before new authoring;
    3. ``number`` ascending — oldest first, which is what actually breaks the
       flat ``status:ready`` tie.

    Because the ready tier is intentionally flat, rule 3 decides most real
    selections. That is only safe when ranking sees the *complete* candidate
    inventory: truncating before this call silently redefines "oldest" as
    "oldest among whatever survived the slice", which is the defect #758
    fixed. Callers must rank everything and bound reporting afterwards.
    """
    return sorted(
        candidates,
        key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
    )


def allocate_next_work(
    db: ControlPlaneDB,
    *,
    session_id: str,
    role: str,
    remote: str,
    org: str,
    repo: str,
    candidates: Sequence[WorkCandidate],
    apply: bool = False,
    profile_name: str | None = None,
    username: str | None = None,
    lease_ttl_seconds: int | None = None,
) -> dict[str, Any]:
    """Select and optionally reserve the next work unit via control-plane DB.

    *apply=False* (default): dry-run selection only — no lease/assignment.
    *apply=True*: atomic ``assign_and_lease`` for the selected candidate.

    Never uses file locks or comment-only leases as the assignment source.
    """
    if db is None:
        return {
            "success": False,
            "outcome": OUTCOME_NO_SAFE,
            "reasons": [
                "control-plane DB substrate unavailable (fail closed, #600/#613)"
            ],
            "skipped": [],
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    try:
        role_norm = normalize_role(role, profile_name=profile_name)
    except ControlPlaneError as exc:
        return {
            "success": False,
            "outcome": OUTCOME_ROLE_INELIGIBLE,
            "reasons": [str(exc)],
            "skipped": [],
            "assignment": None,
            "substrate": "control_plane_db",
        }

    session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
    try:
        db.upsert_session(
            session_id=session_id,
            role=role_norm,
            profile=profile_name,
            pid=os.getpid(),
        )
    except Exception as exc:  # noqa: BLE001 — surface structured
        return {
            "success": False,
            "outcome": OUTCOME_NO_SAFE,
            "reasons": [
                f"failed to register session in control-plane DB: {exc} "
                "(fail closed, #613)"
            ],
            "skipped": [],
            "assignment": None,
            "substrate": "control_plane_db",
        }

    # Expire stale leases globally before selection.
    try:
        db.expire_stale_leases()
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "outcome": OUTCOME_NO_SAFE,
            "reasons": [f"lease expiry failed: {exc} (fail closed)"],
            "skipped": [],
            "assignment": None,
            "substrate": "control_plane_db",
        }

    terminal = None
    try:
        terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo)
    except Exception as exc:  # noqa: BLE001
        return {
            "success": False,
            "outcome": OUTCOME_NO_SAFE,
            "reasons": [f"terminal lock lookup failed: {exc} (fail closed)"],
            "skipped": [],
            "assignment": None,
            "substrate": "control_plane_db",
        }
    terminal_pr = int(terminal["terminal_pr"]) if terminal else None

    skipped: list[SkipRecord] = []
    ordered = sort_candidates(list(candidates))
    selected: WorkCandidate | None = None
    for c in ordered:
        reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
        if reason:
            skipped.append(SkipRecord(c.kind, c.number, reason))
            continue
        selected = c
        break

    if selected is None:
        # If terminal lock blocks all review work, surface that explicitly.
        if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
            outcome = OUTCOME_BLOCKED_TERMINAL
            reasons = [
                f"no safe work for role '{role_norm}': active terminal-review "
                f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
            ]
        else:
            outcome = OUTCOME_NO_SAFE
            reasons = [
                f"no safe assignable work for role '{role_norm}' "
                f"among {len(ordered)} candidates"
            ]
        return {
            "success": True,
            "outcome": outcome,
            "apply": bool(apply),
            "role": role_norm,
            "profile_name": profile_name,
            "username": username,
            "session_id": session_id,
            "remote": remote,
            "org": org,
            "repo": repo,
            "selected": None,
            "expected_role_next": None,
            "reasons": reasons,
            "skipped": [s.as_dict() for s in skipped],
            "terminal_pr": terminal_pr,
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
            "downstream_note": (
                "#612 incident bridge remains downstream of #600; "
                "allocator never assigns raw monitoring incidents"
            ),
        }

    expected_role = expected_role_for_candidate(selected)
    allowed, forbidden = role_actions(role_norm)
    selection = {
        "kind": selected.kind,
        "number": selected.number,
        "title": selected.title,
        "labels": list(selected.labels),
        "head_sha": selected.head_sha,
        "priority": selected.priority,
        "expected_role_next": expected_role,
        "reason_selected": (
            f"highest-priority candidate for role '{role_norm}' "
            f"(expected_role={expected_role})"
        ),
    }

    if not apply:
        return {
            "success": True,
            "outcome": OUTCOME_PREVIEW,
            "apply": False,
            "role": role_norm,
            "profile_name": profile_name,
            "username": username,
            "session_id": session_id,
            "remote": remote,
            "org": org,
            "repo": repo,
            "selected": selection,
            "expected_role_next": expected_role,
            "reasons": [
                "dry-run only (apply=false); no assignment/lease created — "
                "call again with apply=true to reserve via control-plane DB"
            ],
            "skipped": [s.as_dict() for s in skipped],
            "terminal_pr": terminal_pr,
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
            "downstream_note": (
                "#612 incident bridge remains downstream of #600; "
                "allocator never assigns raw monitoring incidents"
            ),
        }

    # Atomic reserve via #613 substrate.
    ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
    try:
        kwargs: dict[str, Any] = {
            "session_id": session_id,
            "role": role_norm,
            "remote": remote,
            "org": org,
            "repo": repo,
            "kind": selected.kind,
            "number": selected.number,
            "expected_head_sha": selected.head_sha,
            "allowed_actions": allowed,
            "forbidden_actions": forbidden,
            "phase": "allocated",
        }
        if ttl is not None:
            kwargs["lease_ttl_seconds"] = int(ttl)
        result = db.assign_and_lease(**kwargs)
    except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc:
        return {
            "success": False,
            "outcome": OUTCOME_NO_SAFE,
            "apply": True,
            "role": role_norm,
            "session_id": session_id,
            "remote": remote,
            "org": org,
            "repo": repo,
            "selected": selection,
            "expected_role_next": expected_role,
            "reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"],
            "skipped": [s.as_dict() for s in skipped],
            "terminal_pr": terminal_pr,
            "assignment": None,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    if result.outcome == "wait":
        return {
            "success": True,
            "outcome": OUTCOME_WAIT,
            "apply": True,
            "role": role_norm,
            "session_id": session_id,
            "remote": remote,
            "org": org,
            "repo": repo,
            "selected": selection,
            "expected_role_next": expected_role,
            "reasons": [result.reason or "foreign active lease"],
            "skipped": [s.as_dict() for s in skipped],
            "terminal_pr": terminal_pr,
            "assignment": result.as_dict(),
            "owner_session_id": result.owner_session_id,
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    if result.outcome == "no_safe_work":
        return {
            "success": True,
            "outcome": OUTCOME_NO_SAFE,
            "apply": True,
            "role": role_norm,
            "session_id": session_id,
            "remote": remote,
            "org": org,
            "repo": repo,
            "selected": selection,
            "expected_role_next": expected_role,
            "reasons": [result.reason or "no_safe_work"],
            "skipped": [s.as_dict() for s in skipped],
            "terminal_pr": terminal_pr,
            "assignment": result.as_dict(),
            "substrate": "control_plane_db",
            "file_lock_only": False,
            "comment_lease_only": False,
        }

    # assigned
    return {
        "success": True,
        "outcome": OUTCOME_ASSIGNED,
        "apply": True,
        "role": role_norm,
        "profile_name": profile_name,
        "username": username,
        "session_id": session_id,
        "remote": remote,
        "org": org,
        "repo": repo,
        "selected": selection,
        "expected_role_next": expected_role,
        "reasons": [
            selection["reason_selected"],
            result.reason or "atomic assign+lease created",
        ],
        "skipped": [s.as_dict() for s in skipped],
        "terminal_pr": terminal_pr,
        "assignment": result.as_dict(),
        "lease_proof": {
            "assignment_id": result.assignment_id,
            "lease_id": result.lease_id,
            "expires_at": result.expires_at,
            "expected_head_sha": result.expected_head_sha,
            "allowed_actions": list(result.allowed_actions),
            "forbidden_actions": list(result.forbidden_actions),
            "source": "control_plane_db.assign_and_lease",
        },
        "next_valid_command": _next_command(role_norm, selected),
        "substrate": "control_plane_db",
        "file_lock_only": False,
        "comment_lease_only": False,
        "downstream_note": (
            "#612 incident bridge remains downstream of #600; "
            "allocator never assigns raw monitoring incidents"
        ),
    }


def _next_command(role: str, c: WorkCandidate) -> str:
    if role == ROLE_AUTHOR and c.kind == "issue":
        return f"implement issue #{c.number} under a branches/ worktree; open PR when ready"
    if role == ROLE_AUTHOR and c.kind == "pr":
        return (
            f"address REQUEST_CHANGES on PR #{c.number} at head "
            f"{(c.head_sha or '')[:12]} and push fixes"
        )
    if role == ROLE_REVIEWER:
        return (
            f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} "
            "via full reviewer workflow"
        )
    if role == ROLE_MERGER:
        return (
            f"merge PR #{c.number} only with explicit operator MERGE "
            f"authorization at head {(c.head_sha or '')[:12]}"
        )
    if role == ROLE_RECONCILER:
        return f"diagnose contested state for {c.kind}#{c.number}"
    return f"proceed on {c.kind}#{c.number} under role {role}"


def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
    """Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
    return WorkCandidate(
        kind=str(data.get("kind") or "issue"),
        number=int(data["number"]),
        state=str(data.get("state") or "open"),
        labels=tuple(data.get("labels") or ()),
        title=str(data.get("title") or ""),
        priority=int(data.get("priority") or 0),
        head_sha=data.get("head_sha"),
        request_changes_current_head=bool(data.get("request_changes_current_head")),
        approval_on_current_head=bool(data.get("approval_on_current_head")),
        approval_stale=bool(data.get("approval_stale")),
        approval_contaminated=bool(data.get("approval_contaminated")),
        mergeable=bool(data.get("mergeable")),
        blocked=bool(data.get("blocked")),
        dependency_unmet=bool(data.get("dependency_unmet")),
        dependency_reason=data.get("dependency_reason"),
        already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")),
    )
