#!/usr/bin/env python3
import argparse
import concurrent.futures
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.request import urlopen


def _run(cmd: List[str], input_text: Optional[str] = None) -> Tuple[int, str, str]:
    proc = subprocess.run(
        cmd,
        input=input_text,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False,
    )
    return proc.returncode, proc.stdout, proc.stderr


def _run_streaming(cmd: List[str]) -> int:
    """Run a subprocess and stream combined stdout/stderr live to the console.
    Returns the exit code."""
    try:
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True,
        )
    except Exception as e:
        print(f"Failed to spawn: {' '.join(cmd)}: {e}", file=sys.stderr)
        return 127
    assert proc.stdout is not None
    for line in proc.stdout:
        print(line.rstrip())
    proc.wait()
    return proc.returncode


def require(cmd: str, hint: str):
    if shutil.which(cmd) is None:
        print(f"Error: required command '{cmd}' not found. {hint}", file=sys.stderr)
        sys.exit(1)


def detect_repo_root() -> Optional[str]:
    code, out, _ = _run(["git", "rev-parse", "--show-toplevel"])
    if code != 0:
        return None
    return out.strip()


def parse_repo_from_url(url: str) -> Optional[str]:
    u = url.strip()
    if not u:
        return None
    if "github.com:" in u:
        path = u.split("github.com:", 1)[1]
    elif "github.com/" in u:
        path = u.split("github.com/", 1)[1]
    elif u.startswith("github.com/"):
        path = u.split("github.com/", 1)[1]
    else:
        return None
    if path.endswith(".git"):
        path = path[:-4]
    parts = path.strip("/").split("/")
    if len(parts) >= 2:
        return f"{parts[0]}/{parts[1]}"
    return None


def detect_repo_from_git() -> Optional[str]:
    code, out, _ = _run(["git", "rev-parse", "--is-inside-work-tree"])
    if code != 0 or out.strip() != "true":
        return None
    code, origin_url, _ = _run(["git", "config", "--get", "remote.origin.url"])
    if code != 0:
        return None
    return parse_repo_from_url(origin_url)


def reviewers_json_path(reviewer: str) -> str:
    root = detect_repo_root() or os.getcwd()
    path = os.path.join(root, "reviewers")
    os.makedirs(path, exist_ok=True)
    return os.path.join(path, f"{reviewer}.json")


def load_reviewer_json(reviewer: str) -> Optional[Dict[str, Any]]:
    p = reviewers_json_path(reviewer)
    if not os.path.isfile(p):
        return None
    try:
        with open(p, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None

def is_https_url(s: str) -> bool:
    try:
        return s.startswith("https://")
    except Exception:
        return False


def load_json_from_path(path: str) -> Optional[Dict[str, Any]]:
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def load_json_from_source(source: str) -> Optional[Dict[str, Any]]:
    if is_https_url(source):
        try:
            with urlopen(source, timeout=30) as resp:
                if resp.status != 200:
                    return None
                data = resp.read()
                return json.loads(data.decode("utf-8"))
        except Exception:
            return None
    return load_json_from_path(source)


def reviewer_from_source(source: str, data: Optional[Dict[str, Any]]) -> str:
    # Prefer explicit name inside JSON
    name = None
    try:
        name = (data or {}).get("reviewer")
    except Exception:
        name = None
    if isinstance(name, str) and name.strip():
        return name.strip()
    # Fallback: stem from path or URL
    if is_https_url(source):
        try:
            p = urlparse(source)
            base = os.path.basename(p.path)
            stem = os.path.splitext(base)[0]
            return stem or "reviewer"
        except Exception:
            return "reviewer"
    base = os.path.basename(source)
    return os.path.splitext(base)[0] or "reviewer"


def save_reviewer_json(reviewer: str, data: Dict[str, Any]):
    """Atomically write reviewers/<reviewer>.json to avoid corruption on interrupts."""
    p = reviewers_json_path(reviewer)
    dirpath = os.path.dirname(p)
    os.makedirs(dirpath, exist_ok=True)
    tmp_path = os.path.join(dirpath, f".{os.path.basename(p)}.tmp")
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp_path, p)

def save_json_to_path(path: str, data: Dict[str, Any]):
    dirpath = os.path.dirname(path)
    os.makedirs(dirpath, exist_ok=True)
    tmp_path = os.path.join(dirpath, f".{os.path.basename(path)}.tmp")
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp_path, path)

def get_current_branch() -> str:
    code, out, _ = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
    return out.strip() if code == 0 else "HEAD"


