"""Findings storage for the reviewer agent.

Findings live in LangGraph thread metadata under the canonical reviewer thread
for a PR. This file owns the Finding schema and the read/write helpers that
the reviewer's tools and webhook handlers go through.

Why thread metadata: it survives sandbox eviction, is queryable cross-thread
via the langgraph SDK (a future UI lists all reviewer threads by filtering on
``metadata.kind == "reviewer"``), and matches existing patterns the codebase
already uses for durable non-secret run state like ``sandbox_id``.
"""

from __future__ import annotations

import logging
import uuid
from collections.abc import Callable
from typing import Any, Literal, TypedDict, cast

from langgraph.config import get_config
from langgraph_sdk import get_client
from langgraph_sdk.errors import NotFoundError as LangGraphSDKNotFoundError

logger = logging.getLogger(__name__)


class ReviewerThreadMissingError(RuntimeError):
    """The reviewer thread backing findings storage does not exist.

    Raised instead of the SDK's ``NotFoundError`` so tool wrappers can return a
    structured do-not-retry result: the thread won't appear on retry (evicted,
    eval-mode, or never created), and blind retries burn the whole run.
    """

    def __init__(self, thread_id: str, original: Exception) -> None:
        super().__init__(f"Reviewer thread {thread_id!r} not found: {original}")
        self.thread_id = thread_id


REVIEWER_THREAD_KIND = "reviewer"

# Suggestions are only useful when the reader can scan them at a glance and
# accept with one click. Anything longer reads as the reviewer rewriting the
# code for the author and clutters the comment. We cap at 4 lines and drop
# longer suggestions; the description still gets posted on its own.
MAX_SUGGESTION_LINES = 4
MAX_FINDING_TITLE_LENGTH = 120
DEFAULT_FINDING_TITLE = "Code review finding"


def clip_suggestion(suggestion: str | None) -> tuple[str | None, bool]:
    """Return (suggestion_or_none, was_dropped). Drops if over the line cap."""
    if not suggestion:
        return suggestion, False
    if suggestion.count("\n") + 1 > MAX_SUGGESTION_LINES:
        return None, True
    return suggestion, False


def normalize_finding_title(title: str | None, description: str = "") -> str:
    """Return a compact finding title suitable for a review comment headline."""
    raw = title.strip() if isinstance(title, str) else ""
    if not raw and description:
        raw = description.strip().split("\n", 1)[0].strip()
    compact = " ".join(raw.split())
    if not compact:
        return DEFAULT_FINDING_TITLE
    if len(compact) > MAX_FINDING_TITLE_LENGTH:
        return f"{compact[: MAX_FINDING_TITLE_LENGTH - 3].rstrip()}..."
    return compact


Severity = Literal["low", "medium", "high", "critical"]
Confidence = Literal["low", "medium", "high"]
FindingStatus = Literal["open", "resolved", "dismissed"]
DiffSide = Literal["LEFT", "RIGHT"]
SurfaceState = Literal["not_surfaced", "surfaced", "resolve_pending", "resolved", "error"]
InteractionKind = Literal["human_reply", "bot_reply"]

SEVERITY_ORDER: dict[Severity, int] = {
    "low": 0,
    "medium": 1,
    "high": 2,
    "critical": 3,
}

# Confidence is recorded on every finding for post-hoc calibration analysis
# but does not gate publication — the system prompt's defensibility bar is
# the discipline.


class Finding(TypedDict, total=False):
    """A single review finding.

    All fields are optional at the TypedDict level so partial updates and
    legacy findings without generated titles are representable.
    """

    id: str
    severity: Severity
    confidence: Confidence
    category: str
    title: str
    file: str
    start_line: int | None
    end_line: int | None
    side: DiffSide
    in_diff: bool
    description: str
    suggestion: str | None
    status: FindingStatus
    first_seen_sha: str
    last_confirmed_sha: str
    github_review_id: int | None
    github_review_comment_id: int | None
    github_review_comment_ids: list[int]
    github_review_thread_id: str | None
    github_review_thread_ids: list[str]
    github_review_run_id: str | None
    github_thread_resolved: bool
    github_resolved_thread_ids: list[str]
    github_posted_resolution_comment_ids: list[int]
    last_human_reply_at: str | None
    last_human_reply_author: str | None
    last_human_reply_body: str | None
    last_reconciliation_note: str | None
    resolution_note: str | None
    diff_hunk: str | None
    fingerprint: str
    anchor: FindingAnchor
    surface: FindingSurface
    interactions: list[FindingInteraction]


