"""Read-only workflow dashboard for live queue / lease / next-safe-action (#605).

Builds a machine-readable + human-readable operational view so humans and LLMs
can see what is safe to work on without reconstructing state from comments.

Design rules:
* Read-only: never assigns work. Assignment still goes through
  ``gitea_allocate_next_work`` (#600).
* Never present blocked / terminal-locked / dependency-unmet items as safe.
* Prefer pure classification so unit tests can inject inventory (including
  terminal-blocked queues from #593/#592/#587-style scenarios).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Iterable, Sequence

from allocator_service import (
    ROLE_AUTHOR,
    ROLE_CONTROLLER,
    ROLE_MERGER,
    ROLE_RECONCILER,
    ROLE_REVIEWER,
    WorkCandidate,
    classify_skip,
    expected_role_for_candidate,
    sort_candidates,
)

DASHBOARD_VERSION = "1.0.0-issue-605"

# Roles the dashboard surfaces next-safe prompts for.
DASHBOARD_ROLES: tuple[str, ...] = (
    ROLE_AUTHOR,
    ROLE_REVIEWER,
    ROLE_MERGER,
    ROLE_RECONCILER,
    ROLE_CONTROLLER,
)

# Exact operator prompts (fill-in tokens only — no self-selection).
PROMPT_AUTHOR = (
    "AUTHOR session: call gitea_allocate_next_work(apply=true, role='author') "
    "for {remote}/{org}/{repo}, then implement only the assigned issue under "
    "branches/ and open/update its PR. Do not self-select outside the allocator."
)
PROMPT_REVIEWER = (
    "REVIEWER session: call gitea_allocate_next_work(apply=true, role='reviewer') "
    "for {remote}/{org}/{repo}, pin the assigned PR head SHA, submit exactly one "
    "formal review verdict for that head. Do not merge."
)
PROMPT_MERGER = (
    "MERGER session: call gitea_allocate_next_work(apply=true, role='merger') "
    "for {remote}/{org}/{repo}, reassess the assigned approved head, and merge "
    "only that exact head via gitea_merge_pr. Do not review."
)
PROMPT_RECONCILER = (
    "RECONCILER session: call gitea_allocate_next_work(apply=true, role='reconciler') "
    "for {remote}/{org}/{repo}, then perform only the assigned terminal "
    "reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
)
PROMPT_CONTROLLER = (
    "CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, "
    "diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule "
    "exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band."
)
PROMPT_IDLE = (
    "IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "
    "Do not self-select. Re-run gitea_workflow_dashboard on the next cycle."
)
PROMPT_TERMINAL_BLOCK = (
    "BLOCKED by terminal-review lock on PR #{terminal_pr} for {remote}/{org}/{repo}. "
    "Resolve the terminal path for that exact PR before any other review/merge work. "
    "Do not treat other open PRs as safe."
)
PROMPT_BLOCKED_ITEM = (
    "NOT SAFE: {kind}#{number} is blocked ({reason}). Never present as next safe work."
)


@dataclass(frozen=True)
class QueueEntry:
    kind: str
    number: int
    title: str
    expected_role: str
    safe_for_roles: tuple[str, ...]
    badges: tuple[str, ...]
    block_reason: str | None = None
    head_sha: str | None = None

    def as_dict(self) -> dict[str, Any]:
        return {
            "kind": self.kind,
            "number": self.number,
            "title": self.title,
            "expected_role": self.expected_role,
            "safe_for_roles": list(self.safe_for_roles),
            "badges": list(self.badges),
            "block_reason": self.block_reason,
            "head_sha": self.head_sha,
            "is_safe": self.block_reason is None and bool(self.safe_for_roles),
        }


@dataclass(frozen=True)
class RoleNextAction:
    role: str
    status: str  # safe | idle | blocked_terminal | none
    target_kind: str | None
    target_number: int | None
    head_sha: str | None
    prompt: str
    reasons: tuple[str, ...] = ()

    def as_dict(self) -> dict[str, Any]:
        return {
            "role": self.role,
            "status": self.status,
            "target_kind": self.target_kind,
            "target_number": self.target_number,
            "head_sha": self.head_sha,
            "prompt": self.prompt,
            "reasons": list(self.reasons),
            "is_safe": self.status == "safe",
        }


@dataclass
class DashboardSnapshot:
    remote: str
    org: str
    repo: str
    inventory_complete: bool
    candidate_count: int
    open_prs: list[QueueEntry] = field(default_factory=list)
    open_issues: list[QueueEntry] = field(default_factory=list)
    review_ready_prs: list[QueueEntry] = field(default_factory=list)
    merge_ready_prs: list[QueueEntry] = field(default_factory=list)
    author_remediation: list[QueueEntry] = field(default_factory=list)
    discussion_issues: list[QueueEntry] = field(default_factory=list)
    blocked_items: list[QueueEntry] = field(default_factory=list)
    controller_needed: list[QueueEntry] = field(default_factory=list)
    active_leases_by_role: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
    stale_or_expired_leases: list[dict[str, Any]] = field(default_factory=list)
    terminal_review_lock: dict[str, Any] | None = None
    next_safe_by_role: dict[str, RoleNextAction] = field(default_factory=dict)
    primary_next_safe_action: RoleNextAction | None = None
    reasons: list[str] = field(default_factory=list)
    dashboard_version: str = DASHBOARD_VERSION

    def as_dict(self) -> dict[str, Any]:
        return {
            "success": self.inventory_complete and not any(
                r.startswith("inventory incomplete") for r in self.reasons
            ),
            "read_only": True,
            "dashboard_version": self.dashboard_version,
            "remote": self.remote,
            "org": self.org,
            "repo": self.repo,
            "inventory_complete": self.inventory_complete,
            "candidate_count": self.candidate_count,
            "open_pr_queue": [e.as_dict() for e in self.open_prs],
            "open_issue_queue": [e.as_dict() for e in self.open_issues],
            "review_ready_prs": [e.as_dict() for e in self.review_ready_prs],
            "merge_ready_prs": [e.as_dict() for e in self.merge_ready_prs],
            "author_remediation": [e.as_dict() for e in self.author_remediation],
            "discussion_issues": [e.as_dict() for e in self.discussion_issues],
            "blocked_items": [e.as_dict() for e in self.blocked_items],
            "controller_needed": [e.as_dict() for e in self.controller_needed],
            "active_leases_by_role": {
                role: list(items) for role, items in self.active_leases_by_role.items()
            },
            "stale_or_expired_leases": list(self.stale_or_expired_leases),
            "terminal_review_lock": self.terminal_review_lock,
            "next_safe_by_role": {
                role: action.as_dict() for role, action in self.next_safe_by_role.items()
            },
            "primary_next_safe_action": (
                self.primary_next_safe_action.as_dict()
                if self.primary_next_safe_action
                else None
            ),
            "reasons": list(self.reasons),
            "human_summary": format_human_summary(self),
        }


def _scope_tokens(remote: str, org: str, repo: str) -> dict[str, str]:
    return {"remote": remote, "org": org, "repo": repo}


def _prompt_for_role(
    role: str,
    *,
    remote: str,
    org: str,
    repo: str,
    terminal_pr: int | None = None,
    idle: bool = False,
) -> str:
    scope = _scope_tokens(remote, org, repo)
    if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
        return PROMPT_TERMINAL_BLOCK.format(terminal_pr=terminal_pr, **scope)
    if idle:
        return PROMPT_IDLE.format(role=role, **scope)
    templates = {
        ROLE_AUTHOR: PROMPT_AUTHOR,
        ROLE_REVIEWER: PROMPT_REVIEWER,
        ROLE_MERGER: PROMPT_MERGER,
        ROLE_RECONCILER: PROMPT_RECONCILER,
        ROLE_CONTROLLER: PROMPT_CONTROLLER,
    }
    return templates.get(role, PROMPT_CONTROLLER).format(**scope)


def _badges_for_candidate(c: WorkCandidate, *, terminal_pr: int | None) -> tuple[str, ...]:
    badges: list[str] = []
    if c.kind == "pr":
        if c.request_changes_current_head:
            badges.append("request-changes")
        if c.approval_on_current_head and c.mergeable:
            badges.append("merge-ready")
        elif c.approval_on_current_head and not c.mergeable:
            badges.append("approved-not-mergeable")
        if c.approval_stale:
            badges.append("approval-stale")
        if c.approval_contaminated:
            badges.append("contaminated")
        if not c.approval_on_current_head and not c.request_changes_current_head:
            badges.append("review-ready")
        if terminal_pr is not None and c.number == terminal_pr:
            badges.append("terminal-lock")
        if terminal_pr is not None and c.number != terminal_pr:
            badges.append("blocked-by-terminal")
    else:
        labels = set(c.labels)
        if "status:ready" in labels:
            badges.append("ready")
        if "status:in-progress" in labels:
            badges.append("in-progress")
        if "status:blocked" in labels or c.blocked:
            badges.append("blocked")
        if "discussion" in labels or "type:discussion" in labels:
            badges.append("discussion")
    if c.dependency_unmet:
        badges.append("dependency-unmet")
    if c.already_claimed_elsewhere:
        badges.append("claimed")
    if c.blocked:
        badges.append("blocked")
    # de-dupe preserve order
    seen: set[str] = set()
    out: list[str] = []
    for b in badges:
        if b not in seen:
            seen.add(b)
            out.append(b)
    return tuple(out)


def _entry_for_candidate(
    c: WorkCandidate,
    *,
    terminal_pr: int | None,
) -> QueueEntry:
    expected = expected_role_for_candidate(c)
    badges = _badges_for_candidate(c, terminal_pr=terminal_pr)
    safe_roles: list[str] = []
    block_reason: str | None = None

    # Global hard blocks (never safe for any worker role).
    if c.blocked or "status:blocked" in c.labels:
        block_reason = "status blocked"
    elif c.dependency_unmet:
        block_reason = c.dependency_reason or "unmet dependency"
    elif c.already_claimed_elsewhere:
        block_reason = "already claimed elsewhere"
    elif c.kind == "pr" and not (c.head_sha or "").strip():
        block_reason = "missing head_sha pin"
    elif (
        terminal_pr is not None
        and c.kind == "pr"
        and c.number != terminal_pr
    ):
        # Other PRs remain visible but are not safe for review/merge while a
        # terminal lock is active (#593/#592/#587-style queue).
        block_reason = f"active terminal-review lock on PR #{terminal_pr}"

    if block_reason is None:
        # Safe only for the expected role, and only when classify_skip agrees.
        skip = classify_skip(c, role=expected, terminal_pr=terminal_pr)
        if skip is None:
            safe_roles.append(expected)
        else:
            block_reason = skip

    return QueueEntry(
        kind=c.kind,
        number=c.number,
        title=c.title or "",
        expected_role=expected,
        safe_for_roles=tuple(safe_roles),
        badges=badges,
        block_reason=block_reason,
        head_sha=c.head_sha,
    )


def _partition_leases(
    leases: Sequence[dict[str, Any]] | None,
) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
    by_role: dict[str, list[dict[str, Any]]] = {r: [] for r in DASHBOARD_ROLES}
    stale: list[dict[str, Any]] = []
    for raw in leases or ():
        if not isinstance(raw, dict):
            continue
        role = str(raw.get("role") or raw.get("owner_role") or "unknown").strip().lower()
        status = str(raw.get("status") or raw.get("lease_status") or "active").strip().lower()
        entry = dict(raw)
        if status in ("expired", "stale", "released", "moot") or raw.get("stale") or raw.get(
            "expired"
        ):
            stale.append(entry)
            continue
        if role in by_role:
            by_role[role].append(entry)
        else:
            by_role.setdefault(role, []).append(entry)
    return by_role, stale


def _first_safe_for_role(
    entries: Iterable[QueueEntry],
    role: str,
) -> QueueEntry | None:
    for entry in entries:
        if role in entry.safe_for_roles and entry.block_reason is None:
            return entry
    return None


def _role_next_action(
    role: str,
    *,
    entries: Sequence[QueueEntry],
    remote: str,
    org: str,
    repo: str,
    terminal_pr: int | None,
) -> RoleNextAction:
    # Terminal lock blocks reviewer/merger from non-terminal work.
    if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER):
        terminal_entry = next(
            (
                e
                for e in entries
                if e.kind == "pr" and e.number == terminal_pr and role in e.safe_for_roles
            ),
            None,
        )
        if terminal_entry is None:
            return RoleNextAction(
                role=role,
                status="blocked_terminal",
                target_kind="pr",
                target_number=terminal_pr,
                head_sha=None,
                prompt=_prompt_for_role(
                    role,
                    remote=remote,
                    org=org,
                    repo=repo,
                    terminal_pr=terminal_pr,
                ),
                reasons=(
                    f"active terminal-review lock on PR #{terminal_pr}; "
                    "no other review/merge target is safe",
                ),
            )
        return RoleNextAction(
            role=role,
            status="safe",
            target_kind="pr",
            target_number=terminal_pr,
            head_sha=terminal_entry.head_sha,
            prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
            reasons=(f"terminal-path PR #{terminal_pr} is the only safe target",),
        )

    if role == ROLE_CONTROLLER:
        needed = [e for e in entries if e.expected_role == ROLE_CONTROLLER or e.block_reason]
        if not needed:
            return RoleNextAction(
                role=role,
                status="idle",
                target_kind=None,
                target_number=None,
                head_sha=None,
                prompt=_prompt_for_role(
                    role, remote=remote, org=org, repo=repo, idle=True
                ),
                reasons=("no controller-needed items",),
            )
        target = needed[0]
        return RoleNextAction(
            role=role,
            status="safe",
            target_kind=target.kind,
            target_number=target.number,
            head_sha=target.head_sha,
            prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
            reasons=(target.block_reason or "controller diagnosis required",),
        )

    hit = _first_safe_for_role(entries, role)
    if hit is None:
        return RoleNextAction(
            role=role,
            status="idle",
            target_kind=None,
            target_number=None,
            head_sha=None,
            prompt=_prompt_for_role(
                role, remote=remote, org=org, repo=repo, idle=True
            ),
            reasons=(f"no safe assignable work for role '{role}'",),
        )
    return RoleNextAction(
        role=role,
        status="safe",
        target_kind=hit.kind,
        target_number=hit.number,
        head_sha=hit.head_sha,
        prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo),
        reasons=(f"highest-ranked safe candidate for role '{role}'",),
    )


def build_workflow_dashboard(
    *,
    candidates: Sequence[WorkCandidate],
    remote: str = "prgs",
    org: str = "Scaled-Tech-Consulting",
    repo: str = "Gitea-Tools",
    leases: Sequence[dict[str, Any]] | None = None,
    terminal_pr: int | None = None,
    terminal_lock: dict[str, Any] | None = None,
    inventory_complete: bool = True,
    inventory_reasons: Sequence[str] | None = None,
) -> DashboardSnapshot:
    """Build a full dashboard snapshot from injected inventory (pure)."""
    reasons = [str(r) for r in (inventory_reasons or ()) if str(r).strip()]
    if not inventory_complete:
        reasons.append(
            "inventory incomplete: refuse to present partial queues as complete "
            "(fail closed, #605/#758)"
        )

    ranked = sort_candidates(list(candidates))
    entries = [
        _entry_for_candidate(c, terminal_pr=terminal_pr) for c in ranked
    ]

    open_prs = [e for e in entries if e.kind == "pr"]
    open_issues = [e for e in entries if e.kind == "issue"]
    review_ready = [
        e
        for e in open_prs
        if "review-ready" in e.badges
        and e.block_reason is None
        and ROLE_REVIEWER in e.safe_for_roles
    ]
    merge_ready = [
        e
        for e in open_prs
        if "merge-ready" in e.badges
        and e.block_reason is None
        and ROLE_MERGER in e.safe_for_roles
    ]
    author_remediation = [
        e
        for e in open_prs
        if "request-changes" in e.badges
        and e.block_reason is None
        and ROLE_AUTHOR in e.safe_for_roles
    ]
    discussion = [
        e
        for e in open_issues
        if "discussion" in e.badges
    ]
    blocked = [e for e in entries if e.block_reason is not None]
    controller_needed = [
        e
        for e in entries
        if e.expected_role == ROLE_CONTROLLER
        or (e.block_reason and "contaminated" in (e.badges or ()))
        or "contaminated" in e.badges
    ]

    leases_by_role, stale_leases = _partition_leases(leases)

    term_payload = None
    if terminal_lock is not None:
        term_payload = dict(terminal_lock)
    elif terminal_pr is not None:
        term_payload = {
            "active": True,
            "terminal_pr": terminal_pr,
            "state": "locked",
        }

    next_by_role: dict[str, RoleNextAction] = {}
    for role in DASHBOARD_ROLES:
        next_by_role[role] = _role_next_action(
            role,
            entries=entries,
            remote=remote,
            org=org,
            repo=repo,
            terminal_pr=terminal_pr,
        )

    # Primary next action prefers in-flight PR work, then author issues.
    primary: RoleNextAction | None = None
    for role in (ROLE_REVIEWER, ROLE_MERGER, ROLE_AUTHOR, ROLE_RECONCILER, ROLE_CONTROLLER):
        action = next_by_role[role]
        if action.status == "safe":
            primary = action
            break
    if primary is None:
        # Prefer an explicit terminal block signal over generic idle.
        for role in (ROLE_REVIEWER, ROLE_MERGER):
            if next_by_role[role].status == "blocked_terminal":
                primary = next_by_role[role]
                break
    if primary is None:
        primary = next_by_role[ROLE_AUTHOR]

    # Incomplete inventory: strip all safe flags / never suggest work.
    if not inventory_complete:
        for role, action in list(next_by_role.items()):
            next_by_role[role] = RoleNextAction(
                role=role,
                status="none",
                target_kind=None,
                target_number=None,
                head_sha=None,
                prompt=(
                    f"BLOCKED: inventory incomplete for {remote}/{org}/{repo}; "
                    "do not select work. Re-run after a complete listing."
                ),
                reasons=tuple(reasons) or ("inventory incomplete",),
            )
        primary = next_by_role[ROLE_CONTROLLER]
        review_ready = []
        merge_ready = []
        author_remediation = []

    return DashboardSnapshot(
        remote=remote,
        org=org,
        repo=repo,
        inventory_complete=inventory_complete,
        candidate_count=len(ranked),
        open_prs=open_prs,
        open_issues=open_issues,
        review_ready_prs=review_ready,
        merge_ready_prs=merge_ready,
        author_remediation=author_remediation,
        discussion_issues=discussion,
        blocked_items=blocked,
        controller_needed=controller_needed,
        active_leases_by_role=leases_by_role,
        stale_or_expired_leases=stale_leases,
        terminal_review_lock=term_payload,
        next_safe_by_role=next_by_role,
        primary_next_safe_action=primary,
        reasons=reasons,
    )


def format_human_summary(snapshot: DashboardSnapshot) -> str:
    """Compact human-readable multi-line summary for menus and operators."""
    lines: list[str] = []
    lines.append(
        f"Workflow dashboard v{snapshot.dashboard_version} — "
        f"{snapshot.remote}/{snapshot.org}/{snapshot.repo}"
    )
    lines.append(
        f"Inventory: complete={snapshot.inventory_complete} "
        f"candidates={snapshot.candidate_count}"
    )
    if snapshot.terminal_review_lock:
        tpr = snapshot.terminal_review_lock.get("terminal_pr")
        lines.append(f"Terminal review lock: ACTIVE on PR #{tpr}")
    else:
        lines.append("Terminal review lock: none")

    lines.append(
        f"Open PRs: {len(snapshot.open_prs)} | Open issues: {len(snapshot.open_issues)}"
    )
    lines.append(
        f"Review-ready: {len(snapshot.review_ready_prs)} | "
        f"Merge-ready: {len(snapshot.merge_ready_prs)} | "
        f"Author remediation: {len(snapshot.author_remediation)}"
    )
    lines.append(
        f"Blocked: {len(snapshot.blocked_items)} | "
        f"Controller-needed: {len(snapshot.controller_needed)} | "
        f"Discussion: {len(snapshot.discussion_issues)}"
    )

    active_counts = {
        role: len(items)
        for role, items in snapshot.active_leases_by_role.items()
        if items
    }
    if active_counts:
        parts = [f"{role}={n}" for role, n in sorted(active_counts.items())]
        lines.append("Active leases by role: " + ", ".join(parts))
    else:
        lines.append("Active leases by role: none")
    lines.append(
        f"Stale/expired leases: {len(snapshot.stale_or_expired_leases)}"
    )

    # Never list blocked items as safe.
    if snapshot.blocked_items:
        lines.append("Blocked (NOT safe):")
        for entry in snapshot.blocked_items[:12]:
            lines.append(
                f"  - {entry.kind}#{entry.number}: {entry.block_reason}"
            )
            lines.append(
                "    "
                + PROMPT_BLOCKED_ITEM.format(
                    kind=entry.kind,
                    number=entry.number,
                    reason=entry.block_reason or "blocked",
                )
            )

    lines.append("Next safe action by role:")
    for role in DASHBOARD_ROLES:
        action = snapshot.next_safe_by_role.get(role)
        if action is None:
            continue
        target = (
            f"{action.target_kind}#{action.target_number}"
            if action.target_number is not None
            else "none"
        )
        lines.append(
            f"  - {role}: status={action.status} target={target} "
            f"safe={action.status == 'safe'}"
        )
        lines.append(f"    prompt: {action.prompt}")

    if snapshot.primary_next_safe_action:
        p = snapshot.primary_next_safe_action
        lines.append(
            f"Primary next: role={p.role} status={p.status} "
            f"target={p.target_kind}#{p.target_number if p.target_number else 'none'}"
        )
        lines.append(f"  prompt: {p.prompt}")

    if snapshot.reasons:
        lines.append("Notes:")
        for r in snapshot.reasons:
            lines.append(f"  - {r}")

    lines.append(
        "Assignment still requires gitea_allocate_next_work; "
        "this dashboard never self-selects exclusive work."
    )
    return "\n".join(lines)
