"""Core PR review orchestration."""

import fnmatch
import logging
from datetime import datetime

from github import GithubException
from rich.console import Console

from prlens.config import load_guidelines
from prlens.gh.pull_request import get_diff, get_pull, get_repo
from prlens.providers.anthropic import AnthropicReviewer
from prlens.providers.openai import OpenAIReviewer
from prlens.utils.code import is_code_file

console = Console()
logger = logging.getLogger(__name__)

_SEVERITY_RANK = {"critical": 3, "major": 2, "minor": 1, "nitpick": 0}


def _get_reviewer(config: dict):
    model = config["model"]
    if model == "anthropic":
        return AnthropicReviewer(api_key=config["anthropic_api_key"])
    if model == "openai":
        return OpenAIReviewer(api_key=config["openai_api_key"])
    raise ValueError(f"Unknown model provider: {model!r}. Choose 'anthropic' or 'openai'.")


def _is_excluded(filename: str, patterns: list[str]) -> bool:
    """Return True if filename matches any exclude pattern.

    Supports:
    - fnmatch globs on the full path: "src/generated/*.py"
    - fnmatch globs on the basename: "*.lock", "*.min.js"
    - Directory names/prefixes: "migrations/", "tests" (matches any file within that tree)
    """
    for pattern in patterns:
        if fnmatch.fnmatch(filename, pattern):
            return True
        # Basename match: "*.lock" matches "path/to/yarn.lock"
        if fnmatch.fnmatch(filename.rsplit("/", 1)[-1], pattern):
            return True
        # Directory prefix: "migrations" or "migrations/" matches "app/migrations/0001.py"
        prefix = pattern.rstrip("/") + "/"
        if filename.startswith(prefix) or ("/" + prefix) in filename:
            return True
    return False


def _determine_event(comments: list[dict]) -> str:
    """Choose the GitHub review event based on the highest severity present."""
    if not comments:
        return "APPROVE"
    severities = {c.get("severity", "minor") for c in comments}
    if severities & {"critical", "major"}:
        return "REQUEST_CHANGES"
    return "COMMENT"


def _build_summary(file_summary: list[dict], total_comments: int) -> str:
    """Build the top-level review body posted as the GitHub review description."""
    reviewed = [f for f in file_summary if not f["skipped"] and f["error"] is None]
    skipped = [f for f in file_summary if f["skipped"]]
    errors = [f for f in file_summary if f["error"] is not None]

    parts = [f"Reviewed **{len(reviewed)}** file(s)"]
    if skipped:
        parts.append(f"skipped **{len(skipped)}**")
    if errors:
        parts.append(f"errors **{len(errors)}**")

    lines = ["## Lens AI Review\n", ", ".join(parts) + f" — **{total_comments}** comment(s)\n"]

    files_with_comments = [f for f in reviewed if f["count"] > 0]
    if files_with_comments:
        lines.append("| File | Comments |")
        lines.append("|------|----------|")
        for f in files_with_comments:
            lines.append(f"| `{f['filename']}` | {f['count']} comment(s) |")

    clean_files = [f for f in reviewed if f["count"] == 0]
    if clean_files:
        lines.append(f"\nClean ({len(clean_files)} file(s) with no issues).")

    if errors:
        lines.append("\nCould not fetch:")
        for f in errors:
            lines.append(f"- `{f['filename']}`: {f['error']}")

    return "\n".join(lines)


def get_diff_positions(patch_text: str) -> dict[int, int]:
    """
    Maps new-file line numbers to their cumulative GitHub diff positions.

    GitHub's review comment API requires positions that are cumulative across
    the entire patch, not reset per hunk. The @@ header line is NOT counted —
    position 1 is the first content line immediately below the @@ header.
    """
    positions: dict[int, int] = {}
    diff_position = 0
    file_line: int | None = None

    for line in patch_text.splitlines():
        if line.startswith("@@"):
            try:
                new_file_range = line.split("+")[1].split(" ")[0]
                file_line = int(new_file_range.split(",")[0])
            except Exception:
                file_line = None
            continue  # @@ header is not counted in diff positions

        diff_position += 1

        if line.startswith("+") and not line.startswith("+++"):
            if file_line is not None:
                positions[file_line] = diff_position
                file_line += 1
        elif line.startswith("-") and not line.startswith("---"):
            pass  # Removed line — does not advance the new-file line counter
        else:
            if file_line is not None:
                file_line += 1

    return positions