class FindingAnchor(TypedDict):
    file: str
    start_line: int | None
    end_line: int | None
    side: DiffSide


class FindingSurface(TypedDict, total=False):
    finding_id: str
    state: SurfaceState
    github_review_id: int | None
    github_review_comment_id: int | None
    github_review_thread_id: str | None
    severity_threshold_at_publish: Severity | None
    surfaced_at_sha: str | None
    last_github_sync_at: str | None
    last_error: str | None


class FindingInteraction(TypedDict, total=False):
    kind: InteractionKind
    github_comment_id: int | None
    github_parent_comment_id: int | None
    author: str
    body: str
    created_at: str
    needs_reassessment: bool


class ReviewerPRMeta(TypedDict, total=False):
    """PR identity stored on reviewer thread metadata, used by the UI."""

    owner: str
    name: str
    number: int
    url: str
    title: str
    head_ref: str
    base_ref: str
    author: str


class ReviewerSlackThread(TypedDict, total=False):
    """Slack thread that initiated this review — used to post a completion reply."""

    channel_id: str
    thread_ts: str


def new_finding_id() -> str:
    """Return a stable, short, URL-friendly finding id (``f_<hex>``)."""
    return f"f_{uuid.uuid4().hex[:10]}"


def new_finding(
    *,
    severity: Severity,
    category: str,
    file: str,
    start_line: int | None,
    end_line: int | None,
    description: str,
    sha: str,
    title: str | None = None,
    confidence: Confidence = "medium",
    side: DiffSide = "RIGHT",
    suggestion: str | None = None,
    diff_hunk: str | None = None,
    finding_id: str | None = None,
    in_diff: bool = True,
) -> Finding:
    """Construct a fully-populated ``Finding`` ready to persist."""
    resolved_id = finding_id or new_finding_id()
    anchor: FindingAnchor = {
        "file": file,
        "start_line": start_line,
        "end_line": end_line,
        "side": side,
    }
    surface: FindingSurface = {
        "finding_id": resolved_id,
        "state": "not_surfaced",
        "github_review_id": None,
        "github_review_comment_id": None,
        "github_review_thread_id": None,
        "severity_threshold_at_publish": None,
        "surfaced_at_sha": None,
        "last_github_sync_at": None,
        "last_error": None,
    }
    finding: Finding = {
        "id": resolved_id,
        "severity": severity,
        "confidence": confidence,
        "category": category,
        "file": file,
        "start_line": start_line,
        "end_line": end_line,
        "side": side,
        "in_diff": in_diff,
        "description": description,
        "suggestion": suggestion,
        "status": "open",
        "first_seen_sha": sha,
        "last_confirmed_sha": sha,
        "github_review_id": None,
        "github_review_comment_id": None,
        "github_review_comment_ids": [],
        "github_review_thread_id": None,
        "github_review_thread_ids": [],
        "github_review_run_id": None,
        "github_thread_resolved": False,
        "github_resolved_thread_ids": [],
        "github_posted_resolution_comment_ids": [],
        "last_human_reply_at": None,
        "last_human_reply_author": None,
        "last_human_reply_body": None,
        "last_reconciliation_note": None,
        "resolution_note": None,
        "diff_hunk": diff_hunk,
        "fingerprint": _finding_fingerprint(file, start_line, end_line, description),
        "anchor": anchor,
        "surface": surface,
        "interactions": [],
    }
    if title is not None:
        finding["title"] = normalize_finding_title(title)
    return finding


