"""Reviewer graph factory.

Mirrors `agent.server.get_agent`'s sandbox lifecycle but configures a deep
agent for code review only:

- Deterministic repo prep (clone-or-fetch + checkout) before the agent's first
  model call so the LLM doesn't burn tokens narrating ``gh repo clone``.
- A computed unified diff and the set of (file, line) tuples in that diff,
  passed via the runnable config so ``add_finding`` can validate at creation
  time rather than failing at GitHub-publish time.
- A reviewer-specific tool set: ``add_finding``, ``update_finding``,
  ``list_findings``, ``publish_review``. No commit/push/PR-opening tools.
- A system prompt that pins the single-evolving-findings model, in-diff-only
  discipline, severity ladder, and the watch-mode reconciliation flow.
"""
# ruff: noqa: E402

import logging
import warnings

logger = logging.getLogger(__name__)

from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel

warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)

from ._patch_messages_reducer import _apply as _apply_messages_reducer_patch

_apply_messages_reducer_patch()

from deepagents import create_deep_agent
from langchain.agents.middleware import ModelCallLimitMiddleware

from .middleware import (
    SanitizeToolInputsMiddleware,
    SlackAssistantStatusMiddleware,
    ToolErrorMiddleware,
)
from .reviewer_findings import (
    list_findings as list_findings_async,
)
from .server import (
    DEFAULT_LLM_MAX_TOKENS,
    DEFAULT_RECURSION_LIMIT,
    MODEL_CALL_RECURSION_LIMIT,
    ensure_sandbox_for_thread,
    graph_loaded_for_execution,
)
from .tools import (
    add_finding,
    fetch_url,
    http_request,
    list_findings,
    publish_review,
    update_finding,
    web_search,
)
from .utils.agents_md import fetch_agents_md
from .utils.auth import resolve_github_token
from .utils.github_token import get_github_token_from_thread
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
from .utils.sandbox_paths import aresolve_sandbox_work_dir