def get_patch_line_content(patch_text: str, target_line: int) -> str:
    """Return the source content of a specific new-file line number from a patch."""
    file_line: int | None = None
    for line in patch_text.splitlines():
        if line.startswith("@@"):
            try:
                new_file_range = line.split("+")[1].split(" ")[0]
                file_line = int(new_file_range.split(",")[0])
            except Exception:
                file_line = None
            continue
        if line.startswith("-") and not line.startswith("---"):
            continue  # removed line — no new-file line number
        if file_line is not None:
            if file_line == target_line:
                return line[1:] if line and line[0] in ("+", " ") else line
            file_line += 1
    return ""


def already_commented(
    existing_comments,
    file_path: str,
    file_line: int,
    comment_text: str,
    queued: set[tuple] | None = None,
) -> bool:
    """Check whether an identical comment already exists on the PR for this file+line.

    Checks both GitHub's existing review comments and any comments queued in the
    current run (to catch duplicates across batch boundaries or within a single file).
    """
    text = comment_text.strip()
    if queued is not None and (file_path, file_line, text) in queued:
        return True
    for c in existing_comments:
        # c.line is None for comments whose line no longer exists in the current diff
        # (e.g. after a force-push). Fall back to original_line in that case.
        comment_line = c.line if c.line is not None else getattr(c, "original_line", None)
        if c.path == file_path and comment_line == file_line and text in c.body.strip():
            return True
    return False


def process_file(
    reviewer,
    guidelines: str,
    pr_body: str,
    file,
    patch: str,
    file_content: str,
    existing_comments: list,
    queued: set[tuple] | None = None,
) -> list[dict]:
    if file.filename is None or not patch:
        return []
    if file.status not in ("modified", "added"):
        return []

    diff_positions = get_diff_positions(patch)
    comments = reviewer.review(
        description=pr_body,
        file_name=file.filename,
        diff_patch=patch,
        file_content=file_content,
        guidelines=guidelines,
    )

    results = []
    for comment in comments:
        line = comment.get("line")
        text = comment.get("comment", "")
        severity = comment.get("severity", "minor")
        if severity not in _SEVERITY_RANK:
            severity = "minor"
        if not line or not text:
            continue
        if line not in diff_positions:
            logger.debug("Skipping comment for line %d (not in diff positions)", line)
            continue
        if already_commented(existing_comments, file.filename, line, text, queued):
            logger.debug("Skipping duplicate comment for line %d", line)
            continue
        body = f"**[{severity.upper()}]** {text}"
        results.append({
            "path": file.filename,
            "position": diff_positions[line],
            "body": body,
            "line": line,
            "severity": severity,
            "code": get_patch_line_content(patch, line),
        })
        if queued is not None:
            queued.add((file.filename, line, text.strip()))

    return results


def print_shadow_comments(comments: list[dict]) -> None:
    """Print review comments to the terminal without posting to GitHub."""
    _severity_color = {"critical": "red", "major": "yellow", "minor": "blue", "nitpick": "dim"}
    if not comments:
        console.print("[yellow]Shadow mode: no comments generated.[/yellow]")
        return
    console.print(f"\n[bold]Shadow review — {len(comments)} comment(s) (not posted)[/bold]\n")
    for c in comments:
        severity = c.get("severity", "minor")
        color = _severity_color.get(severity, "white")
        console.print(
            f"[bold cyan]{c['path']}[/bold cyan]  line [bold]{c['line']}[/bold]  "
            f"[{color}]{severity.upper()}[/{color}]"
        )
        code = c.get("code", "").strip()
        if code:
            console.print(f"  [dim]{code}[/dim]")
        console.print(f"  {c['body']}")
        console.print()


def flush_to_file(repo_name: str, pr_id: int, comments: list[dict], log_path: str = "comments.log"):
    with open(log_path, "a", encoding="utf-8") as f:
        for comment in comments:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            path = comment.get("path", "unknown")
            position = comment.get("position", "")
            text = comment.get("body", "").replace('"', '\\"')
            f.write(
                f'[{timestamp}] - {repo_name}#{pr_id} : {path} - '
                f'{{"position": {position}, "comment": "{text}"}}\n'
            )


