#!/usr/bin/env python3
"""
Render scan reports from update.json using Reports/email_html.jinja2,
full_vulnerable_reports.jinja2, and email_body.jinja2 (short client email).

Filename stem: {TargetNameSlug}_{YYYY-MM-DD} using JSON keys target_name,
client_name, or project_name when set; otherwise derived from the URL host.

Dependencies:
  pip install jinja2
  pip install playwright && playwright install chromium   # for --pdf / --send-email

Environment (optional):
  SCAN_REPORT_CTA_URL, SCAN_REPORT_CTA_LABEL, SCAN_REPORT_CTA_LEDE
  SCAN_REPORT_SIGNOFF_NAME, SCAN_REPORT_SIGNOFF_EMAIL
  SCAN_REPORT_EMAIL_TO (default jaco@overdrive.co.za), SCAN_REPORT_SMTP_FROM

  Bcc on the first mail: jaco@overdrive.co.za. After that send succeeds, a second
  mail (To jaco only) goes with the full HTML report attached.
  SCAN_REPORT_EMAIL_SUBJECT (override auto subject for first mail)
  SCAN_REPORT_FULL_EMAIL_SUBJECT (override subject for full-report follow-up)
  SCAN_REPORT_SMTP_HOST, SCAN_REPORT_SMTP_PORT (465 or 587),
  SCAN_REPORT_SMTP_USER, SCAN_REPORT_SMTP_PASSWORD

  Generic fallbacks (e.g. appnotify): SMTP_HOST, SMTP_PORT, SMTP_USER,
  SMTP_PASSWORD, SMTP_FROM — used when SCAN_REPORT_* is unset.

  Optional: place KEY=VAL pairs in `.env` next to this script (loaded if not
  already in the process environment). Keep `.env` out of git.
"""
from __future__ import annotations

import argparse
import html
import json
import os
import re
import smtplib
from datetime import datetime, timezone
from email.message import EmailMessage
from pathlib import Path
from urllib.parse import urlparse

from load_env import load_env_files, log_smtp_config, mail_from_header
from report_branding import report_logo_context

try:
    from jinja2 import Environment, FileSystemLoader, select_autoescape
except ImportError as e:  # pragma: no cover
    raise SystemExit(
        "Missing dependency: jinja2. Install with: pip install jinja2"
    ) from e

# Internal copy address (Bcc on summary mail; To on follow-up full report).
REPORT_EMAIL_JACO = "jaco@overdrive.co.za"
REPORT_EMAIL_BCC = REPORT_EMAIL_JACO
OPS_ALERT_EMAIL = os.environ.get("SCAN_OPS_ALERT_EMAIL", REPORT_EMAIL_JACO)
REPORT_BRAND_NAME = os.environ.get("SCAN_REPORT_BRAND_NAME", "Silicon Overdrive")


def report_brand_name() -> str:
    return (os.environ.get("SCAN_REPORT_BRAND_NAME") or REPORT_BRAND_NAME).strip() or "Silicon Overdrive"

SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITY_ORDER)}

PILL_CLASS = {
    "critical": "crit",
    "high": "high",
    "medium": "med",
    "low": "low",
    "info": "info",
}

EMAIL_BADGE = {
    "critical": ("#fbe9e7", "#8b1a1a"),
    "high": ("#fdecdb", "#c2410c"),
    "medium": ("#fbf2cf", "#a16207"),
    "low": ("#e0e7ff", "#1d4ed8"),
    "info": ("#e8eaef", "#475569"),
}


def _parse_iso(dt: str) -> datetime:
    s = (dt or "").strip()
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    return datetime.fromisoformat(s)


def friendly_target_name(data: dict, target: str) -> str:
    """Display name: JSON target_name / client_name / project_name, else derived from host."""
    for key in ("target_name", "client_name", "project_name"):
        v = data.get(key)
        if v and str(v).strip():
            return str(v).strip()
    h = host_display(target)
    if not h:
        return "Scan"
    first = h.split(".")[0].replace("-", " ").replace("_", " ")
    return first.title() if first else "Scan"


def slug_stem(name: str) -> str:
    """Filesystem-safe stem fragment from a display name (e.g. Shorts of Practise → Shorts_of_Practise)."""
    s = re.sub(r"[^a-zA-Z0-9]+", "_", (name or "").strip()).strip("_")
    return s or "Scan"


def file_stem_from_data(data: dict, scan_date: str) -> str:
    """{TargetNameSlug}_{YYYY-MM-DD} — starts with client/target name when set in JSON."""
    target = str(data.get("target") or "")
    display = friendly_target_name(data, target)
    dt = _parse_iso(scan_date)
    stamp = dt.strftime("%Y-%m-%d")
    return f"{slug_stem(display)}_{stamp}"


def host_display(target: str) -> str:
    p = urlparse((target or "").strip())
    return re.sub(r"^www\.", "", p.hostname or p.netloc or target, flags=re.I)