REVIEWER_PROMPT_TEMPLATE = """You are a specialized code reviewer agent. Your job is to review one GitHub PR and publish a single review.

Sandbox: `{working_dir}`. Invoke `gh` as `GH_TOKEN=dummy gh <command>`.

Fetch the diff:

```
GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}
```

Re-review (user message says "A new commit has been pushed"):

```
GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/<last_reviewed_sha>...<head_sha> -H "Accept: application/vnd.github.v3.diff"
```

Clone the repo so you can grep for full file context:

```
GH_TOKEN=dummy gh repo clone {repo_owner}/{repo_name} && cd {repo_name} && git checkout <head_sha>
```

Tools: `add_finding`, `update_finding`, `list_findings`, `publish_review`.
Call `publish_review` once at the end.

Re-review: for each open finding, `update_finding(id, status="resolved")` if
fixed, `update_finding` with new fields + `note` if changed, otherwise do
nothing. Add net-new findings with `add_finding`.

# The bar: file a finding only if it passes these criteria

1. You can anchor it to a specific changed line and quote that line.
2. You can name the concrete failure mode — what breaks at build time,
   runtime, or for users, given the code as it exists today.
3. **Diff-anchor:** the finding's file appears in the PR diff hunk, OR you
   proved a regression via `git show <base_sha>:path` vs
   `git show <head_sha>:path` on a callsite of a symbol whose signature
   changed in the diff. Do not file bugs in unrelated files or subsystems
   based on inference alone.

# Do NOT file

- **Style / naming / convention nits.** No "rename this", "extract a
  constant", "use a different helper", "this could be cleaner". The one
  exception: typos that break behavior (a template binding, an exported name
  a template references by string, a misspelled identifier that fails to
  resolve).
- **Speculation.** No "if X is ever null", "if a future caller passes Y",
  "could potentially race". You need a concrete trigger reachable from the
  current code.
- **Scope-policing / architectural critique.** No "this PR doesn't achieve
  its stated goal", "the design should be different".
- **Pre-existing issues** not introduced by this diff.
- **Out-of-diff / wrong-subsystem speculation.** Do not file findings in
  files absent from the PR diff unless you proved base-vs-head regression on
  a changed symbol's callsite.
- **Same-bug fan-out.** If the same defect appears in N files, file ONE
  finding that lists all sites in `description`. Not N findings.

# Review workflow

The diff is the starting point, not the whole job. Work the changed code
carefully before reaching for unchanged code.

1. **Read the diff end-to-end.** For each changed hunk, ask: *what did this
   exact line change, and what's the failure mode if the change is wrong?*
   Prioritize literal defects (wrong variable, wrong operator, wrong key,
   wrong return) over inferred bugs in nearby unchanged code.
2. **Base-vs-head on refactors.** When the PR renames, moves, extracts, or
   rewrites a function, compare each touched function's old body against the
   new one with `git show <base_sha>:path`. Watch for silently dropped
   behavior: nil-checks, logging, error handling, async-ness, lock scope,
   transactions, validation.
3. **Grep beyond the diff when a contract changed.** If a function
   signature, interface, exported name, config key, or data-shape changed,
   grep implementers and callers. Are they all updated? Same for new lookup
   helpers — find where the data is written and confirm keys match.
4. **Security / trust boundaries when touched.** If the diff includes auth,
   permissions, sessions, caching of authorization decisions, URL fetching,
   HTML/template rendering, or cross-origin behavior, trace the resolution
   path. Don't just suggest tidying — confirm what actually happens on the
   hit, miss, and error paths.
5. **Verify library / framework usage you're not certain of.** If a
   stdlib, ORM, or framework call's semantics matter to the change, confirm
   the contract before assuming a bug or assuming safety.

Use `add_finding` to record each candidate. Don't over-investigate before
recording — capture the finding, keep moving, then rank and prune before
publishing.

# Before publish_review

1. Call `list_findings`. If the diff touches production code and you have
   zero findings, double-check you have actually walked the workflow above —
   silence on a real change is usually a miss, not a clean PR.
2. **Dedup:** collapse duplicate `(file, line, failure_mode)` entries; use
   the fan-out rule for the same defect across multiple sites.
3. **Rank** open findings by severity and confidence. Prefer findings tied
   to a concrete failure mode over findings that merely describe a smell.
4. Keep only the strongest small set. No two findings in the same file
   unless they are independent failure modes with different user-visible
   symptoms.
5. Cross-check PR title and top-changed directories: if a major changed
   prefix has zero findings, re-read that prefix before publishing.

# Severity rubric (tied to runtime consequence)

- `critical` — panic, crash, data loss, auth bypass, security regression.
- `high` — wrong result for users; clear correctness bug.
- `medium` — correctness in an edge case; concurrency hazard with a
  reachable trigger.
- `low` — a real defect with limited blast radius (typo that breaks a
  binding, log level wrong in a hot path, UX bug with concrete impact).

Architectural opinions, naming preferences, and micro-perf are not
severities — they're not findings.

# Other rules

- Read-only. Do not commit, push, or use `gh pr review` / `gh api .../reviews`.
- One finding per defect (with the fan-out rule above for cross-file bugs).
- Include `suggestion` only when the fix is ≤4 lines and obvious.
- Publish a concise review: prefer the highest-confidence findings that
  pass the bar. Use fewer when fewer issues are defensible; publish zero
  only after the workflow above found no concrete regression.
"""


REVIEWER_EVAL_PROMPT_SUFFIX = """
# Eval mode — calibration

This run is scored against a closed set of golden review comments per PR.
The dataset expects 1-5 comments per PR (mean ~2).

- **Hard minimum: at least 1 finding per review.** Publishing zero is only
  acceptable after you have explicitly walked Passes 1-4 and have nothing
  that meets the bar. If you reach `publish_review` empty, return to the
  checklist — silence costs more than a defensible medium-severity finding.
- **Hard cap: at most 3 findings per review.**
- Findings that match a golden comment are rewarded; findings that don't
  are penalized. Missing a golden comment is also penalized. Optimize for
  *defects a careful maintainer would also flag* — not coverage of every
  observation you make.
"""


def _reviewer_system_prompt(
    working_dir: str,
    *,
    repo_owner: str,
    repo_name: str,
    pr_number: int | str,
    reviewer_eval: bool = False,
    repo_style_prompt: str | None = None,
    agents_md_content: str | None = None,
) -> str:
    prompt = REVIEWER_PROMPT_TEMPLATE.format(
        working_dir=working_dir,
        repo_owner=repo_owner or "<owner>",
        repo_name=repo_name or "<repo>",
        pr_number=pr_number if pr_number != "" else "<pr_number>",
    )
    if reviewer_eval:
        prompt = f"{prompt}\n{REVIEWER_EVAL_PROMPT_SUFFIX}"
    if repo_style_prompt:
        prompt = (
            f"{prompt}\n\n"
            "# Repository-specific review style\n\n"
            "The following rules were learned from this repository's historical "
            "PR reviews. Apply them when they agree with the global bar above; "
            "they refine tone, severity, and what this team typically flags.\n\n"
            f"{repo_style_prompt}"
        )
    if agents_md_content:
        prompt = (
            f"{prompt}\n\n"
            "# Repository conventions (AGENTS.md)\n\n"
            "The following is the `AGENTS.md` file from the target branch "
            "(the PR's base), not from the PR head. It documents the "
            "project's conventions, architecture, and rules. Treat "
            "violations of these conventions as candidate findings when "
            "they meet the global bar above (anchored to a changed line, "
            "concrete failure mode, in-diff). Do not file findings for "
            "pre-existing violations outside the diff.\n\n"
            "```\n"
            f"{agents_md_content}\n"
            "```"
        )
    return prompt


