#!/usr/bin/env python3
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any


MAX_INLINE_FINDINGS = 20
MAX_COMMENT_BODY = 60000
OMISSION_NOTICE_RESERVE = 256
NO_FINDINGS_MARKER = "<!-- opencode-review:no-findings -->"
DIAGNOSTIC_MARKER = "<!-- opencode-review:diagnostic -->"
FALLBACK_MARKER = "<!-- opencode-review:fallback-findings -->"


def required_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"{name} is required")
    return value


def gh_api(args: list[str]) -> subprocess.CompletedProcess[str]:
    gh_bin = os.environ.get("GH_BIN", "gh")
    try:
        return subprocess.run(
            [gh_bin, "api", *args],
            text=True,
            capture_output=True,
            check=False,
        )
    except FileNotFoundError as exc:
        return subprocess.CompletedProcess(
            args=[gh_bin, "api", *args],
            returncode=127,
            stdout="",
            stderr=f"Binary not found: {exc}",
        )


def list_issue_comments() -> list[dict[str, Any]]:
    repository = required_env("GITHUB_REPOSITORY")
    pr_number = required_env("PR_NUMBER")
    result = gh_api(["--paginate", f"repos/{repository}/issues/{pr_number}/comments?per_page=100"])
    if result.returncode != 0:
        print(f"Could not list existing PR comments: {result.stderr.strip()}", file=sys.stderr)
        return []
    try:
        comments = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        print(f"Could not parse existing PR comments: {exc}", file=sys.stderr)
        return []
    if isinstance(comments, list):
        return [comment for comment in comments if isinstance(comment, dict)]
    return []


def upsert_issue_comment(marker: str, body: str) -> bool:
    repository = required_env("GITHUB_REPOSITORY")
    pr_number = required_env("PR_NUMBER")
    comment_id = None
    for comment in reversed(list_issue_comments()):
        if marker in (comment.get("body") or ""):
            comment_id = comment.get("id")
            break

    method = "PATCH" if comment_id else "POST"
    endpoint = (
        f"repos/{repository}/issues/comments/{comment_id}"
        if comment_id
        else f"repos/{repository}/issues/{pr_number}/comments"
    )
    result = gh_api(["-X", method, endpoint, "-f", f"body={body}"])
    if result.returncode == 0:
        action = "Updated" if comment_id else "Posted"
        print(f"{action} OpenCode comment for marker {marker}.")
        return True
    print(f"Could not post OpenCode comment for marker {marker}: {result.stderr.strip()}", file=sys.stderr)
    return False


def append_summary(path: Path, title: str, lines: list[str]) -> None:
    with path.open("a", encoding="utf-8") as handle:
        handle.write(f"## {title}\n\n")
        for line in lines:
            handle.write(f"{line}\n")
        handle.write("\n")


def snippet(label: str, text: str, *, max_chars: int = 12000) -> list[str]:
    trimmed = text.strip()
    if len(trimmed) > max_chars:
        trimmed = trimmed[:max_chars] + "\n... truncated ..."
    if not trimmed:
        trimmed = "(empty)"
    return [f"### {label}", "", "```text", trimmed, "```", ""]


def read_status(path: Path) -> int:
    try:
        return int(path.read_text(encoding="utf-8").strip())
    except (OSError, ValueError):
        return 1


def parse_findings(raw: str) -> tuple[list[Any] | None, str | None]:
    match = re.search(r"<opencode_findings>\s*(.*?)\s*</opencode_findings>", raw, re.S)
    if not match:
        return None, "No parseable findings block was found."
    payload_text = match.group(1).strip()
    fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", payload_text, re.S)
    if fenced:
        payload_text = fenced.group(1).strip()
    try:
        payload = json.loads(payload_text)
    except json.JSONDecodeError as exc:
        return None, f"Findings block contained invalid JSON: {exc}"
    if not isinstance(payload, list):
        return None, "Findings block JSON was not an array."
    return payload, None


def diagnostic_body(reason: str, status: int, stdout: str, stderr: str) -> str:
    short_sha = required_env("HEAD_SHA")[:12]
    model = required_env("OPENCODE_MODEL")
    lines = [
        DIAGNOSTIC_MARKER,
        "**OpenCode review diagnostic**",
        "",
        "OpenCode did not produce parseable findings, so the workflow posted this diagnostic instead of silently skipping comments.",
        "",
        f"OpenCode model: `{model}`",
        f"Commit: `{short_sha}`",
        f"OpenCode exit status: `{status}`",
        f"Reason: {reason}",
    ]
    stderr_text = stderr.strip()
    stdout_text = stdout.strip()
    if stderr_text:
        lines.extend(["", "Recent stderr:", "```text", stderr_text[-1800:], "```"])
    elif stdout_text:
        lines.extend(["", "Recent stdout:", "```text", stdout_text[-1800:], "```"])
    return "\n".join(lines)