def resolve_base_ref() -> str:
    # Prefer remote tracking branch if available.
    for ref in ("origin/main", "upstream/main", "main"):
        code, _, _ = _run(["git", "rev-parse", "--verify", ref])
        if code == 0:
            return ref
    return "main"


def get_diff_text(base_ref: str, head_ref: str) -> str:
    # Use merge-base (three-dot) to focus only on changes introduced on the branch.
    # Avoid color codes for clean parsing in the model.
    code, out, err = _run(["git", "diff", "--no-color", f"{base_ref}...{head_ref}"])
    if code != 0:
        print(f"Error: failed to compute git diff: {err.strip()}", file=sys.stderr)
        sys.exit(2)
    return out


def get_changed_files_count(base_ref: str, head_ref: str) -> int:
    code, out, _ = _run(["git", "diff", "--name-only", "--no-color", f"{base_ref}...{head_ref}"])
    if code != 0:
        return 0
    return sum(1 for ln in out.splitlines() if ln.strip())


# Approximate token estimation: ~4 characters per token heuristic.
MAX_DIFF_TOKENS = 50_000


def estimate_tokens_approx(text: str) -> int:
    # Conservative: 1 token per 4 characters; at least number of whitespace-separated words.
    by_chars = (len(text) + 3) // 4
    by_words = len(text.split())
    return max(by_chars, by_words)


def study_files_in_dir(base: str) -> List[str]:
    if not os.path.isdir(base):
        return []
    files = []
    for name in os.listdir(base):
        if re.match(r"PR-\d+-study\.md$", name):
            files.append(os.path.join(base, name))
    # Sort by PR number for determinism
    def prnum(p: str) -> int:
        m = re.search(r"(\d+)", os.path.basename(p))
        return int(m.group(1)) if m else 0
    return sorted(files, key=prnum)


def build_prompt(studyguide: str, diff_text: str, branch: str, base_ref: str) -> str:
    return (
        "You are a senior code reviewer. Evaluate the current branch diff against a study guide.\n\n"
        f"Branch: {branch}\nBase: {base_ref}\n\n"
        "STUDYGUIDE (Markdown):\n" + studyguide + "\n\n"
        "DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
        "Task: Determine whether this diff adheres to the DOs and DON'Ts from the studyguide.\n"
        "- The studyguide might be irrelevant to this diff; mark that clearly.\n"
        "- If relevant and the diff violates items, list each failing point.\n"
        "- If everything passes, return a single green check.\n\n"
        "Output: Respond with EXACTLY one JSON object as RAW JSON (no Markdown, no backticks). Nothing else.\n"
        "Schema: {\n  \"relevant\": boolean,\n  \"passes\": boolean,\n  \"failures\": [\n    { \"issue\": string, \"file\": string, \"line\": number, \"excerpt\": string }\n  ]  // empty if passes or irrelevant\n}\n"
        "Rules:\n- Use true/false for booleans.\n- Provide best-effort file and 1-based line number from the diff; if unknown, use an empty string for file and -1 for line.\n- excerpt should be a short single-line or trimmed code snippet near the line.\n- When irrelevant, set relevant=false and passes=true and failures=[].\n- Do not wrap output in code fences.\n"
    )


def run_codex_exec(prompt: str, last_message_file: Optional[str] = None) -> Tuple[int, str, str]:
    # Prefer globally installed codex; fallback to cargo run.
    if shutil.which("codex") is not None:
        cmd = ["codex", "-c", "model_reasoning_effort=high", "exec"]
        if last_message_file:
            cmd.extend(["--output-last-message", last_message_file])
        return _run(cmd, input_text=prompt)
    cmd = [
        "cargo",
        "run",
        "--quiet",
        "--bin",
        "codex",
        "--",
        "-c",
        "model_reasoning_effort=high",
        "exec",
    ]
    if last_message_file:
        cmd.extend(["--output-last-message", last_message_file])
    return _run(cmd, input_text=prompt)


def build_study_prompt(contents: str, reviewer: str, out_path: str) -> str:
    return (
        f"{contents}\n---\n"
        f"Summarize the takeaways from this PR review by {reviewer} into a concise, practical guide with two checklists: DOs and DON'Ts. "
        f"Add short, accurate code examples in fenced code blocks to illustrate key points. "
        f"Output ONLY the final document as your final message — no preamble, no status notes, no explanations about saving files. "
        f"The CLI will save your final message to {out_path}."
    )