def run_review(repo: str, pr_number: int, config: dict, auto_confirm: bool = False, shadow: bool = False):
    this_repo = get_repo(repo, token=config["github_token"])

    try:
        this_pr = get_pull(this_repo, pr_number)
    except GithubException:
        raise ValueError(f"PR #{pr_number} not found in {repo}.")

    if this_pr.draft and not config.get("review_draft_prs", False):
        console.print("[yellow]Skipping draft PR. Set review_draft_prs: true in .prlens.yml to review drafts.[/yellow]")
        return

    pr_body = this_pr.body or ""
    reviewer = _get_reviewer(config)
    guidelines = load_guidelines(config)
    max_chars = config.get("max_chars_per_file", 20000)
    batch_limit = config.get("batch_limit", 60)
    exclude_patterns = config.get("exclude", [])

    diff_files = sorted(get_diff(this_pr), key=lambda f: f.filename)
    total = len(diff_files)

    existing_comments = list(this_pr.get_review_comments())
    queued: set[tuple] = set()

    all_comments: list[dict] = []
    file_summary: list[dict] = []

    for i, file in enumerate(diff_files, 1):
        if _is_excluded(file.filename, exclude_patterns) or not is_code_file(file.filename):
            console.print(f"  Skipping: {file.filename}")
            file_summary.append({"filename": file.filename, "count": 0, "skipped": True, "error": None})
            continue

        console.print(f"\n[[{i}/{total}]] Reviewing: {file.filename}")

        try:
            file_content = this_repo.get_contents(
                file.filename, ref=this_pr.head.sha
            ).decoded_content.decode("utf-8", errors="replace")
        except GithubException as e:
            console.print(f"  [red]Could not fetch file: {e}[/red]")
            file_summary.append({"filename": file.filename, "count": 0, "skipped": False, "error": str(e)})
            continue

        patch = file.patch or ""
        if len(patch) > max_chars:
            patch = patch[:max_chars] + "\n... [diff truncated]"
        if len(file_content) > max_chars:
            file_content = file_content[:max_chars] + "\n... [file truncated]"

        new_comments = process_file(
            reviewer, guidelines, pr_body, file, patch, file_content, existing_comments, queued
        )
        all_comments.extend(new_comments)
        file_summary.append({"filename": file.filename, "count": len(new_comments), "skipped": False, "error": None})
        console.print(f"  {len(new_comments)} comment(s) found.")

    if shadow:
        print_shadow_comments(all_comments)
        console.print(f"[bold]Shadow review complete. {len(all_comments)} comment(s) would be posted.[/bold]")
        return

    event = _determine_event(all_comments)
    summary_body = _build_summary(file_summary, len(all_comments))

    if not all_comments:
        if not auto_confirm:
            answer = input("No issues found. Post APPROVE review? (y/n): ").strip().lower()
            if answer != "y":
                return
        this_pr.create_review(body=summary_body, event="APPROVE")
        console.print("\n[green]Review posted: APPROVE[/green]")
        return

    if not auto_confirm:
        answer = input(f"Post {len(all_comments)} comment(s) as {event}? (y/n): ").strip().lower()
        if answer != "y":
            return

    batches = [all_comments[i: i + batch_limit] for i in range(0, len(all_comments), batch_limit)]
    total_posted = 0
    for idx, batch in enumerate(batches):
        is_last = idx == len(batches) - 1
        batch_body = summary_body if is_last else f"Review in progress ({total_posted + len(batch)}/{len(all_comments)} comments)..."
        batch_event = event if is_last else "COMMENT"
        flush_to_file(repo, pr_number, batch)
        api_comments = [{"path": c["path"], "position": c["position"], "body": c["body"]} for c in batch]
        this_pr.create_review(body=batch_body, event=batch_event, comments=api_comments)
        total_posted += len(batch)

    reviewed_count = sum(1 for f in file_summary if not f["skipped"] and f["error"] is None)
    console.print(
        f"\n[green]Review posted: {event}. {total_posted} comment(s) across {reviewed_count} file(s).[/green]"
    )