def no_findings_body() -> str:
    short_sha = required_env("HEAD_SHA")[:12]
    model = required_env("OPENCODE_MODEL")
    return (
        f"{NO_FINDINGS_MARKER}\n"
        "**OpenCode review complete**\n\n"
        f"OpenCode model: `{model}`\n\n"
        f"No suggestions found for commit `{short_sha}`."
    )


def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]) -> tuple[str, int, int]:
    model = required_env("OPENCODE_MODEL")
    short_sha = required_env("HEAD_SHA")[:12]
    body_limit = MAX_COMMENT_BODY - OMISSION_NOTICE_RESERVE
    body = "\n".join(
        [
            FALLBACK_MARKER,
            "**OpenCode findings that could not be posted inline**",
            "",
            f"OpenCode model: `{model}`",
            f"Commit: `{short_sha}`",
            "",
            "Some findings could not be posted inline, so they are preserved here.",
        ]
    )
    included = 0
    omitted = 0

    def append_section(title: str, findings: list[dict[str, Any]]) -> None:
        nonlocal body, included, omitted
        if not findings:
            return
        heading = f"\n\n## {title}"
        if len(body) + len(heading) > body_limit:
            omitted += len(findings)
            return
        body += heading

        for finding in findings:
            section = "\n\n".join(
                [
                    "",
                    f"### {finding['path']}:{finding['line']} - {finding['title']}",
                    str(finding["body"]).strip(),
                ]
            )
            if len(body) + len(section) > body_limit:
                omitted += 1
                continue
            body += section
            included += 1

    append_section("Inline placement unavailable", rejected)
    append_section("Additional findings beyond inline comment limit", overflow)

    if omitted:
        notice = f"\n\n_Additional fallback findings omitted from this comment due to GitHub body size limits: {omitted}._"
        if len(notice) > OMISSION_NOTICE_RESERVE:
            notice = notice[: OMISSION_NOTICE_RESERVE - 3].rstrip() + "..."
        body += notice

    return body, included, omitted


def parse_commentable_right_lines(patch: str) -> dict[str, set[int]]:
    commentable: dict[str, set[int]] = {}
    path: str | None = None
    next_right_line: int | None = None

    for line in patch.splitlines():
        if line.startswith("diff --git "):
            path = None
            next_right_line = None
            continue
        if line.startswith("+++ b/"):
            path = line[len("+++ b/") :]
            commentable.setdefault(path, set())
            continue
        if line.startswith("@@ "):
            match = re.search(r"\+(\d+)(?:,(\d+))?", line)
            next_right_line = int(match.group(1)) if match else None
            continue
        if path is None or next_right_line is None:
            continue
        if line.startswith("+") and not line.startswith("+++ "):
            commentable.setdefault(path, set()).add(next_right_line)
            next_right_line += 1
            continue
        if line.startswith("-"):
            continue
        if line.startswith("\\"):
            continue
        commentable.setdefault(path, set()).add(next_right_line)
        next_right_line += 1

    return commentable


def normalize_finding(value: Any, changed_paths: set[str]) -> dict[str, Any] | None:
    if not isinstance(value, dict):
        return None
    path = value.get("path")
    line = value.get("line")
    title = value.get("title")
    body = value.get("body")
    if not isinstance(path, str):
        print(f"Skipped OpenCode finding with invalid path value: {path}")
        return None
    if isinstance(line, bool):
        print(f"Skipped OpenCode finding with invalid line value: {path}:{line}")
        return None
    if isinstance(line, float) and line.is_integer():
        line = int(line)
    if not isinstance(line, int):
        print(f"Skipped OpenCode finding with invalid line value: {path}:{line}")
        return None
    if not isinstance(title, str) or not isinstance(body, str):
        return None
    if path not in changed_paths:
        print(f"Skipped OpenCode finding outside diff scope: {path}:{line}")
        return None
    return {
        "path": path,
        "line": line,
        "title": title.strip(),
        "body": body.strip(),
    }


def post_inline_finding(finding: dict[str, Any]) -> tuple[bool, str]:
    repository = required_env("GITHUB_REPOSITORY")
    pr_number = required_env("PR_NUMBER")
    head_sha = required_env("HEAD_SHA")
    comment = f"**OpenCode: {finding['title']}**\n\n{finding['body']}"[:4000]
    result = gh_api(
        [
            "-X",
            "POST",
            f"repos/{repository}/pulls/{pr_number}/comments",
            "-f",
            f"body={comment}",
            "-f",
            f"commit_id={head_sha}",
            "-f",
            f"path={finding['path']}",
            "-F",
            f"line={finding['line']}",
            "-f",
            "side=RIGHT",
        ]
    )
    if result.returncode == 0:
        return True, ""
    return False, result.stderr.strip()


def handle_diagnostic(
    args: argparse.Namespace,
    reason: str,
    status: int,
    stdout: str,
    stderr: str,
    raw: str,
    *,
    fail_check: bool,
) -> int:
    append_summary(
        args.summary,
        "OpenCode review diagnostic",
        [
            f"OpenCode status: `{status}`",
            f"Reason: {reason}",
            "",
            *snippet("OpenCode stdout", stdout),
            *snippet("OpenCode stderr", stderr),
            *snippet("OpenCode findings output", raw),
        ],
    )
    upsert_issue_comment(DIAGNOSTIC_MARKER, diagnostic_body(reason, status, stdout, stderr))
    print(f"OpenCode did not produce parseable findings: {reason}", file=sys.stderr)
    return 1 if fail_check else 0