def resolved_ip(findings: list, target: str) -> str:
    for f in findings:
        raw = f.get("raw") or {}
        if f.get("tool") == "httpx" and raw.get("host_ip"):
            return str(raw["host_ip"])
        if f.get("tool") == "nmap" and raw.get("host"):
            return str(raw["host"])
    p = urlparse(target)
    return p.hostname or "—"


def tools_line(findings: list) -> str:
    tools = sorted({str(f.get("tool") or "?") for f in findings})
    return " · ".join(tools)


def sort_findings(findings: list) -> list:
    def key(f):
        sev = (f.get("severity") or "info").lower()
        return (SEVERITY_RANK.get(sev, 99), (f.get("title") or ""))

    return sorted(findings, key=key)


def instance_count(f: dict) -> int:
    raw = f.get("raw") or {}
    if raw.get("count") is not None:
        try:
            return int(str(raw["count"]))
        except ValueError:
            pass
    inst = raw.get("instances")
    if isinstance(inst, list):
        return len(inst)
    return 1


def confidence_label(raw: dict) -> str:
    c = str(raw.get("confidence", ""))
    if c == "3":
        return "High"
    if c == "2":
        return "Medium"
    if c == "1":
        return "Low"
    return "—"


def difficulty_badge(raw: dict) -> tuple[str | None, str | None]:
    sol = (raw.get("solution") or "").lower()
    if any(x in sol for x in ("easy", "simple", "x-frame-options", "same-site")):
        return ("easy", "Easy fix")
    if any(x in sol for x in ("difficult", "complex", "hard to")):
        return ("hard", "Hard fix")
    if raw.get("solution"):
        return ("mod", "Moderate fix")
    return (None, None)


def wrap_plain_description(text: str) -> str:
    t = (text or "").strip()
    if not t:
        return "<p>(No description)</p>"
    if "<" in t and ">" in t:
        return t
    return f"<p>{html.escape(t)}</p>"


def evidence_blocks(f: dict) -> str:
    raw = f.get("raw") or {}
    parts: list[str] = []
    inst = raw.get("instances")
    if isinstance(inst, list) and inst:
        for it in inst[:12]:
            uri = html.escape(str(it.get("uri") or it.get("nodeName") or ""))
            ev = it.get("evidence") or it.get("param") or ""
            evs = html.escape(str(ev)[:2000])
            parts.append(f'<div class="evidence"><span class="uri">{uri}</span>{evs}</div>')
        if len(inst) > 12:
            parts.append(f'<div class="evidence">… {len(inst) - 12} more instance(s)</div>')
    elif f.get("tool") == "nuclei":
        line = html.escape(json.dumps(raw, indent=2)[:4000])
        parts.append(f'<div class="evidence"><span class="uri">nuclei raw</span>{line}</div>')
    elif f.get("tool") == "nmap":
        r = raw
        line = f"{r.get('host')}:{r.get('port')}/{r.get('protocol')} {r.get('service')} {r.get('product') or ''}"
        parts.append(f'<div class="evidence">{html.escape(line.strip())}</div>')
    if not parts and f.get("endpoint"):
        parts.append(f'<div class="evidence">{html.escape(str(f.get("endpoint")))}</div>')
    if not parts:
        return ""
    return '<div class="evidence-list">' + "".join(parts) + "</div>"


def references_html(raw: dict) -> str:
    ref = raw.get("reference") or ""
    if not ref.strip():
        return ""
    # ZAP often wraps links in <p>
    if "<li>" in ref or "<ul" in ref:
        return ref
    # strip outer <p> and split URLs
    text = re.sub(r"</?p[^>]*>", "\n", ref, flags=re.I)
    lines = [ln.strip() for ln in re.sub(r"<[^>]+>", "", text).splitlines() if ln.strip()]
    if not lines:
        return ""
    items = "".join(f"<li>{html.escape(ln)}</li>" for ln in lines[:20])
    return f'<ul class="ref-list">{items}</ul>'


def is_network_finding(f: dict) -> bool:
    t = f.get("tool")
    if t == "nmap":
        return True
    if t == "httpx":
        return True
    return False


def is_detail_finding(f: dict) -> bool:
    if f.get("tool") == "nmap":
        return False
    if f.get("tool") == "httpx":
        return False
    if f.get("tool") == "zap" and (f.get("severity") or "").lower() == "info":
        return False
    return True


def is_info_zap(f: dict) -> bool:
    return f.get("tool") == "zap" and (f.get("severity") or "").lower() == "info"