def _finding_fingerprint(
    file: str,
    start_line: int | None,
    end_line: int | None,
    description: str,
) -> str:
    normalized_description = " ".join(description.strip().lower().split())
    return f"{file}:{start_line or ''}:{end_line or ''}:{normalized_description[:160]}"


def _coerce_finding(value: Any) -> Finding | None:
    if not isinstance(value, dict):
        return None
    if "id" not in value or not isinstance(value["id"], str):
        return None
    return cast(Finding, value)


def _coerce_findings_list(value: Any) -> list[Finding]:
    if not isinstance(value, list):
        return []
    out: list[Finding] = []
    for entry in value:
        finding = _coerce_finding(entry)
        if finding is not None:
            out.append(finding)
    return out


def get_thread_id_from_runtime() -> str:
    """Return the thread id from the current LangGraph runnable config."""
    config = get_config()
    configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
    thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
    if not isinstance(thread_id, str) or not thread_id:
        msg = "No thread_id available in runtime config"
        raise RuntimeError(msg)
    return thread_id


async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
    """Fetch the current metadata for a thread.

    Raises :class:`ReviewerThreadMissingError` when the thread does not exist
    (swallowing it as ``{}`` made tools report misleading results like "No
    finding found" instead of the do-not-retry contract). Other transient
    failures still degrade to ``{}``.
    """
    client = get_client()
    try:
        thread = await client.threads.get(thread_id)
    except LangGraphSDKNotFoundError as exc:
        raise ReviewerThreadMissingError(thread_id, exc) from exc
    except Exception:  # noqa: BLE001
        logger.exception("Failed to fetch thread metadata for %s", thread_id)
        return {}
    metadata = thread.get("metadata") if isinstance(thread, dict) else None
    return metadata if isinstance(metadata, dict) else {}


async def resolve_review_head_sha(thread_id: str, configurable: dict[str, Any]) -> str:
    """Return the current PR head SHA for a reviewer run.

    A push that lands while a reviewer run is in flight is delivered as a queued
    message into that run, whose frozen ``configurable`` still names the head the
    run was created for. The dispatching webhook records the current head in
    thread metadata, so prefer that; fall back to the run's config when metadata
    carries no head (first review, eval, tests).
    """
    config_head = configurable.get("head_sha") if isinstance(configurable, dict) else None
    config_head = config_head if isinstance(config_head, str) else ""
    if not thread_id:
        return config_head
    metadata = await get_thread_metadata(thread_id)
    meta_head = metadata.get("head_sha")
    return meta_head if isinstance(meta_head, str) and meta_head else config_head


async def list_findings(thread_id: str) -> list[Finding]:
    """Return all findings persisted on the reviewer thread."""
    metadata = await get_thread_metadata(thread_id)
    return _coerce_findings_list(metadata.get("findings"))


async def get_finding(thread_id: str, finding_id: str) -> Finding | None:
    """Return one finding by id, or ``None`` if not present."""
    findings = await list_findings(thread_id)
    for finding in findings:
        if finding.get("id") == finding_id:
            return finding
    return None


async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
    """Overwrite the findings list on a thread's metadata.

    Prefer :func:`mutate_findings` for read-modify-write updates: it re-reads the
    freshest persisted list immediately before mutating, which shrinks the
    lost-update window a blind overwrite here leaves open.
    """
    client = get_client()
    try:
        await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
    except LangGraphSDKNotFoundError as exc:
        raise ReviewerThreadMissingError(thread_id, exc) from exc


def thread_missing_tool_result(exc: ReviewerThreadMissingError) -> dict[str, Any]:
    """Structured tool result for a missing reviewer thread.

    Returned (not raised) so the agent sees an explicit do-not-retry contract
    instead of an empty error blob it retries against.
    """
    return {
        "success": False,
        "error": "thread_not_found",
        "thread_id": exc.thread_id,
        "note": (
            "Reviewer findings storage is unavailable. Do not retry; report the "
            "blocker and include intended findings inline in the final message."
        ),
        "detail": str(exc),
    }