def study_one_from_json(pr_number: int, markdown: str, reviewer: str, dump_dir: str, force: bool = False) -> Tuple[int, Optional[str], Optional[str]]:
    """Return (pr_number, studyguide_text_or_none, error_or_none)."""
    try:
        os.makedirs(dump_dir, exist_ok=True)
        out_path = os.path.join(dump_dir, f"PR-{pr_number}-study.md")
        if (not force) and os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
            with open(out_path, "r", encoding="utf-8") as f:
                return pr_number, f.read(), None
        prompt = build_study_prompt(markdown, reviewer, out_path)
        code, out, err = run_codex_exec(prompt, last_message_file=out_path)
        if code != 0:
            return pr_number, None, f"codex exec failed (exit {code}): {err.strip()}"
        # Fallback to stdout content if file missing/empty
        try:
            if (not os.path.isfile(out_path)) or os.path.getsize(out_path) == 0:
                with open(out_path, "w", encoding="utf-8") as f:
                    f.write(out)
        except Exception:
            pass
        with open(out_path, "r", encoding="utf-8") as f:
            return pr_number, f.read(), None
    except Exception as e:
        return pr_number, None, str(e)


def _study_fill_studyguides(
    data: Dict[str, Any],
    reviewer: str,
    jobs: int = 10,
    limit: Optional[int] = None,
    debug: bool = False,
    force: bool = False,
    save_path: Optional[str] = None,
):
    prs = list(data.get("prs") or [])
    items = []
    for p in prs:
        n = p.get("number")
        md = p.get("markdown", "")
        if not isinstance(n, int) or not isinstance(md, str) or not md.strip():
            continue
        if (not force) and isinstance(p.get("studyguide"), str) and p.get("studyguide").strip():
            continue
        items.append((n, md))
    if limit is not None:
        items = items[:limit]

    repo_root = detect_repo_root() or os.getcwd()
    dump_dir = os.path.join(repo_root, "reviewers", "dump", reviewer)
    if (not debug) and os.path.isdir(dump_dir):
        shutil.rmtree(dump_dir, ignore_errors=True)
    os.makedirs(dump_dir, exist_ok=True)

    total = len(items)
    print(f"Generating studyguides for {total} PR(s) → {dump_dir}")
    lock = threading.Lock()
    completed = 0
    print_study_progress(completed, total, lock)

    results: List[Tuple[int, Optional[str], Optional[str]]] = []
    # Build a map for quick updates
    num_to_obj: Dict[int, Dict[str, Any]] = {}
    for p in prs:
        try:
            n = int(p.get("number"))
            num_to_obj[n] = p
        except Exception:
            continue
    save_lock = threading.Lock()
    with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs)) as ex:
        futs = [ex.submit(study_one_from_json, n, md, reviewer, dump_dir, force) for (n, md) in items]
        for fut in concurrent.futures.as_completed(futs):
            n, sg, err = fut.result()
            results.append((n, sg, err))
            # Persist incrementally on success
            if sg:
                obj = num_to_obj.get(n)
                if obj is not None:
                    obj["studyguide"] = sg
                    with save_lock:
                        if save_path:
                            save_json_to_path(save_path, data)
                        else:
                            save_reviewer_json(reviewer, data)
                        # After saving into JSON, delete the per‑PR study file unless in debug mode
                        if not debug:
                            try:
                                os.remove(os.path.join(dump_dir, f"PR-{n}-study.md"))
                            except FileNotFoundError:
                                pass
                            except Exception:
                                # best‑effort cleanup; ignore failures
                                pass
            completed += 1
            print_study_progress(completed, total, lock)
    # Apply
    number_to_study = {n: sg for (n, sg, err) in results if sg}
    errors = [(n, err) for (n, sg, err) in results if err]
    for p in prs:
        n = p.get("number")
        if n in number_to_study:
            p["studyguide"] = number_to_study[n]
    ok = len(number_to_study)
    print(f"Done. {ok}/{total} studyguides generated.")
    if errors:
        print("Errors:")
        for n, e in errors:
            print(f"- PR-{n}: {e}")


def parse_json_from_text(text: str) -> Optional[Dict]:
    # Accept raw JSON or a fenced ```json block; return parsed dict if possible.
    text = text.strip()
    # Prefer raw JSON
    if text.startswith("{") and text.endswith("}"):
        try:
            return json.loads(text)
        except Exception:
            pass
    # Fallback: fenced code block
    m = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", text, re.IGNORECASE)
    if m:
        try:
            return json.loads(m.group(1))
        except Exception:
            return None
    return None


def _format_failure_display(item: Dict[str, Any]) -> str:
    issue = str(item.get("issue", "")).strip()
    file = str(item.get("file", "")).strip()
    line = item.get("line")
    try:
        line = int(line) if line is not None else -1
    except Exception:
        line = -1
    excerpt = str(item.get("excerpt", "")).strip()
    header = f"@{file}:{line}" if file else "@"
    lines: List[str] = [header]
    if excerpt:
        lines.append(excerpt)
    if issue:
        lines.append(f"> {issue}")
    return "\n".join(lines).strip()