def build_detail_row(f: dict, display_id: str) -> dict:
    raw = f.get("raw") or {}
    sev = (f.get("severity") or "info").lower()
    desc = f.get("description") or raw.get("desc") or f.get("title") or ""
    sol = raw.get("solution") or ""
    diff_c, diff_l = difficulty_badge(raw)
    cweid = raw.get("cweid")
    cwe = f"CVE-{cweid}" if cweid else ""
    endpoint_line = f.get("endpoint") or ""
    if not endpoint_line and isinstance(raw.get("instances"), list) and raw["instances"]:
        endpoint_line = str(raw["instances"][0].get("uri") or "")
    param_line = ""
    if isinstance(raw.get("instances"), list) and raw["instances"]:
        p0 = raw["instances"][0].get("param")
        if p0 and len(str(p0)) < 200:
            param_line = str(p0)
    systemic = "Yes" if raw.get("systemic") else ("No" if raw.get("systemic") is False else "—")
    return {
        "display_id": display_id,
        "title": f.get("title") or "Finding",
        "severity_label": sev.title(),
        "pill": PILL_CLASS.get(sev, "info"),
        "diff_class": diff_c,
        "diff_label": diff_l,
        "tool": f.get("tool") or "?",
        "cwe": cwe,
        "confidence": confidence_label(raw),
        "instances": instance_count(f),
        "systemic": systemic,
        "endpoint_line": endpoint_line,
        "param_line": param_line,
        "description_html": wrap_plain_description(str(desc)),
        "evidence_html": evidence_blocks(f),
        "solution_html": sol if sol else "",
        "references_html": references_html(raw),
    }


def next_display_id(sev: str, counters: dict) -> str:
    counters[sev] = counters.get(sev, 0) + 1
    prefix = {"critical": "C", "high": "H", "medium": "M", "low": "L", "info": "I"}.get(sev, "X")
    return f"{prefix}-{counters[sev]:02d}"


def build_network_rows(findings: list) -> list[dict]:
    rows = []
    nf = [f for f in sort_findings(findings) if is_network_finding(f)]
    for i, f in enumerate(nf, start=1):
        raw = f.get("raw") or {}
        if f.get("tool") == "nmap":
            svc = str(raw.get("service") or "?")
            detail = str(raw.get("product") or "") or str(f.get("description") or "")
            ep = str(f.get("endpoint") or f"{raw.get('host')}:{raw.get('port')}")
            rows.append(
                {
                    "idx": f"N-{i:02d}",
                    "endpoint": ep,
                    "service": svc,
                    "detail": detail[:500],
                }
            )
        else:
            title = str(f.get("title") or "httpx")
            loc = raw.get("location") or ""
            tech = ", ".join(raw.get("tech") or [])[:200]
            ep = str(f.get("endpoint") or raw.get("url") or "")
            detail = " ".join(x for x in (loc, tech) if x)
            rows.append(
                {
                    "idx": f"N-{i:02d}",
                    "endpoint": ep,
                    "service": title,
                    "detail": detail[:500],
                }
            )
    return rows


def build_info_zap_rows(findings: list, target: str) -> list[dict]:
    rows = []
    for i, f in enumerate([x for x in sort_findings(findings) if is_info_zap(x)], start=1):
        raw = f.get("raw") or {}
        aff = str(f.get("endpoint") or raw.get("alert") or host_display(target) or "—")
        rows.append(
            {
                "idx": f"I-{i:02d}",
                "title": str(f.get("title") or "Finding"),
                "affected": aff[:300],
                "instances": instance_count(f),
            }
        )
    return rows


def collect_top_mediums(findings: list, limit: int = 5) -> list[dict]:
    """Short list for the email_body highlight box."""
    rows: list[dict] = []
    n = 0
    for f in sort_findings(findings):
        if (f.get("severity") or "").lower() != "medium":
            continue
        n += 1
        rows.append({"row_id": f"M-{n:02d}", "title": str(f.get("title") or "")})
        if len(rows) >= limit:
            break
    return rows


def hook_line(summary: dict) -> str:
    c = int(summary.get("critical") or 0)
    h = int(summary.get("high") or 0)
    m = int(summary.get("medium") or 0)
    if c or h:
        return "Critical or high items need urgent review; see the attached PDF for detail."
    if m:
        return (
            f"There are {m} medium-severity item(s)—often header or configuration fixes. "
            "No critical or high issues in this run."
        )
    return "No critical, high, or medium issues in this run; remaining findings are low or informational."


def summary_email_text(summary: dict, total: int) -> str:
    c, h, m, l, i = (
        summary.get("critical", 0),
        summary.get("high", 0),
        summary.get("medium", 0),
        summary.get("low", 0),
        summary.get("info", 0),
    )
    parts = [
        f"The latest scan identified {total} findings across the public-facing infrastructure."
    ]
    if c or h:
        parts.append(f"Critical: {c}, High: {h} — review urgently.")
    elif m:
        parts.append(
            f"No critical or high-severity issues were observed in this run. "
            f"{m} medium-severity item(s) should be scheduled for the next change window."
        )
    else:
        parts.append("No critical, high, or medium-severity issues were observed in this run.")
    parts.append(f"Low: {l}, Informational: {i}.")
    return " ".join(parts)


def build_info_nuclei_rows(findings: list, limit: int = 20) -> list[dict]:
    """Tabloid PDF rows for informational Nuclei matches (template detections, headers, etc.)."""
    rows = []
    nuclei_info = [
        f
        for f in sort_findings(findings)
        if f.get("tool") == "nuclei" and (f.get("severity") or "").lower() == "info"
    ]
    for i, f in enumerate(nuclei_info[:limit], start=1):
        ep = str(f.get("endpoint") or "")[:120]
        rows.append(
            {
                "row_id": f"N-{i:02d}",
                "title": str(f.get("title") or "Finding"),
                "subline": ep or "—",
            }
        )
    return rows


