#!/usr/bin/env python3
"""Async exception handling review helper.

This script helps you evaluate a C# async/await code path for common reliability
issues described in the source article:
- exceptions surfacing only when awaited
- swallowing failures too early
- treating cancellation as an error
- fire-and-forget risk
- missing timeout, retry, and observability considerations

It is intentionally vendor-neutral and read-only by default.

Usage examples:
  python async_exception_review.py --scenario-file scenario.txt
  python async_exception_review.py --interactive
  cat scenario.txt | python async_exception_review.py

The input can be a brief description of an async service boundary, a code snippet,
or a checklist-style summary of how the operation behaves.
"""

from __future__ import annotations

import argparse
import dataclasses
import json
import re
import sys
from typing import List, Optional


@dataclasses.dataclass
class Finding:
    severity: str
    rule: str
    message: str
    recommendation: str


RULES = [
    {
        "rule": "await-placement",
        "severity": "high",
        "patterns": [r"Task\.Result", r"Task\.Wait\(", r"await\s+.+?outside.+?try", r"fire[- ]and[- ]forget"],
        "message": "Potential async exception handling risk detected.",
        "recommendation": "Place await inside the try/catch at the boundary that can decide on recovery, translation, or rethrow.",
    },
    {
        "rule": "cancellation-handling",
        "severity": "medium",
        "patterns": [r"OperationCanceledException", r"TaskCanceledException"],
        "message": "Cancellation is present and should be separated from faults.",
        "recommendation": "Treat cancellation as a separate path; only catch it when cleanup or specific logging is required.",
    },
    {
        "rule": "rethrow-preservation",
        "severity": "medium",
        "patterns": [r"throw\s+ex\s*;"],
        "message": "Rethrow pattern may reset the stack trace.",
        "recommendation": "Use 'throw;' to preserve the original stack trace.",
    },
    {
        "rule": "observability",
        "severity": "medium",
        "patterns": [r"logger\.", r"correlation", r"trace", r"request id", r"elapsed"],
        "message": "Observability signals appear to be present.",
        "recommendation": "Log with context, dependency name, elapsed time, and correlation identifier before translating or rethrowing.",
    },
    {
        "rule": "transient-faults",
        "severity": "medium",
        "patterns": [r"TimeoutException", r"503", r"retry", r"fallback", r"transient"],
        "message": "Transient-fault handling appears to be considered.",
        "recommendation": "Classify timeouts and upstream failures separately from programming defects, then decide on retry, fallback, or fail-fast behavior.",
    },
]


def read_text(path: Optional[str]) -> str:
    if path:
        with open(path, "r", encoding="utf-8") as f:
            return f.read()
    if not sys.stdin.isatty():
        return sys.stdin.read()
    return ""


def normalize(text: str) -> str:
    return re.sub(r"\s+", " ", text.strip())


def analyze(text: str) -> List[Finding]:
    findings: List[Finding] = []
    haystack = text.lower()

    for rule in RULES:
        matched = any(re.search(pattern, haystack, flags=re.IGNORECASE) for pattern in rule["patterns"])
        if matched:
            findings.append(
                Finding(
                    severity=rule["severity"],
                    rule=rule["rule"],
                    message=rule["message"],
                    recommendation=rule["recommendation"],
                )
            )

    if "try" not in haystack or "catch" not in haystack:
        findings.append(
            Finding(
                severity="medium",
                rule="boundary-handling",
                message="No clear async service boundary handling was detected.",
                recommendation="Add a boundary-level try/catch around the awaited operation that can classify the failure and decide the response.",
            )
        )

    if "await" not in haystack and ("task" in haystack or ".net" in haystack or "async" in haystack):
        findings.append(
            Finding(
                severity="high",
                rule="async-usage",
                message="Async work is mentioned, but no await usage was found.",
                recommendation="Verify that the task is awaited or otherwise observed so exceptions are not lost.",
            )
        )

    return findings


def build_report(text: str, findings: List[Finding]) -> str:
    score = 100
    severity_penalty = {"high": 25, "medium": 10, "low": 3}
    for f in findings:
        score -= severity_penalty.get(f.severity, 5)
    score = max(0, min(100, score))

    lines = []
    lines.append("# Async Exception Handling Review Report")
    lines.append("")
    lines.append(f"**Risk score:** {score}/100")
    lines.append("")
    lines.append("## Summary")
    if findings:
        lines.append("The scenario shows a mix of safe and risky async exception-handling signals. Review the findings below before production.")
    else:
        lines.append("No obvious async exception-handling risks were detected in the provided text, but manual review is still recommended.")
    lines.append("")
    lines.append("## Findings")
    if findings:
        for item in findings:
            lines.append(f"- **{item.severity.upper()}** `{item.rule}`: {item.message}")
            lines.append(f"  - Recommendation: {item.recommendation}")
    else:
        lines.append("- No findings.")
    lines.append("")
    lines.append("## Production-readiness checks")
    checks = [
        "Await every task whose failure must be observed.",
        "Catch exceptions at the boundary that can recover, translate, or rethrow.",
        "Treat cancellation separately from faults.",
        "Use 'throw;' instead of 'throw ex;'.",
        "Avoid fire-and-forget unless failures are explicitly observed.",
        "Log dependency name, timeout budget, and correlation information.",
        "Decide whether retry, fallback, partial response, or fail-fast is appropriate.",
    ]
    for c in checks:
        lines.append(f"- [ ] {c}")
    lines.append("")
    lines.append("## Input snapshot")
    snippet = normalize(text)
    lines.append(snippet[:1200] if snippet else "No input provided.")
    return "\n".join(lines)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Review a C# async/await failure-handling scenario.")
    parser.add_argument("--scenario-file", help="Path to a text file containing code or a scenario description.")
    parser.add_argument("--output", help="Write the report to a file instead of stdout.")
    parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of Markdown.")
    parser.add_argument("--interactive", action="store_true", help="Prompt for a short scenario description.")
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    text = read_text(args.scenario_file)
    if args.interactive:
        print("Enter a short scenario description or paste a code snippet. Finish with Ctrl-D/Ctrl-Z:", file=sys.stderr)
        text = sys.stdin.read()

    if not text.strip():
        print("No input provided. Supply --scenario-file, pipe text via stdin, or use --interactive.", file=sys.stderr)
        return 2

    findings = analyze(text)

    if args.json:
        payload = {
            "risk_score": max(0, min(100, 100 - sum({"high": 25, "medium": 10, "low": 3}.get(f.severity, 5) for f in findings))),
            "findings": [dataclasses.asdict(f) for f in findings],
            "input_preview": normalize(text)[:1200],
        }
        output = json.dumps(payload, indent=2)
    else:
        output = build_report(text, findings)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(output)
    else:
        print(output)

    return 0


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