#!/usr/bin/env python3
"""
HLDS/LG Stage 3 conservative router v4.

Stage 3 consumes Stage 2 normalized payloads and performs conservative
pre-analysis / routing only.

It intentionally does NOT:
    * decrypt
    * disassemble
    * perform MN103S opcode-density scoring
    * search for command handlers, ATAPI handlers, flash routines, etc.
    * validate checksum/footer algorithms
    * generate final fleet profiles

Locked project rules:
    * stubs are never encrypted
    * stubs are never routed to the decryptor
    * DLD containers are expected to contain header + firmware + stub
    * no-DLD/raw payloads may be firmware+stub pairs, firmware-only, encrypted/newstyle, or review-only
    * missing stub is not negative evidence
    * GDR-3120L is a validation reference, not a recipient
    * raw/no-DLD firmware must be decrypted when byte evidence says it is not plaintext
    * optional raw/no-DLD review decrypt probes are controlled by --probe-raw-review-decrypt

Stage 4 consumes this script's decrypt candidate list and attempts firmware
decryption only.
"""

from __future__ import annotations

import argparse
import csv
import datetime as _dt
import hashlib
import json
import math
import os
import re
import sys
from collections import Counter
from pathlib import Path, PureWindowsPath
from typing import Any, Dict, List, Optional, Tuple


PRODUCER = "hlds_stage3_router_v4"

KNOWN_SIZE_FAMILIES = {
    # DLD stub sizes
    0x09F8: "old_dld_stub_0x09f8",
    0x0FF0: "old_dld_stub_0x0ff0",
    0x13E8: "old_dld_stub_0x13e8",
    0x17E8: "old_dld_stub_0x17e8",
    0x1BE8: "old_dld_stub_0x1be8",

    # PRM/raw stub sizes
    0x4400: "prm_stub_0x4400",
    0x6400: "prm_stub_0x6400",

    # Old DLD firmware sizes
    0x31000: "old_dld_firmware_0x31000",
    0x40000: "old_dld_firmware_0x40000",
    0x60000: "old_dld_firmware_0x60000",
    0x80000: "old_dld_firmware_0x80000",

    # Raw/PRM firmware sizes
    0x1A0400: "prm_firmware_0x1a0400",
    0x1C0400: "prm_firmware_0x1c0400",
    0x1E0400: "prm_firmware_0x1e0400",

    # Large raw/newstyle-ish sizes observed in the corpus
    0x1E0004: "large_raw_or_newstyle_0x1e0004",
    0x200000: "large_raw_0x200000",
    0x200040: "large_raw_or_newstyle_0x200040",
}


def utc_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def rel_to_path(workspace: Path, rel_or_abs: str) -> Path:
    if not rel_or_abs:
        return workspace

    p_native = Path(rel_or_abs)
    if p_native.is_absolute():
        return p_native

    p_win = PureWindowsPath(rel_or_abs)
    if p_win.is_absolute():
        return Path(str(p_win))

    return workspace.joinpath(*p_win.parts)


def relpath_str(path: Path, workspace: Path) -> str:
    try:
        rel = path.resolve().relative_to(workspace.resolve())
    except Exception:
        try:
            rel = Path(os.path.relpath(path, workspace))
        except Exception:
            rel = path
    return str(rel).replace("/", "\\")


def entropy(data: bytes) -> float:
    if not data:
        return 0.0
    counts = Counter(data)
    n = len(data)
    return -sum((c / n) * math.log2(c / n) for c in counts.values())


def ratio(n: int, d: int) -> float:
    return float(n) / float(d) if d else 0.0


def printable_ascii_ratio(data: bytes) -> float:
    if not data:
        return 0.0
    printable = sum(1 for b in data if b in (9, 10, 13) or 32 <= b <= 126)
    return printable / len(data)


def ascii_string_count(data: bytes, min_len: int = 6, max_scan: int = 2 * 1024 * 1024) -> int:
    scan = data[:max_scan]
    count = 0
    run = 0
    for b in scan:
        if 32 <= b <= 126:
            run += 1
        else:
            if run >= min_len:
                count += 1
            run = 0
    if run >= min_len:
        count += 1
    return count


def longest_run(data: bytes, value: int, max_scan: int = 4 * 1024 * 1024) -> int:
    scan = data[:max_scan]
    best = 0
    cur = 0
    for b in scan:
        if b == value:
            cur += 1
            if cur > best:
                best = cur
        else:
            cur = 0
    return best