async def mutate_findings(
    thread_id: str,
    mutator: Callable[[list[Finding]], bool],
) -> list[Finding]:
    """Read the latest findings, apply ``mutator`` in place, persist iff changed.

    Centralizes the read-modify-write so every mutation operates on the freshest
    persisted list rather than a stale in-memory snapshot. ``mutator`` edits the
    list in place and returns ``True`` when it changed something; we only write
    on change, so a no-op mutation never clobbers a concurrent update.
    """
    findings = await list_findings(thread_id)
    if mutator(findings):
        await replace_findings(thread_id, findings)
    return findings


async def append_finding(thread_id: str, finding: Finding) -> Finding:
    """Append a finding and persist the new list."""

    def _append(findings: list[Finding]) -> bool:
        findings.append(finding)
        return True

    await mutate_findings(thread_id, _append)
    return finding


async def update_finding_fields(
    thread_id: str,
    finding_id: str,
    updates: dict[str, Any],
) -> Finding | None:
    """Apply field updates to one finding by id and persist."""
    captured: dict[str, Finding] = {}

    def _apply(findings: list[Finding]) -> bool:
        for finding in findings:
            if finding.get("id") == finding_id:
                finding.update(updates)
                captured["finding"] = finding
                return True
        return False

    await mutate_findings(thread_id, _apply)
    return captured.get("finding")


async def update_finding_surface(
    thread_id: str,
    finding_id: str,
    updates: dict[str, Any],
) -> Finding | None:
    """Apply updates to the nested surface record and legacy GitHub fields."""
    captured: dict[str, Finding] = {}

    def _apply(findings: list[Finding]) -> bool:
        for finding in findings:
            if finding.get("id") != finding_id:
                continue
            surface = _coerce_surface(finding, finding_id)
            surface.update(updates)
            finding["surface"] = surface
            _sync_legacy_surface_fields(finding, surface)
            captured["finding"] = finding
            return True
        return False

    await mutate_findings(thread_id, _apply)
    return captured.get("finding")


async def append_finding_interaction(
    thread_id: str,
    finding_id: str,
    interaction: FindingInteraction,
) -> Finding | None:
    """Persist a GitHub review-thread interaction on one finding."""
    captured: dict[str, Finding] = {}

    def _apply(findings: list[Finding]) -> bool:
        for finding in findings:
            if finding.get("id") != finding_id:
                continue
            captured["finding"] = finding
            interactions = finding.get("interactions")
            if not isinstance(interactions, list):
                interactions = []
            github_comment_id = interaction.get("github_comment_id")
            if isinstance(github_comment_id, int) and any(
                isinstance(item, dict) and item.get("github_comment_id") == github_comment_id
                for item in interactions
            ):
                return False
            interactions.append(interaction)
            finding["interactions"] = interactions
            return True
        return False

    await mutate_findings(thread_id, _apply)
    return captured.get("finding")


def _coerce_surface(finding: Finding, finding_id: str) -> FindingSurface:
    surface = finding.get("surface")
    if isinstance(surface, dict):
        coerced = cast(FindingSurface, dict(surface))
    else:
        coerced = {"finding_id": finding_id}
    if not coerced.get("state"):
        if finding.get("github_thread_resolved"):
            coerced["state"] = "resolved"
        elif isinstance(finding.get("github_review_comment_id"), int):
            coerced["state"] = "surfaced"
        else:
            coerced["state"] = "not_surfaced"
    coerced.setdefault("github_review_id", finding.get("github_review_id"))
    coerced.setdefault("github_review_comment_id", finding.get("github_review_comment_id"))
    coerced.setdefault("github_review_thread_id", finding.get("github_review_thread_id"))
    coerced.setdefault("severity_threshold_at_publish", None)
    coerced.setdefault("surfaced_at_sha", None)
    coerced.setdefault("last_github_sync_at", None)
    coerced.setdefault("last_error", None)
    return coerced