def _to_structured_failure(guide: str, item: Any) -> Dict[str, Any]:
    if isinstance(item, str):
        return {
            "guide": guide,
            "issue": item.strip(),
            "file": "",
            "line": -1,
            "excerpt": "",
        }
    if isinstance(item, dict):
        issue = str(item.get("issue", "")).strip()
        file = str(item.get("file", "")).strip()
        line = item.get("line")
        try:
            line = int(line) if line is not None else -1
        except Exception:
            line = -1
        excerpt = str(item.get("excerpt", "")).strip()
        return {"guide": guide, "issue": issue, "file": file, "line": line, "excerpt": excerpt}
    # Fallback
    return {"guide": guide, "issue": str(item).strip(), "file": "", "line": -1, "excerpt": ""}


def review_one(
    study_path: str,
    diff_text: str,
    branch: str,
    base_ref: str,
    out_dir: str,
    force: bool = False,
) -> Tuple[str, bool, List[str], List[Dict[str, Any]], Optional[str]]:
    # Returns (study_filename, passes, failures, error)
    try:
        with open(study_path, "r", encoding="utf-8") as f:
            studyguide = f.read()
        prompt = build_prompt(studyguide, diff_text, branch, base_ref)

        os.makedirs(out_dir, exist_ok=True)
        tmp_outfile = os.path.join(out_dir, os.path.basename(study_path).replace("-study.md", "-review.json"))

        # Reuse cached result unless forcing a recompute
        content = None
        if (not force) and os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
            try:
                with open(tmp_outfile, "r", encoding="utf-8") as f:
                    content = f.read()
            except Exception:
                content = None

        if content is None:
            code, out, err = run_codex_exec(prompt, last_message_file=tmp_outfile)
            if code != 0:
                return (os.path.basename(study_path), False, [], [], f"codex exec failed (exit {code}): {err.strip()}")

            # Prefer file written by codex; fall back to captured stdout
            try:
                if os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
                    with open(tmp_outfile, "r", encoding="utf-8") as f:
                        content = f.read()
                else:
                    content = out
            except Exception:
                content = out

        data = parse_json_from_text(content)
        if not data:
            return (os.path.basename(study_path), False, [], [], "could not parse JSON from model output")

        # Normalize file on disk to pretty-printed raw JSON for future reuse.
        # Normalize cache file to pretty JSON
        try:
            with open(tmp_outfile, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
                f.write("\n")
        except Exception:
            pass

        relevant = bool(data.get("relevant", True))
        passes = bool(data.get("passes", False))
        raw_failures = list(data.get("failures") or [])
        structured = [_to_structured_failure(os.path.basename(study_path), x) for x in raw_failures]
        failures = [_format_failure_display(x) for x in structured]

        # If irrelevant, treat as pass-by-default (per schema instructions)
        if not relevant:
            passes = True
            failures = []
            structured = []

        return (os.path.basename(study_path), passes, failures, structured, None)
    except Exception as e:
        return (os.path.basename(study_path), False, [], [], str(e))


def review_one_from_json(
    pr_number: int,
    studyguide_text: str,
    diff_text: str,
    branch: str,
    base_ref: str,
    out_dir: str,
    force: bool = False,
) -> Tuple[str, bool, List[str], List[Dict[str, Any]], Optional[str]]:
    label = f"PR-{pr_number}-study.md"
    try:
        os.makedirs(out_dir, exist_ok=True)
        tmp_outfile = os.path.join(out_dir, f"PR-{pr_number}-review.json")

        content = None
        if (not force) and os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
            try:
                with open(tmp_outfile, "r", encoding="utf-8") as f:
                    content = f.read()
            except Exception:
                content = None

        if content is None:
            prompt = build_prompt(studyguide_text, diff_text, branch, base_ref)
            code, out, err = run_codex_exec(prompt, last_message_file=tmp_outfile)
            if code != 0:
                return (label, False, [], [], f"codex exec failed (exit {code}): {err.strip()}")
            try:
                if os.path.isfile(tmp_outfile) and os.path.getsize(tmp_outfile) > 0:
                    with open(tmp_outfile, "r", encoding="utf-8") as f:
                        content = f.read()
                else:
                    content = out
            except Exception:
                content = out

        data = parse_json_from_text(content)
        if not data:
            return (label, False, [], [], "could not parse JSON from model output")

        try:
            with open(tmp_outfile, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2)
                f.write("\n")
        except Exception:
            pass

        relevant = bool(data.get("relevant", True))
        passes = bool(data.get("passes", False))
        raw_failures = list(data.get("failures") or [])
        structured = [_to_structured_failure(label, x) for x in raw_failures]
        failures = [_format_failure_display(x) for x in structured]

        if not relevant:
            passes = True
            failures = []
            structured = []

        return (label, passes, failures, structured, None)
    except Exception as e:
        return (label, False, [], [], str(e))


def aggregate_deduplicate(failures_all: List[Dict[str, Any]], diff_text: str, out_dir: str) -> Tuple[str, Optional[List[Dict[str, Any]]], Optional[str]]:
    """Run Codex to deduplicate failures. Returns (outfile_path, dedup_list_or_none, error_or_none)."""
    if not failures_all:
        return ("", [], None)

    out_path = os.path.join(out_dir, "aggregate-dedup.json")
    issues_json = json.dumps(failures_all, indent=2)
    prompt = (
        "You are assisting with de-duplicating code review issues.\n\n"
        "DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
        "Issues (JSON array where each item has keys: guide, issue, file, line, excerpt):\n"
        + issues_json + "\n\n"
        "Task: Deduplicate issues that are semantically the same, ignoring differences in file, line, or excerpt.\n"
        "Keep the single most descriptive 'issue' text for each group and retain its metadata (guide, file, line, excerpt).\n"
        "Output: EXACT RAW JSON array (no Markdown, no backticks) with the same object shape as the input."
    )
    code, out, err = run_codex_exec(prompt, last_message_file=out_path)
    if code != 0:
        return (out_path, None, f"codex exec failed (exit {code}): {err.strip()}")
    # Read result (file or stdout) and parse JSON
    content = None
    try:
        if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
            with open(out_path, "r", encoding="utf-8") as f:
                content = f.read()
        else:
            content = out
    except Exception:
        content = out
    try:
        data = json.loads(content)
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
            f.write("\n")
        return (out_path, data, None)
    except Exception as e:
        return (out_path, None, f"failed to parse dedup JSON: {e}")


def aggregate_rank(dedup_list: List[Dict[str, Any]], diff_text: str, out_dir: str) -> Tuple[str, Optional[str]]:
    out_path = os.path.join(out_dir, "aggregate-ranked.json")
    issues_json = json.dumps(dedup_list, indent=2)
    prompt = (
        "You are assisting with triage and prioritization of code review issues.\n\n"
        "DIFF (unified):\n```diff\n" + diff_text + "\n```\n\n"
        "Issues (JSON array; each item has guide, issue, file, line, excerpt):\n"
        + issues_json + "\n\n"
        "Task: For each issue, assign a category: P0, P1, P2, NIT, WRONG, IRRELEVANT.\n"
        "Output: EXACT RAW JSON object mapping category -> array of issues, preserving the same fields for each issue.\n"
        "Schema: { \"P0\": Issue[], \"P1\": Issue[], \"P2\": Issue[], \"NIT\": Issue[], \"WRONG\": Issue[], \"IRRELEVANT\": Issue[] }"
    )
    code, out, err = run_codex_exec(prompt, last_message_file=out_path)
    if code != 0:
        return (out_path, f"codex exec failed (exit {code}): {err.strip()}")
    # Parse and normalize JSON
    content = None
    try:
        if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
            with open(out_path, "r", encoding="utf-8") as f:
                content = f.read()
        else:
            content = out
    except Exception:
        content = out
    try:
        data = json.loads(content)
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
            f.write("\n")
        return (out_path, None)
    except Exception as e:
        return (out_path, f"failed to parse ranked JSON: {e}")


def print_progress(passed: int, completed: int, total: int, lock: threading.Lock):
    pct = int((completed / total) * 100) if total else 0
    width = 30
    filled = int((completed / total) * width) if total else 0
    bar = "#" * filled + "-" * (width - filled)
    failed = max(0, completed - passed)
    with lock:
        print(f"[{bar}] {completed}/{total} completed ({pct}%), {failed} failed")


def print_study_progress(completed: int, total: int, lock: threading.Lock):
    pct = int((completed / total) * 100) if total else 0
    width = 30
    filled = int((completed / total) * width) if total else 0
    bar = "#" * filled + "-" * (width - filled)
    with lock:
        print(f"[{bar}] {completed}/{total} studyguides ({pct}%)")


def study_cli(argv: List[str]):
    parser = argparse.ArgumentParser(prog="review study", description="Generate studyguides into reviewers/<user>.json and dump files.")
    parser.add_argument("reviewer", nargs="?", help="GitHub login")
    parser.add_argument("--profile", "-p", help="Path to reviewers JSON; overrides reviewer and save path")
    parser.add_argument("--days", "-d", type=int, default=100, help="Look back N days for PRs (default: 30)")
    parser.add_argument("--jobs", "-j", type=int, default=200, help="Parallel jobs (default: 10)")
    parser.add_argument("--limit", "-n", type=int, default=None, help="Limit number of PRs processed")
    parser.add_argument("--debug", action="store_true", help="Do not clear reviewers/dump/<reviewer> before running")
    parser.add_argument("--force", action="store_true", help="Regenerate studyguides even if present")
    args = parser.parse_args(argv)

    # Ensure reviewers JSON exists via lastprs (skipped when --profile is used)
    script_dir = os.path.dirname(os.path.abspath(__file__))
    local_lastprs = os.path.join(script_dir, "lastprs")
    lastprs_cmd = local_lastprs if os.path.isfile(local_lastprs) and os.access(local_lastprs, os.X_OK) else "lastprs"

    profile_path: Optional[str] = args.profile
    reviewer_arg = args.reviewer
    data: Optional[Dict[str, Any]] = None
    reviewer: Optional[str] = None

    if profile_path:
        data = load_json_from_source(profile_path)
        if data is None:
            print(f"Error: profile not found or invalid JSON: {profile_path}", file=sys.stderr)
            sys.exit(2)
        reviewer = reviewer_from_source(profile_path, data)
    else:
        if not reviewer_arg:
            print("Error: reviewer is required when --profile is not provided.", file=sys.stderr)
            sys.exit(2)
        reviewer = reviewer_arg
        data = load_reviewer_json(reviewer)
        if data is None:
            print(f"Running lastprs for {reviewer} (days={args.days})…")
            code, out, err = _run([lastprs_cmd, str(args.days), reviewer])
            if code != 0:
                print(f"lastprs failed: {err.strip()}", file=sys.stderr)
                sys.exit(2)
            if out.strip():
                print(out.strip())
            data = load_reviewer_json(reviewer)
            if data is None:
                print("Failed to load reviewers JSON after lastprs.", file=sys.stderr)
                sys.exit(2)

    # If requested days exceeds what's in the JSON, refresh via lastprs automatically
    try:
        existing_days = int((data or {}).get("days", 0))
    except Exception:
        existing_days = 0
    if (not profile_path) and args.days is not None and args.days > existing_days:
        print(
            f"Dataset is {existing_days} day(s); requested --days={args.days}. Refreshing via lastprs…"
        )
        code = _run_streaming([lastprs_cmd, str(args.days), reviewer])
        if code != 0:
            print("lastprs failed (see output above)", file=sys.stderr)
        else:
            data2 = load_reviewer_json(reviewer)
            if data2 is not None:
                data = data2

    # If a limit was requested that exceeds dataset size, refresh via lastprs automatically
    current_count = len(list(data.get("prs") or []))
    if (not profile_path) and args.limit is not None and args.limit > current_count:
        print(
            f"Dataset has {current_count} PR(s), but --limit is {args.limit}. Refreshing via lastprs (days={args.days})…"
        )
        code = _run_streaming([lastprs_cmd, str(args.days), reviewer])
        if code != 0:
            print("lastprs failed (see output above)", file=sys.stderr)
            # Continue with what we have rather than aborting
        else:
            data2 = load_reviewer_json(reviewer)
            if data2 is not None:
                data = data2

    _study_fill_studyguides(
        data,
        reviewer,
        jobs=args.jobs,
        limit=args.limit,
        debug=args.debug,
        force=args.force,
        save_path=None if (profile_path and is_https_url(profile_path)) else profile_path,
    )
    if profile_path:
        if is_https_url(profile_path):
            save_reviewer_json(reviewer, data)
            print(f"Source was remote. Updated reviewers/{reviewer}.json with studyguides.")
        else:
            save_json_to_path(profile_path, data)
            print(f"Updated {profile_path} with studyguides.")
    else:
        save_reviewer_json(reviewer, data)
        print(f"Updated reviewers/{reviewer}.json with studyguides.")


def main():
    # Subcommand dispatch (lightweight to preserve existing flags)
    if len(sys.argv) > 1 and sys.argv[1] == "study":
        study_cli(sys.argv[2:])
        return

    parser = argparse.ArgumentParser(
        prog="review",
        description=(
            "Evaluate the current branch diff against studyguides stored in reviewers/<user>.json.\n"
            "Aggregates results, shows a progress bar, and writes outputs to reviewers/dump/<user>/."
        ),
    )
    parser.add_argument("reviewer", nargs="?", help="GitHub login whose studyguides to use (from reviewers/<user>.json)")
    parser.add_argument("--profile", "-p", help="Path to reviewers JSON; overrides reviewer and dataset path")
    parser.add_argument("--jobs", "-j", type=int, default=10, help="Parallel jobs (default: 10)")
    parser.add_argument("--base", default=None, help="Base ref to diff against (default: auto: origin/main or main)")
    parser.add_argument(
        "--limit",
        "-n",
        type=int,
        default=None,
        help="Use only the first N study guides after sorting (like head -n)",
    )
    parser.add_argument(
        "--hide-errors",
        action="store_true",
        help="Do not print per-guide errors encountered",
    )
    parser.add_argument(
        "--force",
        action="store_true",
        help="Recompute review JSONs even if cached results exist",
    )
    parser.add_argument("--debug", action="store_true", help="Do not clear reviewers/dump/<reviewer> before running")
    # Default behavior: clear reviewers/dump/<reviewer> unless --debug is set in either run or study mode.

    args = parser.parse_args()

    require("gh", "Install GitHub CLI: https://cli.github.com (used by other tools in this repo)")

    repo_root = detect_repo_root() or os.getcwd()
    profile_path: Optional[str] = args.profile
    data: Optional[Dict[str, Any]] = None

    # Resolve reviewer and dataset
    if profile_path:
        data = load_json_from_source(profile_path)
        if data is None:
            print(f"Error: profile not found or invalid JSON: {profile_path}", file=sys.stderr)
            sys.exit(2)
        reviewer = reviewer_from_source(profile_path, data)
    else:
        reviewer = args.reviewer
        if not reviewer:
            print("Error: reviewer is required when --profile is not provided.", file=sys.stderr)
            sys.exit(2)
        # Preferred source: reviewers/<reviewer>.json
        data = load_reviewer_json(reviewer)
        if data is None:
            repo = detect_repo_from_git() or "owner/repo"
            print(f"No reviewers/{reviewer}.json found.")
            try:
                resp = input(f"Study {reviewer}'s last 100 days of PRs under {repo}? [y/N] ").strip().lower()
            except EOFError:
                resp = "n"
            if resp == "y":
                code, out, err = _run([os.path.join(repo_root, "lastprs"), "100", reviewer])
                if code != 0:
                    print(f"lastprs failed: {err.strip()}", file=sys.stderr)
                    sys.exit(2)
                data = load_reviewer_json(reviewer)
                if data is None:
                    print("Failed to load reviewers JSON after lastprs.", file=sys.stderr)
                    sys.exit(2)
                # Prompt to run study now
                try:
                    resp2 = input("Generate studyguides now? [Y/n] ").strip().lower()
                except EOFError:
                    resp2 = "y"
                if resp2 in ("", "y"):
                    _study_fill_studyguides(data, reviewer, jobs=args.jobs, limit=args.limit, debug=False)
                    save_reviewer_json(reviewer, data)
            else:
                print("Aborting: reviewer dataset not found.", file=sys.stderr)
                sys.exit(2)

    # Build list of (pr_number, studyguide)
    prs = list(data.get("prs") or [])
    # Optional: array of PR numbers to ignore when reviewing
    ignore_list = data.get("ignore") or []
    ignore_set = set()
    try:
        for x in ignore_list:
            try:
                ignore_set.add(int(x))
            except Exception:
                continue
    except Exception:
        ignore_set = set()
    pairs: List[Tuple[int, str]] = []
    for p in prs:
        n = p.get("number")
        sg = p.get("studyguide", "")
        if isinstance(n, int) and isinstance(sg, str) and sg.strip():
            if n in ignore_set:
                continue
            pairs.append((n, sg))
    if not pairs:
        msg = f"No studyguides present in reviewers/{reviewer}.json. Try: ./review study {reviewer}"
        if ignore_set:
            msg = f"No studyguides to review after applying ignore list ({len(ignore_set)} ignored). Try: ./review study {reviewer}"
        print(msg, file=sys.stderr)
        sys.exit(2)
    total_available = len(pairs)
    if args.limit is not None:
        if args.limit <= 0:
            print("Error: --limit must be a positive integer.", file=sys.stderr)
            sys.exit(2)
        pairs = pairs[: args.limit]

    # Output dir: reviewers/dump/<reviewer> (clear unless --debug)
    out_dir = os.path.join(repo_root, "reviewers", "dump", reviewer)
    if not getattr(args, "debug", False) and os.path.isdir(out_dir):
        shutil.rmtree(out_dir, ignore_errors=True)
    os.makedirs(out_dir, exist_ok=True)

    branch = get_current_branch()
    base_ref = args.base or resolve_base_ref()
    diff_text = get_diff_text(base_ref, "HEAD")
    files_changed = get_changed_files_count(base_ref, "HEAD")
    est_tokens = estimate_tokens_approx(diff_text)
    if not diff_text.strip():
        print("Warning: empty diff vs base; all guides may be irrelevant or pass.", file=sys.stderr)

    # out_dir already set earlier (reviewers/dump/<reviewer>)

    total = len(pairs)
    passed = 0
    completed = 0
    lock = threading.Lock()
    failures_all: List[Dict[str, Any]] = []  # structured failures
    errors_all: List[Tuple[str, str]] = []    # (guide, error)

    print(f"Running {total} review(s) against {branch} vs {base_ref}…")
    print(f"Files changed: {files_changed}")
    print(f"Estimated diff tokens: {est_tokens} (limit {MAX_DIFF_TOKENS})")
    if est_tokens > MAX_DIFF_TOKENS:
        print(
            f"Error: diff is too large to review (estimated {est_tokens} tokens > limit {MAX_DIFF_TOKENS}).",
            file=sys.stderr,
        )
        sys.exit(2)
    print(f"Output dir: {out_dir}")
    if args.limit is not None and args.limit < total_available:
        print(f"Limit: using first {total} of {total_available} guides")
    print_progress(passed, completed, total, lock)

    # Worker for ThreadPool: evaluate a single (pr_number, studyguide_text)
    def task(it: Tuple[int, str]):
        pr_number, studyguide_text = it
        return review_one_from_json(
            pr_number,
            studyguide_text,
            diff_text,
            branch,
            base_ref,
            out_dir,
            force=args.force,
        )

    with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
        futs = [ex.submit(task, it) for it in pairs]
        for fut in concurrent.futures.as_completed(futs):
            guide_name, ok, failures_display, failures_structured, err = fut.result()
            with lock:
                completed += 1
                if ok:
                    passed += 1
                else:
                    if err:
                        errors_all.append((guide_name, err))
                    for item in failures_structured:
                        failures_all.append(item)
            print_progress(passed, completed, total, lock)

    print("")
    failures_total = max(0, total - passed)
    print(f"Summary: {failures_total} failed")
    if (not args.hide_errors) and errors_all:
        print("\nErrors:")
        for g, e in errors_all:
            print(f"- {g}: {e}")

    if failures_all:
        print("\nFailed points:")
        for item in failures_all:
            print(f"- [{item.get('guide','?')}] {_format_failure_display(item)}")
    else:
        print("\nNo failed points detected.")

    # 4) Aggregate via Codex: deduplicate (optional), then rank
    if failures_all:
        print("\nAggregating failed points…")
        dedup_path = os.path.join(out_dir, "aggregate-dedup.json")
        dedup_list: List[Dict[str, Any]] = []
        if len(failures_all) == 1:
            # Skip model deduplication for a single issue; still write a trace file.
            single = failures_all[0]
            dedup_list = [single]
            try:
                with open(dedup_path, 'w', encoding='utf-8') as f:
                    json.dump(dedup_list, f, indent=2)
                    f.write("\n")
            except Exception as e:
                print(f"Failed to write dedup file: {e}", file=sys.stderr)
        else:
            path, data, dedup_err = aggregate_deduplicate(failures_all, diff_text, out_dir)
            if dedup_err:
                print(f"Dedup error: {dedup_err}", file=sys.stderr)
            else:
                dedup_path = path
                try:
                    with open(dedup_path, 'r', encoding='utf-8') as f:
                        dedup_list = json.load(f)
                except Exception as e:
                    print(f"Failed to read dedup file: {e}", file=sys.stderr)
                    dedup_list = []

        if dedup_list:
            print(f"\nDeduplicated issues written to: {dedup_path}\n")
            preview = json.dumps(dedup_list, indent=2)[:2000]
            print(preview)

            ranked_path, rank_err = aggregate_rank(dedup_list, diff_text, out_dir)
            if rank_err:
                print(f"Ranking error: {rank_err}", file=sys.stderr)
            else:
                try:
                    with open(ranked_path, 'r', encoding='utf-8') as f:
                        ranked_text = f.read()
                    print(f"\nRanked issues written to: {ranked_path}\n")
                    print(ranked_text.strip()[:2000])
                except Exception as e:
                    print(f"Failed to read ranked file: {e}", file=sys.stderr)


if __name__ == "__main__":
    main()