def info_rollups(findings: list, resolved: str) -> tuple[str, str]:
    nmap = [f for f in findings if f.get("tool") == "nmap"]
    httpx = [f for f in findings if f.get("tool") == "httpx"]
    nuclei = [f for f in findings if f.get("tool") == "nuclei"]
    zap_info = [f for f in findings if is_info_zap(f)]
    ports = []
    for f in nmap[:20]:
        raw = f.get("raw") or {}
        ports.append(f"{raw.get('service')}/{raw.get('port')}")
    primary = (
        f"{len(nmap)} network observation(s) on {resolved}"
        + (f": {', '.join(ports)}" if ports else "")
        + ("." if not httpx else f" plus httpx ({len(httpx)}).")
    )
    if nuclei:
        primary += f" Nuclei: {len(nuclei)} detection(s)."
    secondary = ""
    if zap_info:
        titles = [str(x.get("title") or "") for x in zap_info[:8]]
        secondary = "Additional ZAP informational items: " + "; ".join(titles)
        if len(zap_info) > 8:
            secondary += f" … (+{len(zap_info) - 8} more)"
    return primary, secondary


def extract_article_wrapper(detailed_html_path: Path) -> tuple[str, str]:
    """Return (prefix up to and including <article class=\"page\">, closing suffix)."""
    text = detailed_html_path.read_text(encoding="utf-8")
    tag = '<article class="page">'
    i0 = text.index(tag) + len(tag)
    prefix = text[:i0]
    suffix = "\n</article>\n\n</body>\n</html>\n"
    return prefix, suffix


def build_email_sections(findings: list) -> list[tuple[str, str, list]]:
    """(severity_key, Title Case label, rows) for critical→low."""
    out = []
    for sev, label in [
        ("critical", "Critical"),
        ("high", "High"),
        ("medium", "Medium"),
        ("low", "Low"),
    ]:
        group = [f for f in sort_findings(findings) if (f.get("severity") or "").lower() == sev]
        rows = []
        ctr = 0
        for f in group:
            ctr += 1
            prefix = {"critical": "C", "high": "H", "medium": "M", "low": "L"}[sev]
            bg, fg = EMAIL_BADGE[sev]
            sub = f"{host_display_from_finding(f)} · {instance_count(f)} instance(s)"
            rows.append(
                {
                    "row_id": f"{prefix}-{ctr:02d}",
                    "title": str(f.get("title") or ""),
                    "subline": sub,
                    "badge_bg": bg,
                    "badge_fg": fg,
                }
            )
        out.append((sev, label, rows))
    return out


def host_display_from_finding(f: dict) -> str:
    ep = f.get("endpoint") or ""
    if ep and "://" in ep:
        return urlparse(ep).hostname or ep
    if ep:
        return ep[:80]
    return "—"


def html_to_pdf_playwright(html_path: Path, pdf_path: Path) -> None:
    """Print HTML to PDF (Chromium). Requires: pip install playwright && playwright install chromium."""
    try:
        from playwright.sync_api import sync_playwright
    except ImportError as e:  # pragma: no cover
        raise RuntimeError(
            "PDF export needs Playwright: pip install playwright && playwright install chromium"
        ) from e
    uri = html_path.resolve().as_uri()
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(uri, wait_until="domcontentloaded", timeout=120_000)
        page.pdf(path=str(pdf_path), format="A4", print_background=True)
        browser.close()


def _smtp_get(primary: str, fallback: str, default: str = "") -> str:
    """Prefer SCAN_REPORT_* env; else generic SMTP_* (e.g. appnotify-style vars)."""
    v = os.environ.get(primary, "").strip()
    if v:
        return v
    return os.environ.get(fallback, default).strip()


def _smtp_connection_params() -> tuple[str, int, str, str]:
    host = _smtp_get("SCAN_REPORT_SMTP_HOST", "SMTP_HOST")
    if not host:
        raise RuntimeError(
            "Set SMTP_HOST or SCAN_REPORT_SMTP_HOST (and SMTP_PORT / SMTP_USER / "
            "SMTP_PASSWORD as needed)."
        )
    try:
        port = int(_smtp_get("SCAN_REPORT_SMTP_PORT", "SMTP_PORT", "465") or "465")
    except ValueError:
        port = 465
    user = _smtp_get("SCAN_REPORT_SMTP_USER", "SMTP_USER")
    password = _smtp_get("SCAN_REPORT_SMTP_PASSWORD", "SMTP_PASSWORD")
    return host, port, user, password