def leading_run(data: bytes, value: int) -> int:
    n = 0
    for b in data:
        if b == value:
            n += 1
        else:
            break
    return n


def trailing_run(data: bytes, value: int) -> int:
    n = 0
    for b in reversed(data):
        if b == value:
            n += 1
        else:
            break
    return n


def basic_byte_profile(data: bytes) -> Dict[str, Any]:
    n = len(data)
    if not data:
        return {
            "size": 0,
            "entropy": 0.0,
            "unique_byte_count": 0,
            "zero_ratio": 0.0,
            "ff_ratio": 0.0,
            "printable_ascii_ratio": 0.0,
            "ascii_string_count_min6": 0,
            "longest_zero_run": 0,
            "longest_ff_run": 0,
            "leading_zero_run": 0,
            "leading_ff_run": 0,
            "trailing_zero_run": 0,
            "trailing_ff_run": 0,
            "first_32_hex": "",
            "known_strings": [],
            "known_string_count": 0,
            "last_32_hex": "",
        }

    counts = Counter(data)
    sample = data[:min(n, 2 * 1024 * 1024)]
    known = []
    for needle in (b"HL-DT-ST", b"HL-DL-ST", b"DVD-ROM", b"CD-RW", b"RW/DVD", b"BD-ROM", b"GCC-", b"GDR-", b"GSA-", b"FLASH", b"BOOT"):
        if needle in sample:
            known.append(needle.decode("ascii", errors="replace"))

    return {
        "size": n,
        "entropy": round(entropy(data), 4),
        "unique_byte_count": len(counts),
        "zero_ratio": round(ratio(counts.get(0x00, 0), n), 6),
        "ff_ratio": round(ratio(counts.get(0xFF, 0), n), 6),
        "printable_ascii_ratio": round(printable_ascii_ratio(data), 6),
        "ascii_string_count_min6": ascii_string_count(data, 6),
        "longest_zero_run": longest_run(data, 0x00),
        "longest_ff_run": longest_run(data, 0xFF),
        "leading_zero_run": leading_run(data, 0x00),
        "leading_ff_run": leading_run(data, 0xFF),
        "trailing_zero_run": trailing_run(data, 0x00),
        "trailing_ff_run": trailing_run(data, 0xFF),
        "known_strings": known,
        "known_string_count": len(known),
        "first_32_hex": data[:32].hex(),
        "last_32_hex": data[-32:].hex() if n >= 32 else data.hex(),
    }



def identity_hint_from_record(rec: Dict[str, Any]) -> Dict[str, Any]:
    source = rec.get("source", {}) or {}
    return source.get("identity_hint", {}) or {}


def model_version_from_record(rec: Dict[str, Any]) -> Tuple[str, str]:
    ident = identity_hint_from_record(rec)
    model = str(ident.get("model") or "UNKNOWN")
    version = str(ident.get("version") or "UNK")
    return model, version


def compact_model_text(rec: Dict[str, Any]) -> str:
    model, version = model_version_from_record(rec)
    text = " ".join([
        model,
        version,
        str(rec.get("normalized_rel", "")),
        str(rec.get("source", {})),
    ]).upper()
    return text.replace("-", "").replace("_", "")


def target_scope_for_record(rec: Dict[str, Any]) -> str:
    text = compact_model_text(rec)
    if "GDR8050L" in text:
        return "donor_xbox"
    if "GDR3120L" in text:
        return "validation_reference"
    return "recipient_candidate"


def is_validation_reference_record(rec: Dict[str, Any]) -> bool:
    return target_scope_for_record(rec) == "validation_reference"


def is_large_raw_like(size: int) -> bool:
    # Raw/no-DLD firmware can be firmware-only and does not require a stub.
    # Keep this intentionally broad; Stage 5B/6 will validate usefulness.
    return size >= 0x20000


def is_stub_sized_raw(size: int, sizefam: Dict[str, Any]) -> bool:
    family = str(sizefam.get("family", ""))
    if family.startswith("prm_stub") or family.startswith("old_dld_stub"):
        return True
    return 0 < size <= 0x10000



def size_family(size: int, role: str, container_type: str) -> Dict[str, Any]:
    family = KNOWN_SIZE_FAMILIES.get(size, "unknown_size_family")
    evidence: List[str] = []
    if size in KNOWN_SIZE_FAMILIES:
        evidence.append("exact_known_size")
    if size % 0x10000 == 0:
        evidence.append("aligned_64k")
    if size % 0x400 == 0:
        evidence.append("aligned_1k")
    if role == "stub":
        evidence.append("stub_role")
    if role == "firmware":
        evidence.append("firmware_role")
    if container_type == "dld_component":
        evidence.append("dld_component")
    if container_type == "raw_bin":
        evidence.append("raw_bin")
    return {
        "family": family,
        "size_hex": f"0x{size:X}",
        "evidence": evidence,
    }