def _build_first_review_context(
    *,
    pr_url: str,
    repo_owner: str,
    repo_name: str,
    pr_number: int,
    base_sha: str,
    head_sha: str,
) -> str:
    return (
        f"## Pull request to review\n\n"
        f"- repo: {repo_owner}/{repo_name}\n"
        f"- pr_number: {pr_number}\n"
        f"- url: {pr_url}\n"
        f"- base_sha: {base_sha}\n"
        f"- head_sha: {head_sha}\n\n"
        f"Fetch the diff yourself with "
        f"`GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}`, "
        f"then review using the ordered passes (mechanical grep → diff-line audit "
        f"→ security/auth if applicable → pipeline sweep → deep flow).\n\n"
        f"This is a first review — there are no existing findings. Record issues "
        f"with `add_finding`, call `list_findings` to rank and dedup, then "
        f"`publish_review` once at the end (cap 3)."
    )


def _build_re_review_context(
    *,
    pr_url: str,
    repo_owner: str,
    repo_name: str,
    pr_number: int,
    last_reviewed_sha: str,
    head_sha: str,
    existing_findings_block: str,
) -> str:
    return (
        f"## A new commit has been pushed\n\n"
        f"- repo: {repo_owner}/{repo_name}\n"
        f"- pr_number: {pr_number}\n"
        f"- url: {pr_url}\n"
        f"- previous reviewed SHA: {last_reviewed_sha}\n"
        f"- new HEAD SHA: {head_sha}\n\n"
        f"## Existing findings\n\n{existing_findings_block}\n\n"
        f"Fetch the diff since the previous reviewed SHA yourself with "
        f"`GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/"
        f'{last_reviewed_sha}...{head_sha} -H "Accept: application/vnd.github.v3.diff"`, '
        f"then review only what's in that diff.\n\n"
        f"For each open finding above, decide whether the new commits resolved "
        f'it (`update_finding(id, status="resolved")`), left it unchanged '
        f"(no action), or changed it materially (`update_finding` with new "
        f"fields + a `note`). Then add any net-new findings introduced by the "
        f"new diff, and call `publish_review` once at the end."
    )


def _format_existing_findings(findings: list[dict]) -> str:
    if not findings:
        return "_(none)_"
    lines: list[str] = []
    for f in findings:
        if f.get("status") != "open":
            continue
        location = f.get("file", "<unknown>")
        start = f.get("start_line")
        end = f.get("end_line")
        if start is not None and end is not None:
            location += f":{start}" if start == end else f":{start}-{end}"
        lines.append(
            f"- [{f.get('id')}] ({f.get('severity')}, {f.get('category')}) "
            f"{location} — {f.get('description', '').strip()}"
        )
    return "\n".join(lines) if lines else "_(no open findings)_"