def _smtp_deliver(msg: EmailMessage, *, stage: str = "SMTP") -> None:
    host, port, user, password = _smtp_connection_params()
    to_disp = (msg.get("To") or "").strip() or "?"
    try:
        if port == 465:
            with smtplib.SMTP_SSL(host, port, timeout=60) as smtp:
                if user:
                    smtp.login(user, password)
                refused = smtp.send_message(msg)
        else:
            with smtplib.SMTP(host, port, timeout=60) as smtp:
                smtp.ehlo()
                smtp.starttls()
                smtp.ehlo()
                if user:
                    smtp.login(user, password)
                refused = smtp.send_message(msg)
    except smtplib.SMTPException as e:
        raise RuntimeError(
            f"{stage}: SMTP rejected or protocol error ({host}:{port}, To={to_disp}): {e}"
        ) from e
    except OSError as e:
        raise RuntimeError(
            f"{stage}: cannot reach mail server ({host}:{port}): {e}"
        ) from e
    if refused:
        raise RuntimeError(
            f"{stage}: server refused some recipients {refused!r} (host {host}:{port}, To={to_disp})"
        )
    print(f"[smtp] {stage}: accepted by server → To={to_disp} via {host}:{port}", flush=True)


def send_scan_email(
    *,
    subject: str,
    html_body: str,
    pdf_path: Path | None,
    mail_to: str,
    mail_from: str,
) -> None:
    plain = re.sub(r"<[^>]+>", "", html_body)
    plain = re.sub(r"\s+", " ", plain).strip()[:4000]

    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = mail_from
    msg["To"] = mail_to
    msg["Bcc"] = REPORT_EMAIL_BCC
    msg.set_content(plain or "(HTML only)")
    msg.add_alternative(html_body, subtype="html")

    if pdf_path is not None and pdf_path.is_file():
        msg.add_attachment(
            pdf_path.read_bytes(),
            maintype="application",
            subtype="pdf",
            filename=pdf_path.name,
        )

    _smtp_deliver(msg, stage="Summary report email (PDF to requester)")


def send_full_report_email(
    *,
    mail_from: str,
    full_html_path: Path,
    subject: str,
) -> None:
    """Second message: To jaco only, full technical report HTML attached."""
    if not full_html_path.is_file():
        raise RuntimeError(f"Full report not found: {full_html_path}")

    body = (
        "Attached is the full technical security report (HTML).\n"
        "Open the attachment in a browser; use Print → Save as PDF if you need a PDF.\n"
    )
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = mail_from
    msg["To"] = REPORT_EMAIL_JACO
    msg.set_content(body, subtype="plain", charset="utf-8")
    raw = full_html_path.read_bytes()
    msg.add_attachment(
        raw,
        maintype="text",
        subtype="html",
        filename=full_html_path.name,
    )
    _smtp_deliver(msg, stage="Full technical report email (HTML to internal)")


def send_ops_alert_email(
    *,
    stage: str,
    error_message: str,
    script: str | None = None,
    scan_detail_id: str | None = None,
    target_url: str | None = None,
) -> None:
    """Email ops (default jaco@overdrive.co.za) when a worker step fails."""
    mail_from = mail_from_header()
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
    where = script or stage
    lines = [
        "Scan Shield worker alert",
        "",
        f"When: {ts}",
        f"Where: {stage}",
        f"Script: {where}",
    ]
    if scan_detail_id:
        lines.append(f"Scan row ID: {scan_detail_id}")
    if target_url:
        lines.append(f"Target: {target_url}")
    lines.extend(["", "Details:", (error_message or "").strip()[:6000]])

    msg = EmailMessage()
    msg["Subject"] = f"Scan Shield alert — {stage}"
    msg["From"] = mail_from
    msg["To"] = OPS_ALERT_EMAIL
    msg.set_content("\n".join(lines), subtype="plain", charset="utf-8")
    _smtp_deliver(msg, stage=f"Ops alert ({stage})")


def build_ops_run_summary_body(
    data: dict,
    *,
    outcome: str,
    target_url: str,
    scan_detail_id: str | None = None,
    requester: str | None = None,
    report_path: str | None = None,
    extra_issues: list[str] | None = None,
) -> str:
    """Plain-text ops summary after scan + report pipeline finishes."""
    warnings = [str(w).strip() for w in (data.get("warnings") or []) if str(w).strip()]
    issues = warnings + [str(i).strip() for i in (extra_issues or []) if str(i).strip()]
    findings = data.get("findings") or []
    summary = data.get("summary") or {}
    tools = sorted({str(f.get("tool") or "?") for f in findings})
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
    lines = [
        "Scan Shield — end of run summary",
        "",
        f"Outcome: {outcome}",
        f"When: {ts}",
        f"Target: {target_url}",
    ]
    if scan_detail_id:
        lines.append(f"Scan row ID: {scan_detail_id}")
    if requester:
        lines.append(f"Requester: {requester}")
    if report_path:
        lines.append(f"Report path: {report_path}")
    total = data.get("total_findings", len(findings))
    sev_bits = ", ".join(
        f"{k}={summary.get(k, 0)}"
        for k in ("critical", "high", "medium", "low", "info")
        if summary.get(k, 0)
    ) or "none above info"
    lines.extend(
        [
            "",
            f"Findings: {total} total ({sev_bits})",
            f"Tools with results: {' · '.join(tools) if tools else 'none'}",
        ]
    )
    if "nuclei" not in tools:
        lines.append("Note: no nuclei findings in this run.")
    lines.extend(["", f"Issues ({len(issues)}):"])
    for i, issue in enumerate(issues, start=1):
        lines.append(f"  {i}. {issue}")
    return "\n".join(lines)[:8000]