def _sync_legacy_surface_fields(finding: Finding, surface: FindingSurface) -> None:
    review_id = surface.get("github_review_id")
    if isinstance(review_id, int) or review_id is None:
        finding["github_review_id"] = review_id
    comment_id = surface.get("github_review_comment_id")
    if isinstance(comment_id, int) or comment_id is None:
        finding["github_review_comment_id"] = comment_id
    thread_id = surface.get("github_review_thread_id")
    if isinstance(thread_id, str) or thread_id is None:
        finding["github_review_thread_id"] = thread_id
    if surface.get("state") == "resolved":
        finding["github_thread_resolved"] = True


async def set_reviewer_thread_metadata(
    thread_id: str,
    *,
    pr: ReviewerPRMeta | None = None,
    last_reviewed_sha: str | None = None,
    head_sha: str | None = None,
    watch: bool | None = None,
    findings: list[Finding] | None = None,
    slack_thread: ReviewerSlackThread | None = None,
    extra: dict[str, Any] | None = None,
) -> None:
    """Persist reviewer-thread-level metadata.

    Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
    filtering on metadata. Only includes the fields the caller passed in
    (langgraph metadata updates merge rather than overwrite).

    ``head_sha`` records the current PR head the dispatching webhook is acting
    on. A push that lands mid-run is queued into the still-running run, whose
    frozen config can't be updated; persisting the head here lets the reviewer
    tools resolve the live head via ``resolve_review_head_sha``.
    """
    client = get_client()
    metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
    if pr is not None:
        metadata["pr"] = pr
    if last_reviewed_sha is not None:
        metadata["last_reviewed_sha"] = last_reviewed_sha
    if head_sha is not None:
        metadata["head_sha"] = head_sha
    if watch is not None:
        metadata["watch"] = watch
    if findings is not None:
        metadata["findings"] = findings
    if slack_thread is not None:
        metadata["slack_thread"] = slack_thread
    if extra:
        metadata.update(extra)
    try:
        await client.threads.update(thread_id=thread_id, metadata=metadata)
    except LangGraphSDKNotFoundError as exc:
        raise ReviewerThreadMissingError(thread_id, exc) from exc


def get_thread_watch_flag(metadata: dict[str, Any]) -> bool:
    return bool(metadata.get("watch"))


def get_thread_last_reviewed_sha(metadata: dict[str, Any]) -> str | None:
    value = metadata.get("last_reviewed_sha")
    return value if isinstance(value, str) and value else None


def get_thread_pr_meta(metadata: dict[str, Any]) -> ReviewerPRMeta | None:
    pr = metadata.get("pr")
    if not isinstance(pr, dict):
        return None
    return cast(ReviewerPRMeta, pr)


def get_thread_slack_ref(metadata: dict[str, Any]) -> ReviewerSlackThread | None:
    slack_thread = metadata.get("slack_thread")
    if not isinstance(slack_thread, dict):
        return None
    channel_id = slack_thread.get("channel_id")
    thread_ts = slack_thread.get("thread_ts")
    if not isinstance(channel_id, str) or not isinstance(thread_ts, str):
        return None
    if not channel_id or not thread_ts:
        return None
    return cast(ReviewerSlackThread, slack_thread)


def filter_findings_for_publish(
    findings: list[Finding],
    *,
    severity_threshold: Severity = "medium",
    cap: int = 4,
) -> list[Finding]:
    """Return findings to surface to GitHub.

    - status must be ``open``
    - severity must be at or above ``severity_threshold``
    - sorted by severity descending, then file/start_line for stable ordering
    - capped at ``cap`` to avoid review spam
    """
    severity_rank = SEVERITY_ORDER[severity_threshold]
    eligible = [
        finding
        for finding in findings
        if finding.get("status", "open") == "open"
        and SEVERITY_ORDER.get(finding.get("severity", "low"), 0) >= severity_rank
    ]
    eligible.sort(
        key=lambda f: (
            -SEVERITY_ORDER.get(f.get("severity", "low"), 0),
            f.get("file", ""),
            f.get("start_line") or 0,
        )
    )
    return eligible[:cap]