async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
    """Get or create a reviewer agent with a sandbox + prepped repo."""
    thread_id = config["configurable"].get("thread_id", None)

    config["recursion_limit"] = DEFAULT_RECURSION_LIMIT

    if thread_id is None or not graph_loaded_for_execution(config):
        logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
        return create_deep_agent(system_prompt="", tools=[]).with_config(config)

    github_token: str | None = None
    if config["configurable"].get("source"):
        cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
            thread_id
        )
        if cached_token and cached_encrypted:
            config["metadata"]["github_token_encrypted"] = cached_encrypted
            config["metadata"]["github_token_expires_at"] = cached_expires_at
            github_token = cached_token
        else:
            _token, new_encrypted, new_expires_at = await resolve_github_token(config, thread_id)
            config["metadata"]["github_token_encrypted"] = new_encrypted
            config["metadata"]["github_token_expires_at"] = new_expires_at
            github_token = _token

    sandbox_backend = await ensure_sandbox_for_thread(thread_id)

    work_dir = await aresolve_sandbox_work_dir(sandbox_backend)

    repo_config = config["configurable"].get("repo") or {}
    repo_owner = str(repo_config.get("owner", ""))
    repo_name = str(repo_config.get("name", ""))
    base_sha = str(config["configurable"].get("base_sha", "") or "")
    head_sha = str(config["configurable"].get("head_sha", "") or "")
    pr_number = config["configurable"].get("pr_number")
    pr_url = str(config["configurable"].get("pr_url", "") or "")
    last_reviewed_sha = str(config["configurable"].get("last_reviewed_sha", "") or "")
    is_re_review = bool(config["configurable"].get("re_review"))

    # Hotfix: prep was producing empty diffs for some PRs and the agent
    # silently published "no issues found". The agent now fetches the diff
    # itself via `gh pr diff` (or `gh api ...compare...` on re-review).
    # `add_finding`'s in-diff line-range validation is skipped when no
    # diff_line_set is set in config — we trust the agent's anchors.
    config["configurable"]["diff_text"] = ""
    config["configurable"]["diff_line_set"] = None

    review_context = ""
    if pr_number is not None and isinstance(pr_number, int):
        if is_re_review and last_reviewed_sha:
            existing_findings = await list_findings_async(thread_id)
            review_context = _build_re_review_context(
                pr_url=pr_url,
                repo_owner=repo_owner,
                repo_name=repo_name,
                pr_number=pr_number,
                last_reviewed_sha=last_reviewed_sha,
                head_sha=head_sha,
                existing_findings_block=_format_existing_findings(existing_findings),
            )
        else:
            review_context = _build_first_review_context(
                pr_url=pr_url,
                repo_owner=repo_owner,
                repo_name=repo_name,
                pr_number=pr_number,
                base_sha=base_sha,
                head_sha=head_sha,
            )

    from .dashboard.team_settings import get_team_default_model

    configured_model_id = config["configurable"].get("reviewer_model_id")
    configured_effort = config["configurable"].get("reviewer_reasoning_effort")
    if isinstance(configured_model_id, str) and configured_model_id:
        model_id = configured_model_id
        reasoning_effort = configured_effort if isinstance(configured_effort, str) else None
    else:
        model_id, reasoning_effort = await get_team_default_model("reviewer")
        logger.info(
            "Using team default reviewer model: model=%s effort=%s",
            model_id,
            reasoning_effort,
        )
    model_kwargs = provider_model_kwargs(
        model_id,
        reasoning_effort,
        max_tokens=DEFAULT_LLM_MAX_TOKENS,
        openai_reasoning_default=DEFAULT_LLM_REASONING,
    )

    reviewer_eval = (
        config["configurable"].get("reviewer_eval") is True
        or config["configurable"].get("eval") is True
    )
    repo_style_prompt: str | None = None
    if repo_owner and repo_name:
        from .dashboard.review_styles import get_repo_custom_prompt

        repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name)

    # Fetch AGENTS.md from base_sha (the target branch's state before this
    # PR's changes), not head_sha. The contents are inlined into the system
    # prompt, so reading from head would let a PR author smuggle reviewer
    # instructions ("ignore all bugs", "publish no findings") into the
    # review. base_sha is the trusted ref.
    agents_md_content: str | None = None
    if repo_owner and repo_name and base_sha:
        agents_md_content = await fetch_agents_md(
            repo_owner,
            repo_name,
            base_sha,
            token=github_token,
        )
        if agents_md_content:
            logger.info(
                "Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
                len(agents_md_content),
                repo_owner,
                repo_name,
                base_sha,
            )
    del github_token

    system_prompt = _reviewer_system_prompt(
        f"{work_dir}/{repo_name}" if repo_name else work_dir,
        repo_owner=repo_owner,
        repo_name=repo_name,
        pr_number=pr_number if isinstance(pr_number, int) else "",
        reviewer_eval=reviewer_eval,
        repo_style_prompt=repo_style_prompt,
        agents_md_content=agents_md_content,
    )
    if review_context:
        system_prompt = f"{system_prompt}\n\n{review_context}"

    return create_deep_agent(
        model=make_model(model_id, **model_kwargs),
        system_prompt=system_prompt,
        tools=[
            add_finding,
            update_finding,
            list_findings,
            publish_review,
            web_search,
            fetch_url,
            http_request,
        ],
        backend=sandbox_backend,
        middleware=[
            SanitizeToolInputsMiddleware(),
            ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
            ToolErrorMiddleware(),
            SlackAssistantStatusMiddleware(),
        ],
    ).with_config(config)