def send_ops_run_summary(
    data: dict,
    *,
    scan_detail_id: str,
    target_url: str,
    outcome: str,
    requester: str | None = None,
    report_path: str | None = None,
    extra_issues: list[str] | None = None,
    reports_dir: Path | None = None,
) -> None:
    """One consolidated ops email after scan/report work completes."""
    warnings = data.get("warnings") or []
    extras = extra_issues or []
    if not warnings and not extras:
        return
    out_dir: Path | None = None
    if reports_dir is not None and scan_detail_id:
        out_dir = resolve_report_out_dir(reports_dir, scan_detail_id=scan_detail_id)
        if ops_summary_already_sent(out_dir):
            print(f"[skip] ops summary already sent for row {scan_detail_id}", flush=True)
            return
    try:
        host = urlparse(target_url).netloc or target_url
        issue_count = len(warnings) + len(extras)
        body = build_ops_run_summary_body(
            data,
            outcome=outcome,
            target_url=target_url,
            scan_detail_id=scan_detail_id,
            requester=requester,
            report_path=report_path,
            extra_issues=extras,
        )
        mail_from = mail_from_header()
        msg = EmailMessage()
        msg["Subject"] = f"Scan Shield summary — {host} — {issue_count} issue(s)"
        msg["From"] = mail_from
        msg["To"] = OPS_ALERT_EMAIL
        msg.set_content(body, subtype="plain", charset="utf-8")
        _smtp_deliver(msg, stage=f"Ops run summary ({host})")
        if out_dir is not None:
            mark_ops_summary_sent(out_dir)
    except Exception as e:
        print(f"[ops summary] failed to email {OPS_ALERT_EMAIL}: {e}", flush=True)


def notify_ops_error(
    stage: str,
    error_message: str,
    *,
    script: str | None = None,
    scan_detail_id: str | None = None,
    target_url: str | None = None,
) -> None:
    """Best-effort ops email; never raises (alert must not mask the original failure)."""
    try:
        send_ops_alert_email(
            stage=stage,
            error_message=error_message,
            script=script,
            scan_detail_id=scan_detail_id,
            target_url=target_url,
        )
    except Exception as e:
        print(f"[ops alert] failed to email {OPS_ALERT_EMAIL}: {e}", flush=True)


def resolve_requester_name(explicit: str | None, data: dict) -> str | None:
    """Prefer explicit queue value, then scan JSON, then SCAN_REPORT_REQUESTER_NAME env."""
    for src in (explicit, data.get("requester_name"), os.environ.get("SCAN_REPORT_REQUESTER_NAME")):
        if src and str(src).strip():
            return str(src).strip()
    return None


def resolve_report_out_dir(
    reports_dir: Path,
    scan_detail_id: str | None = None,
    explicit_out_dir: Path | None = None,
) -> Path:
    """Queue scans use Reports/generated/{scan_detail_id}/; ad-hoc runs use Reports/generated/."""
    if explicit_out_dir is not None:
        return explicit_out_dir
    base = reports_dir / "generated"
    sid = (scan_detail_id or os.environ.get("SCAN_REPORT_SCAN_ID") or "").strip()
    if sid:
        return base / sid
    return base


def report_path_for_db(reports_dir: Path, out_dir: Path) -> str:
    """Path relative to project root for scan_details.report_path (forward slashes)."""
    root = reports_dir.parent
    try:
        rel = out_dir.relative_to(root)
    except ValueError:
        rel = out_dir
    return str(rel).replace("\\", "/")


