#!/usr/bin/env python3
"""
Parse a Claude review action's execution log, render a sticky PR comment, and
set a commit status check. Shared by all four .github/workflows/*-review.yml
workflows so the parser logic lives in one place.

The Claude action writes its full conversation log to a JSON file whose path
is exposed as the `execution_file` step output. Claude's final assistant text
lives inside the last `{"type": "result", "result": "<final text>", ...}`
entry; the workflow's prompt instructs Claude to wrap a verdict JSON in
::review_json:: ... ::end:: markers within that final text.

Three extraction strategies, in order:
  1. Canonical markers (with optional ```json``` code-fence inside)
  2. Brace-counted balanced { ... } containing a `verdict` field (last match wins)
  3. Hard fallback to yellow + INFO finding pointing at the CI log

Usage:
  post-claude-review.py \\
      --context "Test coverage review" \\
      --review-kind "test-coverage" \\
      --execution-file "$EXECUTION_FILE" \\
      --workflow-file ".github/workflows/test-coverage-review.yml"

Env vars consumed: GH_TOKEN, PR_NUMBER, HEAD_SHA, REPO, RUN_ID.

Exits non-zero only when the verdict is `red` — that's the signal branch
protection uses to block the merge button.
"""

import argparse
import json
import os
import re
import subprocess
import sys
from textwrap import shorten


def load_execution_log(path: str) -> str:
    """Return Claude's final assistant text from the action's execution log.

    Prefers the last entry of type=result, falling back to concatenating every
    assistant message's text content if the log shape has shifted.
    """
    if not path or not os.path.exists(path):
        return ''
    try:
        with open(path) as fp:
            log = json.load(fp)
    except Exception as exc:
        print(f'Failed to parse execution_file ({path}): {exc}', file=sys.stderr)
        return ''
    if not isinstance(log, list):
        return ''
    for entry in reversed(log):
        if isinstance(entry, dict) and entry.get('type') == 'result' and 'result' in entry:
            return entry['result']
    chunks = []
    for entry in log:
        if isinstance(entry, dict) and entry.get('type') == 'assistant':
            msg = entry.get('message', {})
            for block in msg.get('content', []) or []:
                if isinstance(block, dict) and block.get('type') == 'text':
                    chunks.append(block.get('text', ''))
    return '\n'.join(chunks)


def extract_verdict(text: str):
    """Parse Claude's verdict JSON from arbitrary final-text output."""
    if not text:
        return None
    for pat in (
        r'::review_json::\s*```(?:json)?\s*(\{.*?\})\s*```\s*::end::',
        r'::review_json::\s*(\{.*?\})\s*::end::',
    ):
        m = re.search(pat, text, re.DOTALL)
        if m:
            try:
                return json.loads(m.group(1))
            except json.JSONDecodeError:
                pass
    candidates = []
    depth = 0
    start = -1
    for i, ch in enumerate(text):
        if ch == '{':
            if depth == 0:
                start = i
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0 and start >= 0:
                candidates.append(text[start:i + 1])
                start = -1
    for candidate in reversed(candidates):
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(parsed, dict) and parsed.get('verdict') in ('red', 'yellow', 'green'):
            return parsed
    return None


