#!/usr/bin/env python3
"""composing-vulnerability-report — render cluster 1-4 findings into a deliverable.

Reads JSON/JSONL findings files, deduplicates by Finding.fingerprint(),
groups by severity, and writes a single deliverable-grade markdown
vulnerability report. Emits operational Findings via lib/finding.py for any
parse / structural issue encountered while composing.

Usage:
    python3 compose_report.py PATH [--source FILE]
                                   [--report-output FILE]
                                   [--engagement-id ID]
                                   [--output FILE] [--format json|jsonl|markdown]
                                   [--min-severity sev] [--include-info]
"""

from __future__ import annotations

import argparse
import json
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

# --- lib/ import -------------------------------------------------------------
_LIB_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_LIB_ROOT))

from lib.finding import Finding, Severity, from_json as finding_from_json  # noqa: E402
from lib import report  # noqa: E402


SKILL_ID = "composing-vulnerability-report"
CATEGORY = "report-composition"
REQUIRED_FIELDS = ("title", "severity", "target", "detail", "remediation")


# --- Helpers ----------------------------------------------------------------


def _f(
    severity: Severity,
    title: str,
    target: str,
    detail: str,
    remediation: str,
    evidence: tuple[tuple[str, Any], ...] = (),
) -> Finding:
    return Finding(
        skill_id=SKILL_ID,
        title=title,
        severity=severity,
        target=target,
        detail=detail,
        remediation=remediation,
        evidence=evidence,
    )


# --- Source discovery -------------------------------------------------------


def discover_sources(root: Path, explicit: list[str]) -> list[Path]:
    if explicit:
        return [Path(p).resolve() for p in explicit]
    out: list[Path] = []
    findings_dir = root / "findings"
    if findings_dir.is_dir():
        out.extend(sorted(findings_dir.glob("**/*.json")))
        out.extend(sorted(findings_dir.glob("**/*.jsonl")))
    return out


def load_finding_records(path: Path) -> tuple[list[dict[str, Any]], str | None]:
    """Load findings from a file. Returns (records, error_message_or_None)."""
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as e:
        return [], f"read failed: {e}"
    text = text.strip()
    if not text:
        return [], None
    out: list[dict[str, Any]] = []
    if path.suffix == ".jsonl" or "\n{" in text:
        for line in text.splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                out.append(json.loads(line))
            except json.JSONDecodeError as e:
                return out, f"jsonl line parse error: {e}"
        return out, None
    try:
        data = json.loads(text)
    except json.JSONDecodeError as e:
        return [], f"json parse error: {e}"
    if isinstance(data, list):
        out = [r for r in data if isinstance(r, dict)]
    elif isinstance(data, dict) and "findings" in data:
        out = [r for r in data["findings"] if isinstance(r, dict)]
    elif isinstance(data, dict):
        out = [data]
    return out, None


# --- Finding normalization + dedup ------------------------------------------


def normalize_record(record: dict[str, Any]) -> tuple[Finding | None, str | None]:
    """Convert a record dict into a Finding. Returns (finding, error_or_None)."""
    missing = [f for f in REQUIRED_FIELDS if f not in record]
    if missing:
        return None, f"missing required fields: {', '.join(missing)}"
    try:
        return finding_from_json(record), None
    except (KeyError, ValueError, TypeError) as e:
        return None, f"finding parse error: {e}"


# --- Engagement-id detection ------------------------------------------------


def detect_engagement_id(root: Path) -> str:
    roe = root / "roe.yaml"
    if not roe.exists():
        return root.name
    try:
        text = roe.read_text(encoding="utf-8")
    except OSError:
        return root.name
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("engagement_id:"):
            return line.split(":", 1)[1].strip().strip('"').strip("'")
    return root.name


# --- Report rendering -------------------------------------------------------


def render_summary_table(
    by_severity: dict[Severity, list[Finding]],
) -> str:
    lines = ["| Severity | Count |", "|---|---|"]
    for sev in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO):
        count = len(by_severity.get(sev, []))
        lines.append(f"| **{sev.value.upper()}** | {count} |")
    return "\n".join(lines)


def render_finding_section(f: Finding) -> str:
    anchor = f.fingerprint()
    refs = "\n".join(f"- {r}" for r in f.references) if f.references else "(none)"
    evidence_lines = "\n".join(f"- **{k}**: `{v}`" for k, v in f.evidence) if f.evidence else "(none)"
    sev = f.severity.value.upper()
    cvss = f"\n- **CVSS v3.1:** {f.cvss_score:.1f}" if f.cvss_score else ""
    cve = f"\n- **CVE:** {f.cve_id}" if f.cve_id else ""
    cwe = f"\n- **CWE:** {f.cwe_id}" if f.cwe_id else ""
    owasp = f"\n- **OWASP:** {f.owasp_category}" if f.owasp_category else ""

    return f"""### {f.title}
<a id="finding-{anchor}"></a>

- **Severity:** {sev}
- **Affected target:** `{f.target}`
- **Source skill:** `{f.skill_id}`{cvss}{cve}{cwe}{owasp}

#### Detail

{f.detail}

#### Remediation

{f.remediation}

#### Evidence

{evidence_lines}

#### References

{refs}

---
"""