def plaintext_hint(profile: Dict[str, Any]) -> Dict[str, Any]:
    """Conservative raw/plaintext/encrypted route hint.

    This is not architecture decoding and is not proof.  v3 distinguishes
    strong plaintext evidence from merely structured-looking data so raw/no-DLD
    payloads do not skip decryption just because they contain padding.
    """
    ent = profile["entropy"]
    zero_ratio = profile["zero_ratio"]
    ff_ratio = profile["ff_ratio"]
    printable = profile["printable_ascii_ratio"]
    ascii_count = profile["ascii_string_count_min6"]
    longest_zero = profile["longest_zero_run"]
    longest_ff = profile["longest_ff_run"]
    size = profile["size"]
    known = profile.get("known_strings", [])

    evidence: List[str] = []
    plaintext_score = 0
    encrypted_score = 0

    if known:
        plaintext_score += 4
        evidence.append("known_identity_or_capability_strings_present")
    if ent < 7.5 and ascii_count >= 100:
        plaintext_score += 2
        evidence.append("ascii_string_count_ge_100")
    elif ent < 7.5 and ascii_count >= 20:
        plaintext_score += 1
        evidence.append("ascii_string_count_ge_20")
    if zero_ratio > 0.03 or ff_ratio > 0.03:
        plaintext_score += 1
        evidence.append("padding_or_erased_bytes_present")
    if longest_zero >= min(0x1000, max(256, size // 256)) or longest_ff >= min(0x1000, max(256, size // 256)):
        plaintext_score += 1
        evidence.append("large_padding_run_present")
    if ent < 7.0:
        plaintext_score += 1
        evidence.append("entropy_below_packed_expectation")

    if ent >= 7.75 and zero_ratio < 0.01 and ff_ratio < 0.01:
        encrypted_score += 4
        evidence.append("very_high_entropy_low_padding")
    elif ent >= 7.35 and zero_ratio < 0.02 and ff_ratio < 0.02:
        encrypted_score += 3
        evidence.append("high_entropy_low_padding")
    if not known and ascii_count < 10 and printable < 0.04:
        encrypted_score += 1
        evidence.append("low_text_no_known_strings")

    if plaintext_score >= encrypted_score + 4 and known:
        status = "strong_plaintext_or_structured"
    elif plaintext_score >= encrypted_score + 2:
        status = "likely_plaintext_or_structured"
    elif encrypted_score >= plaintext_score + 2:
        status = "likely_encrypted_or_compressed"
    else:
        status = "inconclusive"

    return {
        "status": status,
        "plaintext_or_structured_score": plaintext_score,
        "encrypted_or_compressed_score": encrypted_score,
        "evidence": evidence,
        "status_is_route_hint_not_proof": True,
    }



def route_record(stage2_record: Dict[str, Any], profile: Dict[str, Any], sizefam: Dict[str, Any], phint: Dict[str, Any], probe_raw_review_decrypt: bool = False) -> Dict[str, Any]:
    role = stage2_record.get("role", "unknown")
    container_type = stage2_record.get("container_type", "unknown")
    target_scope = target_scope_for_record(stage2_record)
    size = int(profile.get("size", 0) or 0)
    ph_status = phint.get("status", "inconclusive")

    # GDR-3120L is a raw Xbox 360 validation-reference firmware. It is allowed
    # through as plaintext even though Stage 2 may not know its role.
    if target_scope == "validation_reference" and container_type == "raw_bin":
        return {
            "route": "raw_plaintext_firmware_analysis_candidate",
            "target_scope": target_scope,
            "effective_role": "firmware",
            "needs_decryption": False,
            "decrypt_method": None,
            "confidence": "high",
            "evidence": [
                "validation_reference_gdr_3120l",
                "raw_no_dld_single_bin_allowed",
                "forced_firmware_analysis_reference",
            ],
            "warnings": [
                "validation_reference_not_recipient",
                "not_original_xbox_donor_auth_target",
            ],
        }

    # Stubs are locked plaintext. DLD stubs and raw stubs never route to decrypt.
    if role == "stub":
        return {
            "route": "plaintext_stub_analysis_candidate",
            "target_scope": target_scope,
            "effective_role": "stub",
            "needs_decryption": False,
            "decrypt_method": None,
            "confidence": "high",
            "evidence": ["stub_role_locked_plaintext", "stubs_are_never_encrypted"],
            "warnings": [],
        }

    # DLD firmware payloads use the old DLD firmware decrypt path.
    if role == "firmware" and container_type == "dld_component":
        return {
            "route": "old_dld_decrypt_candidate",
            "target_scope": target_scope,
            "effective_role": "firmware",
            "needs_decryption": True,
            "decrypt_method": "old_dld_firmware",
            "confidence": "high",
            "evidence": ["firmware_role", "dld_component", "old_dld_firmware_payload"],
            "warnings": ["byte_profile_not_used_to_clear_dld_decryption"],
        }

    # Raw/no-DLD firmware can be plaintext, or it can need a raw/newstyle KPA path.
    if role == "firmware" and container_type == "raw_bin":
        if ph_status == "likely_encrypted_or_compressed":
            return {
                "route": "raw_or_newstyle_decrypt_candidate",
                "target_scope": target_scope,
                "effective_role": "firmware",
                "needs_decryption": True,
                "decrypt_method": "raw_kpa_or_newstyle_firmware",
                "confidence": "medium",
                "evidence": ["firmware_role", "raw_bin", "byte_profile_suggests_packed_or_encrypted"] + phint["evidence"],
                "warnings": ["raw_newstyle_decrypt_required_if_supported"],
            }
        if ph_status in ("strong_plaintext_or_structured", "likely_plaintext_or_structured"):
            return {
                "route": "raw_plaintext_firmware_analysis_candidate",
                "target_scope": target_scope,
                "effective_role": "firmware",
                "needs_decryption": False,
                "decrypt_method": None,
                "confidence": "high" if ph_status == "strong_plaintext_or_structured" else "medium",
                "evidence": ["firmware_role", "raw_bin", "byte_profile_suggests_plaintext_or_structured"] + phint["evidence"],
                "warnings": ["raw_plaintext_route_is_preanalysis_not_checksum_verification"],
            }
        if probe_raw_review_decrypt:
            return {
                "route": "raw_or_newstyle_decrypt_candidate",
                "target_scope": target_scope,
                "effective_role": "firmware",
                "needs_decryption": True,
                "decrypt_method": "raw_kpa_or_newstyle_firmware",
                "confidence": "low",
                "evidence": [
                    "firmware_role",
                    "raw_bin",
                    "raw_review_decrypt_probe_enabled",
                    f"byte_profile_{ph_status}",
                ] + phint["evidence"],
                "warnings": [
                    "raw_decrypt_probe_not_proof_of_encryption",
                    "stage4_must_promote_only_strong_plaintext",
                ],
            }
        return {
            "route": "raw_firmware_review",
            "target_scope": target_scope,
            "effective_role": "firmware",
            "needs_decryption": None,
            "decrypt_method": None,
            "confidence": "low",
            "evidence": ["firmware_role", "raw_bin", "byte_profile_inconclusive"],
            "warnings": ["manual_review_needed_before_decrypt_or_disassembly"],
        }

    # Unknown raw/no-DLD. We may infer stub by size. Large unknown raw is never
    # auto-promoted to analysis by default; it either needs decrypt or review,
    # except GDR-3120L handled above.
    if role == "unknown" and container_type == "raw_bin":
        if is_stub_sized_raw(size, sizefam):
            return {
                "route": "plaintext_stub_analysis_candidate",
                "target_scope": target_scope,
                "effective_role": "stub",
                "needs_decryption": False,
                "decrypt_method": None,
                "confidence": "medium",
                "evidence": ["raw_no_dld_unknown_role", "stub_sized_payload", "stubs_are_never_encrypted"],
                "warnings": ["role_inferred_from_size_not_prm"],
            }

        if is_large_raw_like(size) and ph_status == "likely_encrypted_or_compressed":
            return {
                "route": "raw_or_newstyle_decrypt_candidate",
                "target_scope": target_scope,
                "effective_role": "firmware",
                "needs_decryption": True,
                "decrypt_method": "raw_kpa_or_newstyle_firmware",
                "confidence": "medium",
                "evidence": [
                    "raw_no_dld_unknown_role",
                    "large_single_or_pair_firmware_candidate",
                    "byte_profile_suggests_packed_or_encrypted",
                ] + phint["evidence"],
                "warnings": ["role_inferred_firmware_candidate_for_decrypt_only"],
            }

        if is_large_raw_like(size):
            if probe_raw_review_decrypt:
                return {
                    "route": "raw_or_newstyle_decrypt_candidate",
                    "target_scope": target_scope,
                    "effective_role": "firmware",
                    "needs_decryption": True,
                    "decrypt_method": "raw_kpa_or_newstyle_firmware",
                    "confidence": "low",
                    "evidence": [
                        "raw_no_dld_unknown_role",
                        "large_single_or_pair_firmware_candidate",
                        "raw_review_decrypt_probe_enabled",
                        f"byte_profile_{ph_status}",
                    ] + phint["evidence"],
                    "warnings": [
                        "role_inferred_firmware_candidate_for_decrypt_probe_only",
                        "raw_decrypt_probe_not_proof_of_encryption",
                        "stage4_must_promote_only_strong_plaintext",
                        "missing_stub_is_not_negative_evidence",
                    ],
                }
            return {
                "route": "raw_unknown_firmware_review",
                "target_scope": target_scope,
                "effective_role": "firmware_candidate",
                "needs_decryption": None,
                "decrypt_method": None,
                "confidence": "low",
                "evidence": [
                    "raw_no_dld_unknown_role",
                    "large_single_or_pair_firmware_candidate",
                    f"byte_profile_{ph_status}",
                ] + phint["evidence"],
                "warnings": [
                    "unknown_raw_large_not_auto_analysis",
                    "decide_plaintext_vs_raw_decrypt_after_review",
                    "missing_stub_is_not_negative_evidence",
                ],
            }

        return {
            "route": "unknown_payload_review",
            "target_scope": target_scope,
            "effective_role": "unknown",
            "needs_decryption": None,
            "decrypt_method": None,
            "confidence": "low",
            "evidence": ["unknown_role"],
            "warnings": ["role_unknown"],
        }

    return {
        "route": "unhandled_payload_review",
        "target_scope": target_scope,
        "effective_role": role,
        "needs_decryption": None,
        "decrypt_method": None,
        "confidence": "low",
        "evidence": ["unhandled_role_or_container_type"],
        "warnings": [f"role={role}", f"container_type={container_type}"],
    }



class Stage3Router:
    def __init__(self, args: argparse.Namespace):
        self.args = args
        self.workspace = Path(args.workspace).resolve()
        self.stage2_manifest = Path(args.stage2_manifest) if args.stage2_manifest else self.workspace / "stage2_payload_manifest.json"
        if not self.stage2_manifest.is_absolute():
            self.stage2_manifest = self.workspace / self.stage2_manifest

        self.logs_dir = self.workspace / "logs"
        self.log_path: Optional[Path] = None
        self.stage2: Dict[str, Any] = {}
        self.records: List[Dict[str, Any]] = []
        self.decrypt_candidates: List[Dict[str, Any]] = []
        self.inventory_rows: List[Dict[str, Any]] = []

        self.counts: Dict[str, int] = {
            "stage2_records_seen": 0,
            "payload_records_seen": 0,
            "records_routed": 0,
            "records_missing_file": 0,
            "sha256_mismatches": 0,
            "stub_plaintext_routes": 0,
            "old_dld_decrypt_candidates": 0,
            "raw_or_newstyle_decrypt_candidates": 0,
            "raw_review_probe_decrypt_candidates": 0,
            "plaintext_firmware_routes": 0,
            "validation_reference_routes": 0,
            "raw_no_dld_inferred_analysis_routes": 0,
            "raw_unknown_firmware_review_routes": 0,
            "unknown_review_routes": 0,
            "errors": 0,
        }

    def log(self, msg: str) -> None:
        print(msg)
        if self.log_path:
            with self.log_path.open("a", encoding="utf-8") as f:
                f.write(msg + "\n")

    def setup(self) -> None:
        self.logs_dir.mkdir(parents=True, exist_ok=True)
        ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        self.log_path = self.logs_dir / f"hlds_stage3_router_{ts}.log"
        self.log(f"[INFO] {PRODUCER} started {utc_iso()}")

    def load_stage2(self) -> None:
        self.log(f"[INFO] loading Stage 2 manifest: {self.stage2_manifest}")
        with self.stage2_manifest.open("r", encoding="utf-8") as f:
            self.stage2 = json.load(f)
        self.counts["stage2_records_seen"] = len(self.stage2.get("records", []))

    def run(self) -> None:
        self.setup()
        self.load_stage2()
        self.process_records()
        self.write_outputs()
        self.print_summary()

    def process_records(self) -> None:
        for rec in self.stage2.get("records", []):
            if rec.get("record_type") != "normalized_payload":
                continue
            self.counts["payload_records_seen"] += 1
            self.process_payload_record(rec)

    def process_payload_record(self, rec: Dict[str, Any]) -> None:
        rel = rec.get("normalized_rel", "")
        path = rel_to_path(self.workspace, rel)
        if not path.exists():
            self.counts["records_missing_file"] += 1
            out = {
                "record_id": rec.get("record_id"),
                "stage2_record_id": rec.get("record_id"),
                "normalized_rel": rel,
                "analysis_status": "missing_file",
                "error": "normalized_payload_missing",
            }
            self.records.append(out)
            self.log(f"[WARN] missing normalized payload: {rel}")
            return

        try:
            data = path.read_bytes()
            digest = sha256_bytes(data)
            notes: List[str] = []
            if rec.get("sha256") and rec.get("sha256") != digest:
                self.counts["sha256_mismatches"] += 1
                notes.append("sha256_mismatch")

            profile = basic_byte_profile(data)
            sfam = size_family(len(data), rec.get("role", "unknown"), rec.get("container_type", "unknown"))
            phint = plaintext_hint(profile)
            route = route_record(rec, profile, sfam, phint, probe_raw_review_decrypt=bool(self.args.probe_raw_review_decrypt))
            route.setdefault("target_scope", target_scope_for_record(rec))

            out = {
                "record_id": rec.get("record_id"),
                "record_type": "stage3_route",
                "stage2_record_id": rec.get("record_id"),
                "role": rec.get("role"),
                "container_type": rec.get("container_type"),
                "target_scope": target_scope_for_record(rec),
                "normalized_rel": rel,
                "size": len(data),
                "sha256": digest,
                "source": rec.get("source", {}),
                "stage2_evidence": rec.get("evidence", []),
                "prm_match_summary": rec.get("prm_match_summary", {}),
                "preanalysis": {
                    "byte_profile": profile,
                    "size_family": sfam,
                    "plaintext_encrypted_hint": phint,
                    "mn103s_opcode_density": {
                        "status": "not_run",
                        "reason": "removed_from_stage3_router_until_real_decoder_or_manual_reference_based_check",
                    },
                    "vector_reset_table_candidates": {
                        "status": "not_run",
                        "reason": "deep_architecture_analysis_belongs_after_decryption",
                    },
                    "stub_footer_checksum_candidates": {
                        "status": "not_run" if rec.get("role") != "stub" else "deferred_to_stage5_stub_analysis",
                        "reason": "checksum_candidates_not_used_for_stage4_routing",
                    },
                },
                "route": route,
                "notes": notes,
                "analysis_status": "complete",
            }
            self.records.append(out)

            if route.get("needs_decryption") is True:
                self.decrypt_candidates.append({
                    "stage3_record_id": out["record_id"],
                    "stage2_record_id": rec.get("record_id"),
                    "role": route.get("effective_role") if route.get("effective_role") in ("firmware", "stub") else rec.get("role"),
                    "original_role": rec.get("role"),
                    "container_type": rec.get("container_type"),
                    "target_scope": target_scope_for_record(rec),
                    "normalized_rel": rel,
                    "size": len(data),
                    "sha256": digest,
                    "decrypt_method": route.get("decrypt_method"),
                    "route": route.get("route"),
                    "confidence": route.get("confidence"),
                    "evidence": route.get("evidence", []),
                    "source": rec.get("source", {}),
                })

            self.update_counts(route)
            self.add_inventory(out)
            self.counts["records_routed"] += 1

            self.log(
                f"[INFO] routed {rel} role={rec.get('role')} container={rec.get('container_type')} "
                f"size=0x{len(data):X} route={route.get('route')} needs_decryption={route.get('needs_decryption')}"
            )
        except Exception as e:
            self.counts["errors"] += 1
            self.log(f"[ERROR] failed routing {rel}: {type(e).__name__}: {e}")

    def update_counts(self, route: Dict[str, Any]) -> None:
        r = route.get("route", "")
        if r == "plaintext_stub_analysis_candidate":
            self.counts["stub_plaintext_routes"] += 1
        elif r == "old_dld_decrypt_candidate":
            self.counts["old_dld_decrypt_candidates"] += 1
        elif r == "raw_or_newstyle_decrypt_candidate":
            self.counts["raw_or_newstyle_decrypt_candidates"] += 1
            if "raw_review_decrypt_probe_enabled" in route.get("evidence", []):
                self.counts["raw_review_probe_decrypt_candidates"] += 1
        elif r == "raw_unknown_firmware_review":
            self.counts["raw_unknown_firmware_review_routes"] += 1
            self.counts["unknown_review_routes"] += 1
        elif r == "raw_plaintext_firmware_analysis_candidate":
            self.counts["plaintext_firmware_routes"] += 1
            if route.get("target_scope") == "validation_reference":
                self.counts["validation_reference_routes"] += 1
            if "raw_no_dld_unknown_role" in route.get("evidence", []):
                self.counts["raw_no_dld_inferred_analysis_routes"] += 1
        elif "unknown" in r or "review" in r:
            self.counts["unknown_review_routes"] += 1

    def add_inventory(self, out: Dict[str, Any]) -> None:
        pre = out["preanalysis"]
        route = out["route"]
        profile = pre["byte_profile"]
        self.inventory_rows.append({
            "record_id": out.get("record_id", ""),
            "role": out.get("role", ""),
            "container_type": out.get("container_type", ""),
            "target_scope": out.get("target_scope", ""),
            "normalized_rel": out.get("normalized_rel", ""),
            "size": out.get("size", ""),
            "sha256": out.get("sha256", ""),
            "entropy": profile.get("entropy", ""),
            "zero_ratio": profile.get("zero_ratio", ""),
            "ff_ratio": profile.get("ff_ratio", ""),
            "printable_ascii_ratio": profile.get("printable_ascii_ratio", ""),
            "size_family": pre["size_family"].get("family", ""),
            "plaintext_encrypted_hint": pre["plaintext_encrypted_hint"].get("status", ""),
            "route": route.get("route", ""),
            "needs_decryption": route.get("needs_decryption", ""),
            "decrypt_method": route.get("decrypt_method", ""),
            "confidence": route.get("confidence", ""),
        })

    def write_outputs(self) -> None:
        manifest = {
            "schema_version": 1,
            "producer": PRODUCER,
            "created_utc": utc_iso(),
            "workspace": str(self.workspace),
            "stage2_manifest_rel": relpath_str(self.stage2_manifest, self.workspace),
            "log_rel": relpath_str(self.log_path, self.workspace) if self.log_path else "",
            "counts": self.counts,
            "configuration": {
                "probe_raw_review_decrypt": bool(self.args.probe_raw_review_decrypt),
            },
            "stage_boundaries": {
                "stage3_does": [
                    "conservative_preanalysis",
                    "byte_profile_entropy_padding_strings",
                    "size_family_hints",
                    "stage4_decrypt_candidate_routing",
                    "stub_plaintext_lock",
                    "raw_no_dld_single_or_pair_role_inference",
                    "raw_no_dld_decrypt_needed_routing",
                    "raw_no_dld_unknown_large_review_gate",
                    "optional_raw_review_decrypt_probe_gate",
                    "gdr_3120l_validation_reference_routing",
                ],
                "stage3_does_not": [
                    "decrypt",
                    "disassemble",
                    "mn103s_opcode_density",
                    "vector_table_analysis",
                    "stub_footer_checksum_analysis",
                    "command_handler_analysis",
                    "final_profile_generation",
                ],
            },
            "locked_rules": {
                "stubs_are_never_encrypted": True,
                "stubs_never_enter_decryptor": True,
                "old_dld_firmware_components_route_to_old_dld_decrypt": True,
                "unknown_role_payloads_are_review_only_unless_raw_no_dld_inference_applies": True,
                "dld_always_header_firmware_stub": True,
                "raw_no_dld_may_have_firmware_stub_pair_or_firmware_only": True,
                "raw_no_dld_firmware_may_still_need_decrypt": True,
                "unknown_large_raw_not_auto_analysis_except_validation_reference": True,
                "raw_review_decrypt_probes_require_explicit_flag": True,
                "stage4_must_promote_only_strong_raw_plaintext": True,
                "missing_stub_is_not_negative_evidence": True,
                "gdr_3120l_routes_as_validation_reference": True,
            },
            "decrypt_candidates": self.decrypt_candidates,
            "records": self.records,
        }

        manifest_path = self.workspace / "stage3_routing_manifest.json"
        manifest_path.write_text(json.dumps(manifest, indent=4), encoding="utf-8")
        self.log(f"[INFO] wrote Stage 3 routing manifest: {relpath_str(manifest_path, self.workspace)}")

        inv_path = self.workspace / "stage3_routing_inventory.csv"
        fields = [
            "record_id", "role", "container_type", "target_scope", "normalized_rel", "size", "sha256",
            "entropy", "zero_ratio", "ff_ratio", "printable_ascii_ratio",
            "size_family", "plaintext_encrypted_hint", "route", "needs_decryption",
            "decrypt_method", "confidence",
        ]
        with inv_path.open("w", encoding="utf-8", newline="") as f:
            w = csv.DictWriter(f, fieldnames=fields)
            w.writeheader()
            for row in self.inventory_rows:
                w.writerow(row)
        self.log(f"[INFO] wrote Stage 3 routing inventory: {relpath_str(inv_path, self.workspace)}")

        candidates_path = self.workspace / "stage4_decrypt_candidates.json"
        candidates_doc = {
            "schema_version": 1,
            "producer": PRODUCER,
            "created_utc": utc_iso(),
            "workspace": str(self.workspace),
            "source_manifest_rel": "stage3_routing_manifest.json",
            "count": len(self.decrypt_candidates),
            "candidates": self.decrypt_candidates,
        }
        candidates_path.write_text(json.dumps(candidates_doc, indent=4), encoding="utf-8")
        self.log(f"[INFO] wrote Stage 4 decrypt candidates: {relpath_str(candidates_path, self.workspace)}")

    def print_summary(self) -> None:
        print()
        print("=" * 72)
        print("HLDS STAGE 3 ROUTER SUMMARY")
        print("=" * 72)
        print(f"Workspace              : {self.workspace}")
        print(f"Stage 2 manifest       : {self.stage2_manifest}")
        print(f"Routing manifest       : {self.workspace / 'stage3_routing_manifest.json'}")
        print(f"Routing inventory CSV  : {self.workspace / 'stage3_routing_inventory.csv'}")
        print(f"Stage4 candidates JSON : {self.workspace / 'stage4_decrypt_candidates.json'}")
        print(f"Log file               : {self.log_path}")
        print("-" * 72)
        print(f"Stage2 records         : {self.counts['stage2_records_seen']}")
        print(f"Payload records        : {self.counts['payload_records_seen']}")
        print(f"Records routed         : {self.counts['records_routed']}")
        print(f"Missing files          : {self.counts['records_missing_file']}")
        print(f"SHA mismatches         : {self.counts['sha256_mismatches']}")
        print(f"Stub plaintext routes  : {self.counts['stub_plaintext_routes']}")
        print(f"Old DLD decrypt cand.  : {self.counts['old_dld_decrypt_candidates']}")
        print(f"Raw/new decrypt cand.  : {self.counts['raw_or_newstyle_decrypt_candidates']}")
        print(f"Raw review probes     : {self.counts['raw_review_probe_decrypt_candidates']}")
        print(f"Plaintext firmware rt. : {self.counts['plaintext_firmware_routes']}")
        print(f"Validation ref. routes : {self.counts['validation_reference_routes']}")
        print(f"Raw inferred analysis  : {self.counts['raw_no_dld_inferred_analysis_routes']}")
        print(f"Raw unknown reviews    : {self.counts['raw_unknown_firmware_review_routes']}")
        print(f"Unknown/review routes  : {self.counts['unknown_review_routes']}")
        print(f"Errors                 : {self.counts['errors']}")
        print("=" * 72)


def build_arg_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(
        description="Stage 3 conservative router: routes normalized payloads to decrypt/review/analyze without disassembly."
    )
    ap.add_argument(
        "--workspace",
        default=r"D:\vscode-workspace\xbox-dvd-tools-v2",
        help="Workspace root containing Stage 2 outputs.",
    )
    ap.add_argument(
        "--stage2-manifest",
        default=None,
        help="Path to stage2_payload_manifest.json. Defaults to <workspace>\\stage2_payload_manifest.json.",
    )
    ap.add_argument(
        "--probe-raw-review-decrypt",
        action="store_true",
        help=(
            "Route large raw/no-DLD review firmware candidates to Stage 4 as guarded "
            "raw/newstyle decrypt probes. Mirrors Stage 1's --stage-newstyle-probes style: "
            "off by default, no automatic promotion unless Stage 4 produces strong plaintext."
        ),
    )
    return ap


def main(argv: Optional[List[str]] = None) -> int:
    args = build_arg_parser().parse_args(argv)
    s = Stage3Router(args)
    try:
        s.run()
        return 0 if s.counts.get("errors", 0) == 0 and s.counts.get("records_missing_file", 0) == 0 else 2
    except KeyboardInterrupt:
        print("Interrupted.", file=sys.stderr)
        return 130
    except Exception as e:
        print(f"fatal: {type(e).__name__}: {e}", file=sys.stderr)
        return 1


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