def write_report_bundle(
    out_dir: Path,
    scan_detail_id: str | None,
    data: dict,
    paths: dict,
    pdf_path: Path | None,
) -> None:
    """Persist scan JSON + manifest alongside rendered HTML/PDF for later retrieval."""
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "scan.json").write_text(
        json.dumps(data, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    manifest = {
        "scan_detail_id": scan_detail_id,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "stem": paths.get("stem"),
        "files": {
            "email_html": paths["email"].name,
            "full_html": paths["full"].name,
            "email_body_html": paths["email_body"].name,
            "pdf": pdf_path.name if pdf_path and pdf_path.is_file() else None,
            "scan_json": "scan.json",
        },
    }
    (out_dir / "manifest.json").write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def emails_sent_marker(out_dir: Path) -> Path:
    return out_dir / ".emails_sent"


def emails_already_sent(out_dir: Path) -> bool:
    return emails_sent_marker(out_dir).is_file()


def mark_emails_sent(out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    emails_sent_marker(out_dir).write_text(
        datetime.now(timezone.utc).isoformat(),
        encoding="utf-8",
    )


def ops_summary_marker(out_dir: Path) -> Path:
    return out_dir / ".ops_summary_sent"


def ops_summary_already_sent(out_dir: Path) -> bool:
    return ops_summary_marker(out_dir).is_file()


def mark_ops_summary_sent(out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    ops_summary_marker(out_dir).write_text(
        datetime.now(timezone.utc).isoformat(),
        encoding="utf-8",
    )


def render_reports(
    data: dict,
    reports_dir: Path,
    out_dir: Path,
    base_name: str | None = None,
    requester_name: str | None = None,
) -> dict:
    target = str(data.get("target") or "")
    scan_date = str(data.get("scan_date") or datetime.now(timezone.utc).isoformat())
    scan_id = str(data.get("id") or "")
    findings = list(data.get("findings") or [])
    summary = data.get("summary") or {}
    total = int(data.get("total_findings") or len(findings))

    dt = _parse_iso(scan_date)
    scan_date_short = dt.strftime("%Y-%m-%d")
    scan_date_utc = dt.strftime("%Y-%m-%d %H:%M UTC")
    scan_time_utc = dt.strftime("%H:%M UTC")
    scan_window = dt.strftime("%Y-%m-%d · %H:%M:%S UTC")
    scan_id_short = scan_id.split("-")[0] if scan_id else "—"

    host = host_display(target)
    rip = resolved_ip(findings, target)
    target_name_display = friendly_target_name(data, target)
    brand = report_brand_name()
    wordmark_line = f"{brand} · Security Report"
    target_name_banner = brand.upper()
    sorted_f = sort_findings(findings)
    detail_src = [f for f in sorted_f if is_detail_finding(f)]
    counters: dict[str, int] = {}
    detail_findings = []
    for f in detail_src:
        sev = (f.get("severity") or "info").lower()
        detail_findings.append(build_detail_row(f, next_display_id(sev, counters)))

    top_rows = []
    for f in detail_src[:25]:
        sev = (f.get("severity") or "info").lower()
        top_rows.append(
            {
                "pill": PILL_CLASS.get(sev, "low"),
                "severity_label": sev.title(),
                "title": str(f.get("title") or ""),
                "affected": (f.get("endpoint") or host)[:200],
                "instances": instance_count(f),
            }
        )

    env = Environment(
        loader=FileSystemLoader(str(reports_dir)),
        autoescape=select_autoescape(["html", "xml"]),
    )
    email_t = env.get_template("email_html.jinja2")
    body_t = env.get_template("email_body.jinja2")
    full_t = env.get_template("full_vulnerable_reports.jinja2")

    stem = base_name or file_stem_from_data(data, scan_date)
    out_dir.mkdir(parents=True, exist_ok=True)
    email_path = out_dir / f"{stem}_email.html"
    full_path = out_dir / f"{stem}_full.html"
    email_body_path = out_dir / f"{stem}_email_body.html"

    primary, secondary = info_rollups(findings, rip)
    brand_ctx = report_logo_context(reports_dir)
    email_html = email_t.render(
        host_display=host,
        target=target,
        resolved_ip=rip,
        wordmark_line=wordmark_line,
        **brand_ctx,
        target_name_display=target_name_display,
        scan_date_short=scan_date_short,
        scan_time_utc=scan_time_utc,
        scan_id_short=scan_id_short,
        tools_line=tools_line(findings),
        counts=summary,
        total_findings=total,
        summary_paragraph=summary_email_text(summary, total),
        severity_sections=build_email_sections(findings),
        info_rollup_primary=primary,
        info_rollup_secondary=secondary,
        info_nuclei_rows=build_info_nuclei_rows(findings),
        report_brand_name=brand,
    )

    cta_url = os.environ.get(
        "SCAN_REPORT_CTA_URL",
        "https://outlook.office.com/bookwithme/user/0f60b3ac08c540859e3cc370516b2644@overdrive.co.za/meetingtype/-Jy928dHJUiaKRZMLIhxqQ2?anonymous&ep=mcard",
    ).strip()
    cta_label = os.environ.get("SCAN_REPORT_CTA_LABEL", "Schedule a call →").strip()
    signoff_name = os.environ.get("SCAN_REPORT_SIGNOFF_NAME", "The Silicon Overdrive Security Team").strip()
    signoff_email = os.environ.get("SCAN_REPORT_SIGNOFF_EMAIL", "security@overdrive.co.za").strip()
    cta_lede = os.environ.get(
        "SCAN_REPORT_CTA_LEDE",
        "Book a short slot if you want to walk through priorities or remediation.",
    ).strip()

    email_body_html = body_t.render(
        target_name_display=target_name_display,
        wordmark_line=wordmark_line,
        **brand_ctx,
        host_display=host,
        target=target,
        scan_date_short=scan_date_short,
        scan_id_short=scan_id_short,
        counts=summary,
        total_findings=total,
        hook_line=hook_line(summary),
        top_mediums=collect_top_mediums(findings),
        cta_url=cta_url,
        cta_label=cta_label,
        cta_lede=cta_lede,
        signoff_name=signoff_name,
        signoff_email=signoff_email,
        requester_name=resolve_requester_name(requester_name, data),
    )

    detailed_path = reports_dir / "Detailed Report.html"
    prefix, suffix = extract_article_wrapper(detailed_path)
    full_inner = full_t.render(
        **brand_ctx,
        target_name_banner=target_name_banner,
        scan_id=scan_id,
        scan_id_short=scan_id_short,
        scan_date_utc=scan_date_utc,
        scan_date_short=scan_date_short,
        scan_window=scan_window,
        host_display=host,
        target=target,
        resolved_ip=rip,
        total_findings=total,
        counts=summary,
        tools_line=tools_line(findings),
        top_rows=top_rows,
        detail_findings=detail_findings,
        network_rows=build_network_rows(findings),
        info_zap_rows=build_info_zap_rows(findings, target),
    )
    full_html = prefix + "\n" + full_inner + suffix

    email_path.write_text(email_html, encoding="utf-8")
    full_path.write_text(full_html, encoding="utf-8")
    email_body_path.write_text(email_body_html, encoding="utf-8")

    return {
        "stem": stem,
        "email": email_path,
        "full": full_path,
        "email_body": email_body_path,
    }


def main() -> None:
    ap = argparse.ArgumentParser(
        description="Build email + full HTML reports from scan JSON (e.g. update.json)."
    )
    ap.add_argument(
        "--input",
        "-i",
        default="update.json",
        help="Path to scan JSON (default: update.json next to this script)",
    )
    ap.add_argument(
        "--reports-dir",
        default=None,
        help="Directory containing email_html.jinja2, full_vulnerable_reports.jinja2, Detailed Report.html",
    )
    ap.add_argument(
        "--out-dir",
        "-o",
        default=None,
        help="Output directory (default: Reports/generated next to reports dir)",
    )
    ap.add_argument(
        "--name",
        default=None,
        help="Override output filename stem (default: {TargetNameSlug}_{YYYY-MM-DD} from JSON)",
    )
    ap.add_argument(
        "--pdf",
        action="store_true",
        help="Write {stem}.pdf from the tabloid email HTML (_email.html) via Playwright.",
    )
    ap.add_argument(
        "--send-email",
        action="store_true",
        help="Send summary email then full HTML report to jaco@overdrive.co.za (see docstring).",
    )
    ap.add_argument(
        "--scan-id",
        default=None,
        help="scan_details row UUID — writes to Reports/generated/{scan-id}/ and manifest.json",
    )
    args = ap.parse_args()

    root = Path(__file__).resolve().parent
    load_env_files(Path(__file__))
    log_smtp_config()
    in_path = Path(args.input)
    if not in_path.is_absolute():
        in_path = root / in_path

    reports_dir = Path(args.reports_dir) if args.reports_dir else root / "Reports"
    scan_id = (args.scan_id or os.environ.get("SCAN_REPORT_SCAN_ID") or "").strip() or None
    if args.out_dir:
        out_dir = Path(args.out_dir)
    else:
        out_dir = resolve_report_out_dir(reports_dir, scan_detail_id=scan_id)

    data = json.loads(in_path.read_text(encoding="utf-8"))
    env_name = (os.environ.get("SCAN_REPORT_REQUESTER_NAME") or "").strip() or None
    paths = render_reports(
        data,
        reports_dir,
        out_dir,
        base_name=args.name,
        requester_name=env_name,
    )
    print(
        "Wrote:\n"
        f"  {paths['email']}\n"
        f"  {paths['full']}\n"
        f"  {paths['email_body']}"
    )

    pdf_path: Path | None = None
    if args.pdf or args.send_email:
        pdf_path = out_dir / f"{paths['stem']}.pdf"
        html_to_pdf_playwright(paths["email"], pdf_path)
        print(f"  {pdf_path}")

    if scan_id:
        write_report_bundle(out_dir, scan_id, data, paths, pdf_path)
        db_path = report_path_for_db(reports_dir, out_dir)
        print(f"  {out_dir / 'manifest.json'}")
        print(f"  {out_dir / 'scan.json'}")
        print(f"report_path={db_path}")

    if args.send_email:
        mail_to = os.environ.get("SCAN_REPORT_EMAIL_TO", "jaco@overdrive.co.za").strip()
        mail_from = mail_from_header()
        tn = friendly_target_name(data, str(data.get("target") or ""))
        sd = _parse_iso(
            str(data.get("scan_date") or datetime.now(timezone.utc).isoformat())
        ).strftime("%Y-%m-%d")
        if emails_already_sent(out_dir):
            print(f"[skip] report emails already sent ({out_dir})")
        else:
            subject = os.environ.get(
                "SCAN_REPORT_EMAIL_SUBJECT",
                f"Security scan — {tn} — {sd}",
            )
            send_scan_email(
                subject=subject,
                html_body=paths["email_body"].read_text(encoding="utf-8"),
                pdf_path=pdf_path,
                mail_to=mail_to,
                mail_from=mail_from,
            )
            print(f"Emailed {mail_to} (subject: {subject})")

            full_subject = os.environ.get(
                "SCAN_REPORT_FULL_EMAIL_SUBJECT",
                f"Full security report — {tn} — {sd}",
            )
            send_full_report_email(
                mail_from=mail_from,
                full_html_path=paths["full"],
                subject=full_subject,
            )
            print(f"Emailed full report to {REPORT_EMAIL_JACO} (subject: {full_subject})")
            mark_emails_sent(out_dir)


if __name__ == "__main__":
    main()