def gh(*args: str) -> str:
    result = subprocess.run(['gh', *args], capture_output=True, text=True)
    if result.returncode != 0:
        print(f'gh {args[0]} failed (exit {result.returncode}): {result.stderr}', file=sys.stderr)
        sys.exit(result.returncode)
    return result.stdout


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument('--context', required=True,
                    help='Commit-status context name, e.g. "Test coverage review".')
    ap.add_argument('--review-kind', required=True,
                    help='Short human label, e.g. "test-coverage" — used in fallback descriptions.')
    ap.add_argument('--execution-file', required=True,
                    help='Path to the action\'s execution log JSON (steps.review.outputs.execution_file).')
    ap.add_argument('--workflow-file', required=True,
                    help='Workflow filename for the comment footer, e.g. ".github/workflows/test-coverage-review.yml".')
    ap.add_argument('--findings-heading', default='Findings',
                    help='Section heading above the per-finding details blocks.')
    args = ap.parse_args()

    repo = os.environ['REPO']
    pr_number = os.environ['PR_NUMBER']
    head_sha = os.environ['HEAD_SHA']
    run_id = os.environ['RUN_ID']

    review_output = load_execution_log(args.execution_file)
    if not review_output:
        print(f'review_output is empty (execution_file={args.execution_file!r})', file=sys.stderr)

    parsed = extract_verdict(review_output)
    if parsed is None:
        parsed = {
            'verdict': 'yellow',
            'summary': f'{args.context} ran but no parseable verdict block was found in the output.',
            'findings': [{
                'severity': 'info',
                'category': args.review_kind,
                'location': 'general',
                'description': (
                    'The review output contained neither a ::review_json::...::end:: marker pair '
                    'nor a recognisable JSON verdict object. Check CI logs for the raw Claude output.'
                ),
                'fix': 'Re-run the workflow. If this recurs on the same PR, tighten the prompt or shrink the diff.',
            }],
        }

    verdict = parsed.get('verdict', 'yellow')
    summary = parsed.get('summary', '')
    findings = parsed.get('findings', []) or []
    n = len(findings)

    # Downgrade yellow to green when every finding is LOW or INFO — those
    # severities are informational only and should not produce a noisy advisory.
    priority_sevs = {'CRITICAL', 'HIGH', 'MEDIUM'}
    if verdict == 'yellow' and not any(
        str(f.get('severity', '')).upper() in priority_sevs for f in findings
    ):
        verdict = 'green'

    n_priority = sum(1 for f in findings if str(f.get('severity', '')).upper() in priority_sevs)
    verdict_map = {
        'green':  ('🟢', 'success', f'No concerns ({n} findings, none MEDIUM+)'),
        'yellow': ('🟡', 'success', f'Advisory: {n} finding(s), none blocking'),
        'red':    ('🔴', 'failure', f'Blocked: {n} finding(s) include high/critical severity'),
    }
    emoji, state, status_desc = verdict_map.get(verdict, ('⚪', 'success', f'Verdict unrecognised: {verdict}'))

    decision_map = {
        'green':  '✅ **Decision: clean to merge.** No MEDIUM or above findings; nothing to address.',
        'yellow': (
            '🟡 **Decision: merge OK after reviewing the findings below.** All findings are '
            'advisory (MEDIUM / LOW / INFO) — this status check passes and does NOT block the '
            'merge button. Worth a glance before merging.'
        ),
        'red': (
            f'🛑 **Decision: do NOT merge until findings are addressed.** At least one HIGH/CRITICAL '
            f'finding — this status check **fails**, and branch protection (when configured to '
            f'require `{args.context}`) will block merging.'
        ),
    }
    decision_line = decision_map.get(
        verdict,
        f'⚪ **Decision: unknown verdict `{verdict}`.** Review the workflow output manually before merging.',
    )

    parts = [f'## {emoji} {args.context} — verdict: **{verdict}**\n\n{decision_line}\n\n{summary}\n\n']
    if findings:
        parts.append(f'### {args.findings_heading}\n\n')
        priority_findings = [f for f in findings if str(f.get('severity', '')).upper() in priority_sevs]
        noise_findings = [f for f in findings if str(f.get('severity', '')).upper() not in priority_sevs]

        def finding_block(f: dict) -> str:
            sev = str(f.get('severity', '?')).upper()
            cat = f.get('category', '?')
            loc = f.get('location', 'general')
            desc = (f.get('description') or '').replace('\n', ' ').strip()
            fix = (f.get('fix') or '').strip()
            short = shorten(desc, width=120, placeholder='…') if desc else ''
            return (
                f'<details><summary><strong>{sev} / {cat} / {loc}</strong> — {short}</summary>\n\n'
                f'**Issue:** {desc}\n\n'
                f'**Suggested fix:** {fix}\n\n'
                f'</details>\n\n'
            )

        for f in priority_findings:
            parts.append(finding_block(f))

        if noise_findings:
            n_noise = len(noise_findings)
            label = 'finding' if n_noise == 1 else 'findings'
            inner = ''.join(finding_block(f) for f in noise_findings)
            parts.append(
                f'<details><summary><strong>ℹ️ {n_noise} low-priority {label} (LOW / INFO)</strong></summary>\n\n'
                + inner
                + '</details>\n\n'
            )
    parts.append(
        f'\n---\n_Auto-generated by `{args.workflow_file}` using Claude '
        f'(via your subscription, not pay-per-token). To re-run, comment '
        f'`/review` (all four) or tick a checkbox under '
        f'**Claude review controls** in the PR description. Pushing a '
        f'commit does **not** re-trigger — the workflow only listens on '
        f'`opened` / `ready_for_review` / `issue_comment`._\n'
    )
    body = ''.join(parts)
    with open('/tmp/comment.md', 'w') as fp:
        fp.write(body)

    # Sticky comment heading is unique per workflow context, so each review's
    # comment updates in place without colliding with siblings.
    # Context strings are workflow-owned (e.g. "Security review") — no regex
    # metachars to escape. Avoid re.escape: it backslash-escapes spaces, which
    # Python accepts but jq's `test()` rejects as an invalid escape sequence.
    heading_regex = f'^## (🔴|🟡|🟢|⚪) {args.context}'
    existing = gh(
        'api', f'repos/{repo}/issues/{pr_number}/comments', '--paginate',
        '--jq', f'[.[] | select(.body | test("{heading_regex}"))][0].id // empty',
    ).strip()
    if existing:
        gh('api', '-X', 'PATCH', f'repos/{repo}/issues/comments/{existing}',
           '-F', 'body=@/tmp/comment.md')
        print(f'Updated existing comment {existing}')
    else:
        gh('api', '-X', 'POST', f'repos/{repo}/issues/{pr_number}/comments',
           '-F', 'body=@/tmp/comment.md')
        print('Posted new comment')

    gh('api', '-X', 'POST', f'repos/{repo}/statuses/{head_sha}',
       '-f', f'state={state}',
       '-f', f'context={args.context}',
       '-f', f'description={status_desc[:140]}',
       '-f', f'target_url=https://github.com/{repo}/actions/runs/{run_id}')

    if verdict == 'red':
        print(f'::error::{args.context} verdict: red — see PR comment for findings')
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main())
