"""Markdown report renderer for a forensic case.

Assembles the case metadata + every analyzer's output into a
self-contained `.md` file. Sections appear in this order:

1. Case overview (name, VM, sample, timestamps, host info).
2. Isolation snapshot (Phase 16's IsolationReport, if present).
3. Network summary (top-talkers, protocol counts, DNS, SNI, HTTP).
4. Filesystem diff (added/modified/deleted counts + truncated lists).
5. Memory dump (path + size + hash).
6. Screenshots (one image link per artifact).
7. IOC hits (YARA matches if present; per-file hashes).
8. Timeline (cross-source merged event log, optional cap).

Per FORENSICS.md F-RPT-1, the Markdown report is MUST-tier in v1.
HTML / PDF live at FN-A5.2 / F-RPT-3 respectively (Phase 17-B/D).
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from mgf.vm.forensics.case import ArtifactKind

if TYPE_CHECKING:
    from collections.abc import Sequence
    from datetime import datetime

    from mgf.vm.forensics.analysis import (
        NetworkAnalysis,
        TimelineEvent,
    )
    from mgf.vm.forensics.analysis.filesystem import DiffRenderRow, DiffSummary
    from mgf.vm.forensics.analysis.ioc import FileHashes, YaraMatch
    from mgf.vm.forensics.analysis.network import (
        DnsQuery,
        HttpRequest,
        TalkerPair,
        TlsSni,
    )
    from mgf.vm.forensics.case import Case


__all__ = [
    "ReportInputs",
    "render_markdown_report",
]


# ---------------------------------------------------------------------------
# Inputs container — everything the renderer needs.
# ---------------------------------------------------------------------------


class ReportInputs:
    """Bundle of pre-computed analyzer outputs for one case.

    Construct with whatever analyzers you've run; missing inputs
    surface as "(not captured)" sections in the rendered report
    rather than raising.
    """

    def __init__(
        self,
        case: Case,
        *,
        network: NetworkAnalysis | None = None,
        fs_summary: DiffSummary | None = None,
        fs_rows: Sequence[DiffRenderRow] = (),
        ioc_hashes: Sequence[FileHashes] = (),
        ioc_yara: Sequence[YaraMatch] = (),
        timeline: Sequence[TimelineEvent] = (),
    ) -> None:
        self.case = case
        self.network = network
        self.fs_summary = fs_summary
        self.fs_rows = fs_rows
        self.ioc_hashes = ioc_hashes
        self.ioc_yara = ioc_yara
        self.timeline = timeline


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def render_markdown_report(
    inputs: ReportInputs,
    *,
    fs_row_cap: int = 200,
    timeline_cap: int = 500,
) -> str:
    """Render a complete Markdown report. Returns the document body."""
    parts: list[str] = []
    parts.append(_render_overview(inputs.case))
    parts.append(_render_isolation_snapshot(inputs.case))
    parts.append(_render_network_section(inputs.network))
    parts.append(_render_filesystem_section(inputs.fs_summary, inputs.fs_rows, cap=fs_row_cap))
    parts.append(_render_memory_section(inputs.case))
    parts.append(_render_screenshots_section(inputs.case))
    parts.append(_render_ioc_section(inputs.ioc_hashes, inputs.ioc_yara))
    parts.append(_render_timeline_section(inputs.timeline, cap=timeline_cap))
    parts.append(_render_footer())
    return "\n\n".join(p for p in parts if p)


# ---------------------------------------------------------------------------
# Section renderers
# ---------------------------------------------------------------------------


def _render_overview(case: Case) -> str:
    sample_line = (
        f"- **Sample:** `{case.sample_path}` (sha256 `{case.sample_sha256[:16]}…`)"
        if case.sample_path
        else "- **Sample:** _(none recorded)_"
    )
    completed_line = (
        f"- **Completed:** {_iso(case.completed_at)}"
        if case.completed_at
        else "- **Completed:** _(in progress)_"
    )
    return (
        f"# Forensics Report: {case.name}\n\n"
        f"_Generated by VManager Phase 17 (case schema v{case.schema_version})_\n\n"
        f"## 1. Case Overview\n\n"
        f"- **Case ID:** `{case.case_id}`\n"
        f"- **Quarantine VM:** `{case.vm_name}`\n"
        f"{sample_line}\n"
        f"- **Status:** `{case.status.value}`\n"
        f"- **Created:** {_iso(case.created_at)}\n"
        f"{completed_line}\n"
        f"- **VManager:** `{case.vmanager_version or '?'}`  "
        f"**QEMU:** `{case.qemu_version or '?'}`  "
        f"**Kernel:** `{case.host_kernel or '?'}`\n"
        f"- **Description:** {case.description or '_(none)_'}\n"
        f"- **Total artifacts:** {len(case.artifacts)} "
        f"({_format_bytes(case.total_size_bytes())})\n"
    )


def _render_isolation_snapshot(case: Case) -> str:
    if case.isolation_report is None:
        return (
            "## 2. Isolation Snapshot (Phase 16)\n\n_(no IsolationReport recorded for this case)_"
        )
    s = case.isolation_report.summary
    lines = [
        "## 2. Isolation Snapshot (Phase 16)",
        "",
        f"VM declared `{case.isolation_report.declared_level.value}` at case creation.",
        "",
        "| Severity | Failing | Total |",
        "|---|---:|---:|",
        f"| 🔴 BLOCKING | {s.blocking_failing} | {s.blocking_total} |",
        f"| 🟡 ADVISORY | {s.advisory_failing} | {s.advisory_total} |",
        f"| [i] INFO    | {s.info_failing} | {s.info_total} |",
        "",
        f"`is_clean()` = **{s.is_clean()}** — "
        f"{'safe to run as a malware-analysis VM' if s.is_clean() else 'contains BLOCKING leakage paths'}.",  # noqa: E501
    ]
    return "\n".join(lines)


def _render_network_section(network: NetworkAnalysis | None) -> str:
    if network is None:
        return "## 3. Network Capture\n\n_(no network capture analyzed)_"
    lines = [
        "## 3. Network Capture",
        "",
        f"- **Source pcap:** `{network.pcap_path}`",
        f"- **Packets:** {network.packet_count:,}  "
        f"**Bytes:** {_format_bytes(network.bytes_total)}  "
        f"**Duration:** {network.duration_s:.1f}s",
        f"- **Time range:** {_iso(network.started_at)} → {_iso(network.ended_at)}",
        f"- **BPF filter:** `{network.bpf_filter or '(none — captured everything)'}`",
        "",
        "### 3.1 Protocol Breakdown",
        "",
        "| Layer | Count |",
        "|---|---:|",
        f"| IPv4 | {network.protocols.ipv4:,} |",
        f"| IPv6 | {network.protocols.ipv6:,} |",
        f"| ARP  | {network.protocols.arp:,} |",
        f"| TCP  | {network.protocols.tcp:,} |",
        f"| UDP  | {network.protocols.udp:,} |",
        f"| ICMP | {network.protocols.icmp:,} |",
        f"| other L3 | {network.protocols.other_l3:,} |",
        f"| other L4 | {network.protocols.other_l4:,} |",
    ]
    lines.append("")
    lines.extend(_render_top_talkers(network.top_talkers))
    lines.append("")
    lines.extend(_render_dns_queries(network.dns_queries))
    lines.append("")
    lines.extend(_render_tls_snis(network.tls_snis))
    lines.append("")
    lines.extend(_render_http_requests(network.http_requests))
    if network.errors:
        lines.append("")
        lines.append("### 3.6 Parse Errors")
        lines.append("")
        for err in network.errors[:20]:
            lines.append(f"- {err}")
        if len(network.errors) > 20:
            lines.append(f"- _(plus {len(network.errors) - 20} more, truncated)_")
    return "\n".join(lines)


def _render_top_talkers(talkers: Sequence[TalkerPair]) -> list[str]:
    if not talkers:
        return ["### 3.2 Top Talkers", "", "_(no IP traffic observed)_"]
    out = ["### 3.2 Top Talkers", "", "| Src | Dst | Bytes | Packets |", "|---|---|---:|---:|"]
    for t in talkers:
        out.append(
            f"| `{_cell(t.src)}` | `{_cell(t.dst)}` | {_format_bytes(t.bytes)} | {t.packets:,} |"
        )
    return out


def _render_dns_queries(queries: Sequence[DnsQuery]) -> list[str]:
    if not queries:
        return ["### 3.3 DNS Queries", "", "_(none observed)_"]
    out = ["### 3.3 DNS Queries", "", "| Name | Type | First seen |", "|---|---|---|"]
    for q in queries:
        out.append(f"| `{_cell(q.name)}` | {q.qtype} | {_iso(q.first_seen_at)} |")
    return out


def _render_tls_snis(snis: Sequence[TlsSni]) -> list[str]:
    if not snis:
        return ["### 3.4 TLS SNI Hostnames", "", "_(no TLS handshakes parsed)_"]
    out = [
        "### 3.4 TLS SNI Hostnames",
        "",
        "| Hostname | First seen |",
        "|---|---|",
    ]
    for s in snis:
        out.append(f"| `{_cell(s.hostname)}` | {_iso(s.first_seen_at)} |")
    return out


def _render_http_requests(requests: Sequence[HttpRequest]) -> list[str]:
    if not requests:
        return ["### 3.5 HTTP Requests (plaintext)", "", "_(none observed)_"]
    out = [
        "### 3.5 HTTP Requests (plaintext)",
        "",
        "| Method | Host | Path | Seen |",
        "|---|---|---|---|",
    ]
    for r in requests[:200]:
        out.append(
            f"| {_cell(r.method)} | `{_cell(r.host)}` | `{_cell(r.path)}` | {_iso(r.seen_at)} |",
        )
    if len(requests) > 200:
        out.append(f"_(plus {len(requests) - 200} more, truncated)_")
    return out


def _render_filesystem_section(
    summary: DiffSummary | None,
    rows: Sequence[DiffRenderRow],
    *,
    cap: int,
) -> str:
    if summary is None:
        return "## 4. Filesystem Changes\n\n_(no filesystem diff captured)_"
    header = [
        "## 4. Filesystem Changes",
        "",
        f"- **Baseline:** {_iso(summary.baseline_captured_at)}  "
        f"**Diff:** {_iso(summary.captured_at)}",
        f"- **VM state at diff:** `{summary.vm_state}`  **Disk view:** `{summary.disk_view}`",
        f"- **Added:** {summary.added}  **Modified:** {summary.modified}  "
        f"**Deleted:** {summary.deleted}  **Total changes:** {summary.total_changes}",
        "",
        "| Change | Path | Size | sha256 |",
        "|---|---|---:|---|",
    ]
    body: list[str] = []
    for r in rows[:cap]:
        size_cell = _format_bytes(r.size_bytes) if r.size_bytes else "-"
        sha_cell = r.sha256[:16] + "…" if r.sha256 else ""
        body.append(f"| {r.change} | `{_cell(r.path)}` | {size_cell} | `{sha_cell}` |")
    if len(rows) > cap:
        body.append(f"\n_(plus {len(rows) - cap} more rows, truncated)_")
    return "\n".join([*header, *body])


def _render_memory_section(case: Case) -> str:
    dumps = [a for a in case.artifacts if a.kind is ArtifactKind.MEMORY_DUMP]
    if not dumps:
        return "## 5. Memory Dump\n\n_(none captured)_"
    out = ["## 5. Memory Dump", ""]
    for a in dumps:
        out.append(
            f"- `{_cell(a.path.name)}` — {_format_bytes(a.size_bytes)} — "
            f"sha256 `{a.sha256[:16]}…` — captured {_iso(a.captured_at)}",
        )
    return "\n".join(out)


def _render_screenshots_section(case: Case) -> str:
    shots = [a for a in case.artifacts if a.kind is ArtifactKind.SCREENSHOT]
    if not shots:
        return "## 6. Screenshots\n\n_(none captured)_"
    out = ["## 6. Screenshots", ""]
    for a in shots:
        # Markdown image syntax — relative paths assume the report
        # lives in <case_dir>/reports/.
        rel = f"../screenshots/{a.path.name}"
        out.append(f"![{a.path.name}]({rel})")
        out.append(f"_{_iso(a.captured_at)} — {_format_bytes(a.size_bytes)}_")
        out.append("")
    return "\n".join(out)


def _render_ioc_section(
    hashes: Sequence[FileHashes],
    yara: Sequence[YaraMatch],
) -> str:
    if not hashes and not yara:
        return "## 7. IOC Analysis\n\n_(no IOC analysis run)_"
    out = ["## 7. IOC Analysis", ""]
    if hashes:
        out.extend(
            [
                "### 7.1 File Hashes",
                "",
                "| File | Size | MD5 | SHA-1 | SHA-256 |",
                "|---|---:|---|---|---|",
            ],
        )
        for h in hashes:
            out.append(
                f"| `{_cell(h.file)}` | {_format_bytes(h.size_bytes)} | "
                f"`{h.md5[:16]}…` | `{h.sha1[:16]}…` | `{h.sha256[:16]}…` |",
            )
        out.append("")
    if yara:
        out.extend(
            [
                "### 7.2 YARA Matches",
                "",
                "| File | Rule | Namespace | Tags |",
                "|---|---|---|---|",
            ],
        )
        for m in yara:
            tags = ", ".join(m.tags) if m.tags else ""
            out.append(
                f"| `{_cell(m.file)}` | `{_cell(m.rule)}` | "
                f"`{_cell(m.namespace)}` | {_cell(tags)} |",
            )
    elif hashes:
        out.extend(
            [
                "### 7.2 YARA Matches",
                "",
                "_(no YARA matches — either no rules configured or no hits)_",
            ],
        )
    return "\n".join(out)


def _render_timeline_section(
    events: Sequence[TimelineEvent],
    *,
    cap: int,
) -> str:
    if not events:
        return "## 8. Timeline\n\n_(no events recorded)_"
    out = [
        "## 8. Timeline",
        "",
        "| When | Source | Summary |",
        "|---|---|---|",
    ]
    for e in events[:cap]:
        out.append(
            f"| {_iso(e.occurred_at)} | `{_cell(e.source)}` | {_cell(e.summary)} |",
        )
    if len(events) > cap:
        out.append(f"\n_(plus {len(events) - cap} more events, truncated)_")
    return "\n".join(out)


def _render_footer() -> str:
    return (
        "---\n\n"
        "_Report generated by VManager. Artifacts hashed at write time; "
        "verify with `vmanager forensics case show <name> --verify`._"
    )


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


def _cell(value: str) -> str:
    """Neutralise hostile-evidence strings before embedding in a report.

    Forensic reports render attacker-controlled data — filenames on the
    compromised guest, DNS/SNI/HTTP fields from captured packets — into
    Markdown tables. Unescaped, a value with a ``|`` corrupts the table
    (misleading evidence) and a value with ``<script>`` / ``](javascript:``
    injects HTML/links that execute when the analyst opens the report in a
    browser or Markdown preview (F-VF-001). Escape the table-structural
    pipe + backtick, collapse newlines, and HTML-escape angle brackets so
    the value is always inert literal text.
    """
    return (
        value.replace("\\", "\\\\")
        .replace("`", "\\`")
        .replace("|", "\\|")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\r", " ")
        .replace("\n", " ")
    )


def _iso(value: datetime | None) -> str:
    if value is None:
        return "_(unknown)_"
    return value.isoformat(timespec="seconds")


def _format_bytes(n: int) -> str:
    """Human-readable byte size (KB / MB / GB)."""
    units = ["B", "KB", "MB", "GB", "TB"]
    size = float(n)
    for unit in units:
        if size < 1024:
            return f"{size:,.1f} {unit}" if size != int(size) else f"{int(size):,} {unit}"
        size /= 1024
    return f"{size:,.1f} PB"