def post_findings(args: argparse.Namespace) -> int:
    raw = args.output.read_text(encoding="utf-8")
    stdout = args.stdout.read_text(encoding="utf-8") if args.stdout.exists() else raw
    stderr = args.stderr.read_text(encoding="utf-8") if args.stderr.exists() else ""
    status = read_status(args.opencode_status)

    if status != 0:
        return handle_diagnostic(
            args,
            f"OpenCode exited with status {status}.",
            status,
            stdout,
            stderr,
            raw,
            fail_check=True,
        )

    findings, error = parse_findings(raw)
    if error is not None or findings is None:
        return handle_diagnostic(
            args,
            error or "Unknown parser error.",
            status,
            stdout,
            stderr,
            raw,
            fail_check=False,
        )

    if not findings:
        posted_no_findings = upsert_issue_comment(NO_FINDINGS_MARKER, no_findings_body())
        append_summary(
            args.summary,
            "OpenCode review",
            [
                "OpenCode returned a valid empty findings array.",
                "",
                *snippet("OpenCode stdout", stdout),
                *snippet("OpenCode stderr", stderr),
            ],
        )
        return 0 if posted_no_findings else 1

    changed_paths = set(args.files.read_text(encoding="utf-8").splitlines())
    commentable_lines = None
    patch_filter_warning = ""
    if args.patch is not None:
        try:
            commentable_lines = parse_commentable_right_lines(args.patch.read_text(encoding="utf-8"))
        except OSError as exc:
            patch_filter_warning = f"Could not read OpenCode review patch for inline filtering: {exc}"
            print(patch_filter_warning, file=sys.stderr)
    posted = 0
    rejected = []
    valid_findings = [
        finding
        for finding in (normalize_finding(item, changed_paths) for item in findings)
        if finding is not None
    ]
    prefiltered = []
    inline_candidates = []
    for finding in valid_findings:
        if commentable_lines is not None and finding["line"] not in commentable_lines.get(finding["path"], set()):
            print(
                f"Skipped OpenCode inline finding outside commentable diff lines: {finding['path']}:{finding['line']}",
                file=sys.stderr,
            )
            prefiltered.append(finding)
            continue
        inline_candidates.append(finding)

    inline_findings = inline_candidates[:MAX_INLINE_FINDINGS]
    overflow_findings = inline_candidates[MAX_INLINE_FINDINGS:]

    for finding in inline_findings:
        ok, error_message = post_inline_finding(finding)
        if ok:
            posted += 1
        else:
            print(
                f"GitHub rejected OpenCode inline finding for {finding['path']}:{finding['line']}: {error_message}",
                file=sys.stderr,
            )
            rejected.append(finding)

    if overflow_findings:
        print(f"Preserving {len(overflow_findings)} OpenCode findings beyond inline limit in fallback comment.")

    posted_fallback = True
    fallback_included = 0
    fallback_omitted = 0
    unplaced_findings = prefiltered + rejected
    if unplaced_findings or overflow_findings:
        fallback_comment, fallback_included, fallback_omitted = fallback_body(unplaced_findings, overflow_findings)
        posted_fallback = upsert_issue_comment(FALLBACK_MARKER, fallback_comment)

    summary_lines = [
        f"Parsed findings: `{len(findings)}`",
        f"Inline comments posted: `{posted}`",
        f"Inline comments skipped before posting: `{len(prefiltered)}`",
        f"Inline comments rejected: `{len(rejected)}`",
        f"Findings beyond inline limit: `{len(overflow_findings)}`",
        f"Fallback findings included in comment: `{fallback_included}`",
        f"Fallback findings omitted from comment: `{fallback_omitted}`",
    ]
    if patch_filter_warning:
        summary_lines.append(f"Patch filtering warning: {patch_filter_warning}")
    summary_lines.extend(["", *snippet("OpenCode stdout", stdout), *snippet("OpenCode stderr", stderr)])
    append_summary(args.summary, "OpenCode review", summary_lines)
    print(f"Posted {posted} OpenCode inline comments; preserved {fallback_included} fallback findings.")
    return 0 if posted_fallback else 1


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="OpenCode PR review workflow helpers")
    subparsers = parser.add_subparsers(dest="command", required=True)

    post = subparsers.add_parser("post-findings")
    post.add_argument("--output", type=Path, required=True)
    post.add_argument("--files", type=Path, required=True)
    post.add_argument("--opencode-status", type=Path, required=True)
    post.add_argument("--stdout", type=Path, required=True)
    post.add_argument("--stderr", type=Path, required=True)
    post.add_argument("--summary", type=Path, required=True)
    post.add_argument("--patch", type=Path)
    post.set_defaults(func=post_findings)
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        return args.func(args)
    except RuntimeError as exc:
        print(str(exc), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