def render_report(
    findings: list[Finding],
    engagement_id: str,
    source_files: list[Path],
    include_info: bool,
) -> str:
    by_severity: dict[Severity, list[Finding]] = defaultdict(list)
    for f in findings:
        if not include_info and f.severity == Severity.INFO:
            continue
        by_severity[f.severity].append(f)

    now = datetime.now(timezone.utc).isoformat()

    header = f"""# Vulnerability Report — {engagement_id}

> Generated by `{SKILL_ID}` at {now}.
> Source files: {", ".join(str(s.name) for s in source_files) or "(none)"}.

## Summary

{render_summary_table(by_severity)}

"""

    sections: list[str] = []
    for sev in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO):
        bucket = by_severity.get(sev, [])
        if not bucket:
            continue
        if sev == Severity.INFO and not include_info:
            continue
        sections.append(f"## {sev.value.upper()} ({len(bucket)})\n")
        # Stable sort: by skill_id then title
        for f in sorted(bucket, key=lambda x: (x.skill_id, x.title)):
            sections.append(render_finding_section(f))
    return header + "\n".join(sections)


# --- CLI ---------------------------------------------------------------------


def _build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("path", help="Engagement directory")
    p.add_argument("--source", action="append", default=[], help="Findings file (repeatable)")
    p.add_argument("--report-output", default=None)
    p.add_argument("--engagement-id", default=None)
    p.add_argument("--output", default=None)
    p.add_argument("--format", default="markdown", choices=["json", "jsonl", "markdown"])
    p.add_argument(
        "--min-severity",
        default="info",
        choices=["info", "low", "medium", "high", "critical"],
    )
    p.add_argument("--include-info", action="store_true")
    return p


def _filter_min_severity_for_report(findings: list[Finding], min_sev: str) -> list[Finding]:
    floor = Severity(min_sev).numeric
    return [f for f in findings if f.severity.numeric >= floor]


def main(argv: list[str] | None = None) -> int:
    args = _build_arg_parser().parse_args(argv)
    root = Path(args.path).resolve()
    if not root.exists():
        f = _f(
            Severity.CRITICAL,
            f"engagement path missing: {root}",
            str(root),
            f"PATH `{root}` does not exist; cannot compose report.",
            "Verify the engagement directory and re-run.",
        )
        report.emit([f], args.output, args.format, scan_target=str(root))
        return 1

    sources = discover_sources(root, args.source)
    if not sources:
        f = _f(
            Severity.HIGH,
            "no findings sources",
            str(root),
            f"No JSON or JSONL findings files were found under `{root}`.",
            "Run at least one cluster 1-4 scan skill to produce findings, or pass explicit --source paths.",
        )
        report.emit([f], args.output, args.format, scan_target=str(root))
        return 1

    op_findings: list[Finding] = []
    all_findings: list[Finding] = []
    fp_counts: Counter[str] = Counter()

    for src in sources:
        records, err = load_finding_records(src)
        if err:
            op_findings.append(
                _f(
                    Severity.HIGH,
                    f"source unparseable: {src.name}",
                    str(src),
                    err,
                    "Fix the source file or exclude it via explicit --source list.",
                )
            )
            continue
        if not records:
            op_findings.append(
                _f(
                    Severity.INFO,
                    f"source empty: {src.name}",
                    str(src),
                    "Source file contained no records.",
                    "Skipped.",
                )
            )
            continue
        for rec in records:
            f, ferr = normalize_record(rec)
            if f is None:
                op_findings.append(
                    _f(
                        Severity.HIGH,
                        f"record dropped from {src.name}",
                        str(src),
                        ferr or "unknown normalization error",
                        "Fix the record's structure; re-run.",
                    )
                )
                continue
            fp = f.fingerprint()
            fp_counts[fp] += 1
            if fp_counts[fp] == 1:
                all_findings.append(f)

    if fp_counts:
        dup_count = sum(1 for c in fp_counts.values() if c > 1)
        if dup_count:
            op_findings.append(
                _f(
                    Severity.INFO,
                    f"deduplicated {dup_count} duplicate fingerprint(s)",
                    str(root),
                    f"{dup_count} unique findings appeared in more than one source; each was included exactly once.",
                    "No action required.",
                    evidence=(("unique_findings", len(fp_counts)), ("duplicates_collapsed", dup_count)),
                )
            )

    if not all_findings:
        op_findings.append(
            _f(
                Severity.HIGH,
                "no findings to report",
                str(root),
                "All sources were empty, malformed, or contained only records missing required fields.",
                "Resolve source issues and re-run.",
            )
        )

    engagement_id = args.engagement_id or detect_engagement_id(root)
    filtered_for_report = _filter_min_severity_for_report(all_findings, args.min_severity)
    report_md = render_report(filtered_for_report, engagement_id, sources, args.include_info)

    report_path = (
        Path(args.report_output).resolve() if args.report_output else root / "reports" / "vulnerability-report.md"
    )
    try:
        report_path.parent.mkdir(parents=True, exist_ok=True)
        report_path.write_text(report_md, encoding="utf-8")
        op_findings.append(
            _f(
                Severity.INFO,
                f"vulnerability report written: {report_path.name}",
                str(report_path),
                f"Report contains {len(filtered_for_report)} findings across "
                f"{sum(1 for f in filtered_for_report if f.severity == Severity.CRITICAL)} critical, "
                f"{sum(1 for f in filtered_for_report if f.severity == Severity.HIGH)} high, "
                f"{sum(1 for f in filtered_for_report if f.severity == Severity.MEDIUM)} medium, "
                f"{sum(1 for f in filtered_for_report if f.severity == Severity.LOW)} low.",
                "Hand off to customer per the engagement closeout protocol.",
                evidence=(
                    ("report_path", str(report_path)),
                    ("finding_count", len(filtered_for_report)),
                    ("source_count", len(sources)),
                ),
            )
        )
    except OSError as e:
        op_findings.append(
            _f(
                Severity.HIGH,
                "report write failed",
                str(report_path),
                f"OSError: {e}",
                "Resolve permissions/path; re-run.",
            )
        )

    report.emit(op_findings, args.output, args.format, scan_target=str(root))
    return report.exit_code(op_findings)


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