#!/usr/bin/env python3
"""
HLDS/LG Stage 5B evidence analyzer v3.

Stage 5B is an evidence collector, not a patcher and not a profile verifier.

It consumes:
    * stage5b_analyzer_inputs.json
    * plaintext firmware/stub .bin files
    * MN103S objdump .asm files

It produces:
    * stage5b_analysis_manifest.json
    * stage5b_findings_inventory.csv
    * stage5b_smc_gpio_candidates.json
    * stage6_profile_candidates.json
    * logs/hlds_stage5b_analyzer_YYYYMMDD_HHMMSS.log

Locked project rules:
    * CD_READY is media/disc-ready, not auth-ready unless proven.
    * Xbox authentication logic is expected in donor Xbox firmware only.
    * Recipient PC firmware is not penalized for missing donor auth logic.
    * Xbox auth state clears on eject/tray transition.
    * HTML/project notes are loose references, not authoritative truth.
    * Checksum verification is not performed here; checksum routines/ranges
      are candidate findings only.
    * SFR/GPIO evidence prefers direct absolute memory accesses over generic immediates.
"""

from __future__ import annotations

import argparse
import csv
import datetime as _dt
import hashlib
import json
import math
import os
import re
import struct
import sys
from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path, PureWindowsPath
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple


PRODUCER = "hlds_stage5b_analyzer_v3"


SCSI_COMMANDS = {
    0x00: "TEST_UNIT_READY",
    0x03: "REQUEST_SENSE",
    0x12: "INQUIRY",
    0x1B: "START_STOP_UNIT_MECHANICAL",
    0x25: "READ_CAPACITY_10",
    0x28: "READ_10",
    0x2A: "WRITE_10",
    0x43: "READ_TOC_PMA_ATIP",
    0x46: "GET_CONFIGURATION",
    0x55: "MODE_SELECT_10",
    0x5A: "MODE_SENSE_10",
    0xA8: "READ_12",
    0xAD: "READ_DVD_STRUCTURE",
    0xBB: "SET_CD_SPEED",
    0xBE: "READ_CD",
    0xE2: "ATAPI_IDLE",
    0xE7: "VENDOR_READ_STRUCTURE_OR_MEMDUMP",
    0xFC: "VENDOR_DLD_OR_XBOX_CHALLENGE",
    0xFD: "VENDOR_DEBUG_OR_XBOX_RESPONSE",
}

DONOR_AUTH_TARGETS = {
    "PCJD4_SIGNATURE": [b"PCJD4", b"PCJD4 "],
    "XBOX_STRING": [b"XBOX", b"Xbox", b"xbox"],
}

SHA1_CONSTANTS = {
    0x67452301: "SHA1_H0",
    0xEFCDAB89: "SHA1_H1",
    0x98BADCFE: "SHA1_H2",
    0x10325476: "SHA1_H3",
    0xC3D2E1F0: "SHA1_H4",
    0x5A827999: "SHA1_K0",
    0x6ED9EBA1: "SHA1_K1",
    0x8F1BBCDC: "SHA1_K2",
    0xCA62C1D6: "SHA1_K3",
}

CRC_CONSTANTS = {
    0x04C11DB7: "CRC32_POLY_BE",
    0xEDB88320: "CRC32_POLY_LE",
    0x1021: "CRC16_CCITT",
    0xA001: "CRC16_IBM_REFLECTED",
}

FLASH_CONSTANTS = {
    0xAA: "FLASH_UNLOCK_AA",
    0x55: "FLASH_UNLOCK_55",
    0xA0: "FLASH_PROGRAM",
    0x80: "FLASH_ERASE_SETUP",
    0x90: "FLASH_ID_ENTRY",
    0xF0: "FLASH_RESET",
    0x555: "FLASH_ADDR_555",
    0x2AA: "FLASH_ADDR_2AA",
    0x5555: "FLASH_ADDR_5555",
    0x2AAA: "FLASH_ADDR_2AAA",
}

SERVO_HINT_CONSTANTS = {
    0x42: "PROJECT_NOTE_FOCUS_GAIN_OFFSET",
    0x44: "PROJECT_NOTE_TRACKING_GAIN_OFFSET",
    0x48: "PROJECT_NOTE_FOCUS_OFFSET",
    0x4A: "PROJECT_NOTE_TRACKING_OFFSET",
}

LEGACY_DEPRECATED_GPIO_ADDRS = {
    0x9000A040: "deprecated_project_note_legacy_PORTB_IN",
    0x9000A044: "deprecated_project_note_legacy_PORTB_DIR",
}

SFR_RANGE = (0x0200, 0x03FF)
SFR_LOOSE_RANGES = [(0x0200, 0x03FF), (0x0300, 0x03FF)]

PROJECT_REFERENCE_MODEL = {
    "authority": "project_note_unverified",
    "notes": [
        "Project notes are loose references and may include stale or deprecated mappings.",
        "CD_READY means media/disc-ready unless firmware evidence proves an auth dependency.",
        "Donor Xbox firmware is expected to contain Xbox auth logic; recipient PC firmware is not.",
        "Auth state clears on eject/tray transition.",
        "SFR/GPIO evidence should prefer observed 0x02xx/0x03xx accesses over older 0x9000A040-style notes.",
    ],
    "smc_outputs": {
        "TRAY_IN": {"meaning": "Xbox sees tray fully closed", "polarity": "active_low"},
        "TRAY_OUT": {"meaning": "Xbox sees tray fully open", "polarity": "active_low"},
        "CD_READY": {"meaning": "Xbox sees disc/media present and ready", "polarity": "active_low"},
        "EJECT": {"meaning": "Xbox/host eject request path", "polarity": "hardware_specific"},
    },
    "native_inputs_of_interest": [
        "tray closed/back switch",
        "tray open/front switch",
        "eject button",
        "media present",
        "TOC/readiness state",
        "focus/spindle lock as supporting evidence only",
    ],
}


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 sanitize_key(s: str) -> str:
    s = re.sub(r"[^A-Za-z0-9._-]+", "_", str(s))
    s = re.sub(r"_+", "_", s)
    return s.strip("_") or "UNKNOWN"


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 model_version_from_item(item: Dict[str, Any]) -> Tuple[str, str]:
    ident = ((item.get("source") or {}).get("identity_hint") or {})
    model = ident.get("model") or "UNKNOWN"
    version = ident.get("version") or "UNK"
    return str(model), str(version)


def target_scope_from_item(item: Dict[str, Any]) -> Dict[str, Any]:
    """Classify project-level target scope.

    donor_xbox: GDR-8050L only.
    validation_reference: GDR-3120L only.
    recipient_candidate: everything else unless explicitly excluded.
    """
    model, _ = model_version_from_item(item)
    text = " ".join([model, item.get("plaintext_rel", ""), item.get("asm_rel", "")]).upper()
    text_compact = text.replace("-", "").replace("_", "")

    if "GDR8050L" in text_compact:
        scope = "donor_xbox"
    elif "GDR3120L" in text_compact:
        scope = "validation_reference"
    else:
        scope = "recipient_candidate"

    return {
        "target_scope": scope,
        "is_donor_xbox_firmware": scope == "donor_xbox",
        "is_validation_reference": scope == "validation_reference",
        "is_recipient_candidate": scope == "recipient_candidate",
        "donor_auth_expected": scope == "donor_xbox",
        "recipient_transplant_candidate": scope == "recipient_candidate",
        "tool_validation_target": scope == "validation_reference",
        "recipient_auth_absence_is_failure": False,
        "cd_ready_semantics": "media_disc_ready_not_auth_ready_unless_proven",
        "html_project_notes_authority": "loose_reference_only",
    }


def is_probable_donor(item: Dict[str, Any]) -> bool:
    return target_scope_from_item(item)["is_donor_xbox_firmware"]


def offset_to_vma(offset: int, base_addr: Optional[int]) -> Optional[int]:
    if base_addr is None:
        return None
    return base_addr + offset


def parse_int_auto(s: str) -> Optional[int]:
    try:
        if s.lower().startswith("0x"):
            return int(s, 16)
        return int(s, 10)
    except Exception:
        return None


@dataclass
class AsmLine:
    idx: int
    address: Optional[int]
    text: str
    mnemonic: str
    operands: str
    numbers: List[int]


ASM_LINE_RE = re.compile(
    r"^\s*(?P<addr>[0-9a-fA-F]{4,16}):\s*(?P<bytes>(?:[0-9a-fA-F]{2}\s+)*)?(?P<mnemonic>[A-Za-z][A-Za-z0-9_.]*)?\s*(?P<operands>.*)$"
)
NUM_RE = re.compile(r"(?<![A-Za-z0-9_])(?:0x[0-9a-fA-F]+|-?\d+)(?![A-Za-z0-9_])")


def parse_asm_lines(raw_lines: Sequence[str]) -> List[AsmLine]:
    parsed: List[AsmLine] = []
    for idx, raw in enumerate(raw_lines):
        line = raw.rstrip("\n")
        m = ASM_LINE_RE.match(line)
        if not m:
            parsed.append(AsmLine(idx=idx, address=None, text=line, mnemonic="", operands="", numbers=[]))
            continue
        addr = int(m.group("addr"), 16)
        mnemonic = (m.group("mnemonic") or "").lower()
        operands = (m.group("operands") or "").strip()
        nums = []
        for n in NUM_RE.findall(operands):
            val = parse_int_auto(n)
            if val is not None:
                nums.append(val)
        parsed.append(AsmLine(idx=idx, address=addr, text=line, mnemonic=mnemonic, operands=operands, numbers=nums))
    return parsed


def context_lines(lines: Sequence[str], idx: int, radius: int = 5) -> List[str]:
    start = max(0, idx - radius)
    end = min(len(lines), idx + radius + 1)
    return [lines[i].rstrip("\n") for i in range(start, end)]


def extract_ascii_strings(data: bytes, min_len: int = 6, max_results: int = 1000) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    start = None
    for i, b in enumerate(data + b"\x00"):
        if 32 <= b <= 126:
            if start is None:
                start = i
        else:
            if start is not None and i - start >= min_len:
                raw = data[start:i]
                out.append({
                    "offset": start,
                    "length": i - start,
                    "text": raw.decode("ascii", errors="replace"),
                })
                if len(out) >= max_results:
                    break
            start = None
    return out


def find_runs(data: bytes, value: int, min_len: int = 256, max_results: int = 128) -> List[Dict[str, Any]]:
    runs: List[Dict[str, Any]] = []
    i = 0
    n = len(data)
    while i < n:
        if data[i] != value:
            i += 1
            continue
        start = i
        while i < n and data[i] == value:
            i += 1
        ln = i - start
        if ln >= min_len:
            runs.append({"offset": start, "length": ln, "value": f"0x{value:02X}"})
            if len(runs) >= max_results:
                break
    return runs


def read_u32_at(data: bytes, offset: int, endian: str) -> int:
    return struct.unpack(">I" if endian == "be" else "<I", data[offset:offset + 4])[0]


def plausible_pointer_ranges(base_addr: Optional[int], size: int) -> List[Tuple[int, int, str]]:
    ranges = []
    if base_addr is not None:
        ranges.append((base_addr, base_addr + size, "image_base"))
    ranges.extend([
        (0x90000000, 0x90100000, "rom_0x90000000_family"),
        (0x40000000, 0x40020000, "ram_sram_0x40000000_family"),
        (0x10C00000, 0x10D00000, "dram_buffer_family"),
        (0x00000200, 0x00000400, "sfr_0x02xx_0x03xx"),
    ])
    return ranges


def classify_ptr(val: int, ranges: Sequence[Tuple[int, int, str]]) -> Optional[str]:
    for lo, hi, name in ranges:
        if lo <= val < hi:
            return name
    return None


class FindingEmitter:
    def __init__(self):
        self.findings: List[Dict[str, Any]] = []

    def add(
        self,
        category: str,
        kind: str,
        confidence: str,
        authority: str,
        title: str,
        evidence: str,
        *,
        offset: Optional[int] = None,
        vma: Optional[int] = None,
        asm_addr: Optional[int] = None,
        value: Optional[Any] = None,
        context: Optional[List[str]] = None,
        tags: Optional[List[str]] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.findings.append({
            "category": category,
            "kind": kind,
            "confidence": confidence,
            "source_authority": authority,
            "title": title,
            "evidence": evidence,
            "offset": f"0x{offset:X}" if isinstance(offset, int) else None,
            "vma": f"0x{vma:X}" if isinstance(vma, int) else None,
            "asm_addr": f"0x{asm_addr:X}" if isinstance(asm_addr, int) else None,
            "value": value,
            "context": context or [],
            "tags": tags or [],
            "metadata": metadata or {},
        })


class PayloadAnalyzer:
    def __init__(self, workspace: Path, item: Dict[str, Any], idx: int, args: argparse.Namespace):
        self.workspace = workspace
        self.item = item
        self.idx = idx
        self.args = args
        self.model, self.version = model_version_from_item(item)
        self.role = item.get("role_class") or item.get("role") or "unknown"
        self.container_type = item.get("container_type", "unknown")
        self.base_addr = parse_int_auto(str(item.get("base_addr", ""))) if item.get("base_addr") else None
        self.bin_rel = item.get("plaintext_rel", "")
        self.asm_rel = item.get("asm_rel", "")
        self.bin_path = rel_to_path(workspace, self.bin_rel)
        self.asm_path = rel_to_path(workspace, self.asm_rel)
        self.emitter = FindingEmitter()
        self.raw_lines: List[str] = []
        self.asm_lines: List[AsmLine] = []
        self.data = b""
        self.notes: List[str] = []
        self.status = "PENDING"

    def analyze(self) -> Dict[str, Any]:
        record = {
            "record_id": f"stage5b_{self.idx:04d}",
            "record_type": "stage5b_payload_analysis",
            "input_index": self.idx,
            "model": self.model,
            "version": self.version,
            "role": self.role,
            "container_type": self.container_type,
            "analysis_scope": self.analysis_scope(),
            "base_addr": f"0x{self.base_addr:X}" if self.base_addr is not None else None,
            "plaintext_rel": self.bin_rel,
            "asm_rel": self.asm_rel,
            "input": self.item,
            "status": "PENDING",
            "notes": [],
            "binary_profile": {},
            "asm_profile": {},
            "findings": [],
        }

        if not self.bin_path.exists():
            record["status"] = "MISSING_BINARY"
            record["notes"].append(f"binary not found: {self.bin_rel}")
            return record

        if not self.asm_path.exists():
            record["status"] = "MISSING_ASM"
            record["notes"].append(f"asm not found: {self.asm_rel}")
            return record

        self.data = self.bin_path.read_bytes()
        expected_sha = self.item.get("plaintext_sha256")
        actual_sha = sha256_bytes(self.data)
        if expected_sha and expected_sha != actual_sha:
            self.notes.append("sha256_mismatch")

        self.raw_lines = self.asm_path.read_text(encoding="utf-8", errors="ignore").splitlines()
        self.asm_lines = parse_asm_lines(self.raw_lines)

        record["binary_profile"] = self.binary_profile(actual_sha)
        record["asm_profile"] = self.asm_profile()

        self.analyze_identity()
        self.analyze_padding_and_strings()
        self.analyze_vectors()
        self.analyze_pointer_clusters()
        self.analyze_scsi_dispatchers()
        self.analyze_asm_command_patterns()
        self.analyze_gpio_smc()
        self.analyze_donor_auth()
        self.analyze_flash_candidates()
        self.analyze_checksum_candidates()
        self.analyze_servo_candidates()
        self.analyze_memory_repacker_candidates()
        self.analyze_utilities()

        record["status"] = "ANALYZED"
        record["notes"] = self.notes
        record["findings"] = self.emitter.findings
        return record

    def analysis_scope(self) -> Dict[str, Any]:
        return target_scope_from_item(self.item)

    def binary_profile(self, sha: str) -> Dict[str, Any]:
        n = len(self.data)
        counts = Counter(self.data)
        return {
            "size": n,
            "sha256": sha,
            "entropy": round(entropy(self.data), 4),
            "unique_byte_count": len(counts),
            "zero_ratio": round(counts.get(0, 0) / n, 6) if n else 0.0,
            "ff_ratio": round(counts.get(0xFF, 0) / n, 6) if n else 0.0,
            "first_32_hex": self.data[:32].hex(),
            "last_32_hex": self.data[-32:].hex() if n >= 32 else self.data.hex(),
        }

    def asm_profile(self) -> Dict[str, Any]:
        mnems = Counter(l.mnemonic for l in self.asm_lines if l.mnemonic)
        addrs = [l.address for l in self.asm_lines if l.address is not None]
        return {
            "line_count": len(self.raw_lines),
            "instruction_like_lines": len(addrs),
            "first_addr": f"0x{min(addrs):X}" if addrs else None,
            "last_addr": f"0x{max(addrs):X}" if addrs else None,
            "top_mnemonics": dict(mnems.most_common(20)),
        }

    def analyze_identity(self) -> None:
        strings = extract_ascii_strings(self.data, 6, 1000)
        interesting = []
        patterns = [
            ("HL-DT-ST", re.compile(r"HL-DT-ST", re.I)),
            ("HL-DL-ST", re.compile(r"HL-DL-ST", re.I)),
            ("MODEL", re.compile(r"\b(GCC|GDR|GSA|GWA|GT)[-A-Z0-9]{3,10}\b", re.I)),
            ("DRIVE_TYPE", re.compile(r"(DVD-ROM|CDRW/DVD|RW/DVD|DVD-RAM|DVDRAM|DVD\+/-RW|DVD\+-RW)", re.I)),
            ("DATE", re.compile(r"(?:20\d{2}|\d{2})[/-][0-1]\d[/-][0-3]\d")),
        ]
        for s in strings:
            for label, pat in patterns:
                if pat.search(s["text"]):
                    interesting.append({"label": label, **s})
                    break
            if len(interesting) >= self.args.max_identity_strings:
                break

        if interesting:
            self.emitter.add(
                "identity_provenance",
                "firmware_identity_strings",
                "medium",
                "observed_in_binary",
                "Firmware identity/capability strings found",
                f"{len(interesting)} identity-like strings found",
                value=interesting[:self.args.max_identity_strings],
                tags=["identity", "strings"],
            )

    def analyze_padding_and_strings(self) -> None:
        for value, label in [(0x00, "zero_padding"), (0xFF, "ff_erased_padding")]:
            runs = find_runs(self.data, value, min_len=self.args.min_padding_run)
            if runs:
                self.emitter.add(
                    "binary_structure",
                    label,
                    "medium",
                    "observed_in_binary",
                    f"{label} ranges",
                    f"{len(runs)} run(s) >= {self.args.min_padding_run} bytes",
                    value=runs[:self.args.max_padding_runs],
                    tags=["padding", "range"],
                )

        strings = extract_ascii_strings(self.data, self.args.min_string_len, self.args.max_strings)
        if strings:
            self.emitter.add(
                "binary_structure",
                "ascii_string_ranges",
                "low",
                "observed_in_binary",
                "ASCII strings observed",
                f"{len(strings)} string(s) captured with min length {self.args.min_string_len}",
                value=strings[:self.args.max_strings],
                tags=["strings"],
            )

    def analyze_vectors(self) -> None:
        ranges = plausible_pointer_ranges(self.base_addr, len(self.data))
        candidates = []
        scan_len = min(len(self.data) - 4, self.args.vector_scan_size)
        for endian in ("le", "be"):
            hits = []
            for off in range(0, scan_len, 4):
                val = read_u32_at(self.data, off, endian)
                cls = classify_ptr(val, ranges)
                if cls:
                    hits.append({"offset": f"0x{off:X}", "value": f"0x{val:08X}", "range": cls})
            if len(hits) >= self.args.min_vector_hits:
                candidates.append({"endian": endian, "hits": hits[:64], "hit_count": len(hits)})
        if candidates:
            self.emitter.add(
                "binary_structure",
                "vector_reset_table_candidate",
                "medium",
                "observed_in_binary",
                "Possible vector/reset table near image start",
                f"{len(candidates)} endian interpretation(s) produced vector-like hits",
                offset=0,
                vma=offset_to_vma(0, self.base_addr),
                value=candidates,
                tags=["vectors", "reset_table"],
            )

    def analyze_pointer_clusters(self) -> None:
        ranges = plausible_pointer_ranges(self.base_addr, len(self.data))
        all_clusters = []
        for endian in ("le", "be"):
            clusters = []
            current = []
            for off in range(0, len(self.data) - 3, 4):
                val = read_u32_at(self.data, off, endian)
                cls = classify_ptr(val, ranges)
                if cls:
                    current.append({"offset": off, "value": val, "range": cls})
                else:
                    if len(current) >= self.args.min_pointer_cluster:
                        clusters.append(current)
                    current = []
            if len(current) >= self.args.min_pointer_cluster:
                clusters.append(current)

            for cl in clusters[:self.args.max_pointer_clusters]:
                start = cl[0]["offset"]
                count = len(cl)
                vals = [x["value"] for x in cl[:64]]
                all_clusters.append({
                    "endian": endian,
                    "offset": f"0x{start:X}",
                    "vma": f"0x{offset_to_vma(start, self.base_addr):X}" if self.base_addr is not None else None,
                    "count": count,
                    "first_values": [f"0x{x:08X}" for x in vals],
                    "ranges": dict(Counter(x["range"] for x in cl)),
                })

                confidence = "high" if count >= 64 else "medium"
                kind = "dispatcher_table_candidate" if count >= 64 else "pointer_cluster"
                self.emitter.add(
                    "binary_structure",
                    kind,
                    confidence,
                    "observed_in_binary",
                    f"Pointer cluster at 0x{start:X}",
                    f"{count} consecutive pointer-like words ({endian})",
                    offset=start,
                    vma=offset_to_vma(start, self.base_addr),
                    value=all_clusters[-1],
                    tags=["pointers", "jump_table" if count >= 64 else "cluster"],
                )

    def analyze_scsi_dispatchers(self) -> None:
        # Ported concept from prior analyzer: 256-entry LE table with handlers inside image.
        if self.base_addr is None:
            return
        best = None
        best_count = 0
        for off in range(0, len(self.data) - 1024, 4):
            count = 0
            for j in range(256):
                val = struct.unpack("<I", self.data[off + 4*j:off + 4*j + 4])[0]
                if self.base_addr <= val < self.base_addr + len(self.data):
                    count += 1
                else:
                    break
            if count > best_count:
                best_count = count
                best = off
            if count == 256:
                break

        if best is None or best_count < self.args.min_dispatch_table_entries:
            return

        mapped = {}
        for opcode, name in SCSI_COMMANDS.items():
            if opcode < best_count:
                ptr_off = best + opcode * 4
                val = struct.unpack("<I", self.data[ptr_off:ptr_off + 4])[0]
                mapped[f"0x{opcode:02X}_{name}"] = {
                    "entry_offset": f"0x{ptr_off:X}",
                    "handler": f"0x{val:08X}",
                }

        self.emitter.add(
            "scsi_atapi_dispatch",
            "scsi_jump_table_candidate",
            "high",
            "observed_in_binary",
            "SCSI/ATAPI jump table candidate",
            f"{best_count} consecutive LE handler pointers found",
            offset=best,
            vma=offset_to_vma(best, self.base_addr),
            value={"entry_count": best_count, "mapped_commands": mapped},
            tags=["scsi", "atapi", "dispatcher", "jump_table"],
        )

    def analyze_asm_command_patterns(self) -> None:
        # Scan for cmp/immediate comparisons against known command opcodes.
        hits_by_opcode: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
        for line in self.asm_lines:
            if line.address is None:
                continue
            text = line.text.lower()
            if "cmp" not in text:
                continue
            for val in line.numbers:
                normalized = val & 0xFF
                # MN103S/GNU objdump sometimes prints signed 8-bit immediates.
                if normalized in SCSI_COMMANDS and (0 <= val <= 255 or -128 <= val < 0):
                    hits_by_opcode[normalized].append({
                        "line": line.idx + 1,
                        "addr": f"0x{line.address:X}",
                        "raw_value": val,
                        "text": line.text,
                        "context": context_lines(self.raw_lines, line.idx, self.args.context_radius),
                    })

        for opcode, hits in sorted(hits_by_opcode.items()):
            name = SCSI_COMMANDS[opcode]
            confidence = "high" if len(hits) >= 2 else "medium"
            category = "scsi_atapi_dispatch"
            tags = ["scsi", "atapi", f"cmd_0x{opcode:02x}"]
            if opcode in (0x1B,):
                tags.extend(["tray", "eject", "mechanical"])
            if opcode in (0x5A, 0xAD, 0xE7, 0xFC, 0xFD):
                tags.extend(["vendor_or_auth_related"])
            self.emitter.add(
                category,
                "command_compare_candidate",
                confidence,
                "observed_in_asm",
                f"Command compare candidate: 0x{opcode:02X} {name}",
                f"{len(hits)} compare-like hit(s)",
                asm_addr=int(hits[0]["addr"], 16),
                value={"opcode": f"0x{opcode:02X}", "name": name, "hits": hits[:self.args.max_hits_per_kind]},
                context=hits[0]["context"],
                tags=tags,
            )

    def direct_sfr_accesses(self, line: AsmLine) -> List[Dict[str, Any]]:
        """Return direct absolute SFR memory references from one objdump line.

        v2 counted every 0x02xx/0x03xx immediate, which picked up ordinary
        immediates, compare constants, and branch/jump targets. v3 only treats
        parenthesized absolute memory operands such as (0x2ac) or (0x0304) as
        high-confidence SFR/GPIO accesses. Generic immediates are recorded
        separately as low-confidence mentions.
        """
        if not line.mnemonic.startswith(("mov", "st", "ld")):
            return []
        out: List[Dict[str, Any]] = []
        for m in re.finditer(r"\((0x[0-9a-fA-F]+|-?\d+)\)", line.operands):
            val = parse_int_auto(m.group(1))
            if val is None:
                continue
            if SFR_RANGE[0] <= val <= SFR_RANGE[1]:
                text = line.text.lower()
                access = self.access_hint(line)
                if access == "move_or_store_related":
                    # Parenthesized direct operand still makes this memory-like;
                    # infer a direction from operand placement when possible.
                    paren = re.escape(m.group(0).lower())
                    if re.search(paren + r"\s*,\s*d[0-3]", text):
                        access = "read_like"
                    elif re.search(r"d[0-3]\s*,\s*" + paren, text):
                        access = "write_like"
                    else:
                        access = "direct_memory_operand"
                out.append({
                    "value_int": val,
                    "value": f"0x{val:04X}",
                    "access_hint": access,
                })
        return out

    def loose_sfr_immediate_mentions(self, line: AsmLine, direct_values: Sequence[int]) -> List[int]:
        direct_set = set(direct_values)
        vals = []
        for val in line.numbers:
            if SFR_RANGE[0] <= val <= SFR_RANGE[1] and val not in direct_set:
                vals.append(val)
        return vals

    def analyze_gpio_smc(self) -> None:
        direct_sfr_hits = []
        loose_sfr_mentions = []
        legacy_hits = []
        smc_string_hits = []

        smc_words = re.compile(r"(tray|eject|ready|disc|media|open|close|closed|cd[_ -]?ready|smc)", re.I)

        strings = extract_ascii_strings(self.data, 5, 2000)
        for s in strings:
            if smc_words.search(s["text"]):
                smc_string_hits.append(s)
                if len(smc_string_hits) >= self.args.max_hits_per_kind:
                    break

        if smc_string_hits:
            self.emitter.add(
                "smc_gpio_state_model",
                "smc_media_tray_strings",
                "low",
                "observed_in_binary",
                "Tray/eject/media-ready strings observed",
                f"{len(smc_string_hits)} SMC/media/tray string(s)",
                value=smc_string_hits,
                tags=["smc", "tray", "cd_ready", "strings"],
            )

        for line in self.asm_lines:
            if line.address is None:
                continue

            direct_refs = self.direct_sfr_accesses(line)
            direct_values = [d["value_int"] for d in direct_refs]
            for d in direct_refs:
                direct_sfr_hits.append({
                    "line": line.idx + 1,
                    "addr": f"0x{line.address:X}",
                    "value": d["value"],
                    "mnemonic": line.mnemonic,
                    "text": line.text,
                    "context": context_lines(self.raw_lines, line.idx, self.args.context_radius),
                    "access_hint": d["access_hint"],
                })

            # Preserve lower-confidence breadcrumbs for Stage 6 without allowing
            # generic constants/jump targets to inflate high-confidence GPIO counts.
            for val in self.loose_sfr_immediate_mentions(line, direct_values):
                loose_sfr_mentions.append({
                    "line": line.idx + 1,
                    "addr": f"0x{line.address:X}",
                    "value": f"0x{val:04X}",
                    "mnemonic": line.mnemonic,
                    "text": line.text,
                    "context": context_lines(self.raw_lines, line.idx, self.args.context_radius),
                    "access_hint": "generic_immediate_demoted",
                })

            for val in line.numbers:
                if val in LEGACY_DEPRECATED_GPIO_ADDRS:
                    legacy_hits.append({
                        "line": line.idx + 1,
                        "addr": f"0x{line.address:X}",
                        "value": f"0x{val:08X}",
                        "label": LEGACY_DEPRECATED_GPIO_ADDRS[val],
                        "text": line.text,
                        "context": context_lines(self.raw_lines, line.idx, self.args.context_radius),
                    })

        if direct_sfr_hits:
            counts = Counter(h["value"] for h in direct_sfr_hits)
            self.emitter.add(
                "smc_gpio_state_model",
                "direct_sfr_gpio_access_candidates",
                "high",
                "observed_in_asm",
                "Direct MN103S SFR/GPIO access candidates",
                f"{len(direct_sfr_hits)} direct 0x02xx/0x03xx memory access reference(s)",
                value={
                    "top_addresses": counts.most_common(32),
                    "hits": direct_sfr_hits[:self.args.max_hits_per_kind],
                    "filter": "parenthesized absolute memory operands only",
                },
                context=direct_sfr_hits[0]["context"],
                tags=["sfr", "gpio", "smc", "mn103s", "direct_memory_access"],
                metadata={"v3_filter": "generic_immediates_demoted"},
            )

        if loose_sfr_mentions:
            counts = Counter(h["value"] for h in loose_sfr_mentions)
            self.emitter.add(
                "smc_gpio_state_model",
                "sfr_like_immediate_mentions",
                "low",
                "observed_in_asm",
                "Demoted 0x02xx/0x03xx immediate mentions",
                f"{len(loose_sfr_mentions)} generic immediate/jump/constant mention(s); not treated as direct GPIO access",
                value={
                    "top_addresses": counts.most_common(32),
                    "hits": loose_sfr_mentions[:self.args.max_hits_per_kind],
                    "filter": "non-parenthesized immediates demoted",
                },
                context=loose_sfr_mentions[0]["context"],
                tags=["sfr", "gpio", "low_confidence", "demoted_immediate"],
                metadata={"v3_filter": "not_direct_memory_access"},
            )

        if legacy_hits:
            self.emitter.add(
                "smc_gpio_state_model",
                "deprecated_legacy_gpio_reference",
                "low",
                "project_note_deprecated",
                "Deprecated legacy GPIO address observed",
                "Legacy 0x9000A040-style mapping reference appeared in ASM; treat with caution",
                value=legacy_hits[:self.args.max_hits_per_kind],
                context=legacy_hits[0]["context"],
                tags=["gpio", "deprecated", "review"],
            )

        # Dedicated mechanical/eject command candidates from already detected SCSI comparisons.
        # Add a summary if command 0x1B is present.
        mechanical_hits = []
        for line in self.asm_lines:
            if line.address is None:
                continue
            if "cmp" in line.text.lower():
                for val in line.numbers:
                    if (val & 0xFF) == 0x1B and (0 <= val <= 255 or -128 <= val < 0):
                        mechanical_hits.append({
                            "line": line.idx + 1,
                            "addr": f"0x{line.address:X}",
                            "text": line.text,
                            "context": context_lines(self.raw_lines, line.idx, self.args.context_radius),
                        })
                        break

        if mechanical_hits:
            self.emitter.add(
                "smc_gpio_state_model",
                "tray_eject_command_0x1b_candidates",
                "medium",
                "observed_in_asm",
                "START_STOP_UNIT / tray-eject command candidates",
                f"{len(mechanical_hits)} compare-like 0x1B hit(s)",
                asm_addr=int(mechanical_hits[0]["addr"], 16),
                value=mechanical_hits[:self.args.max_hits_per_kind],
                context=mechanical_hits[0]["context"],
                tags=["tray", "eject", "mechanical", "cmd_0x1b"],
            )

        ready_contexts = []
        for s in strings:
            if re.search(r"(ready|disc|media|toc|mount|spin|focus|lock)", s["text"], re.I):
                ready_contexts.append(s)
                if len(ready_contexts) >= self.args.max_hits_per_kind:
                    break
        if ready_contexts:
            self.emitter.add(
                "smc_gpio_state_model",
                "cd_ready_media_ready_evidence",
                "low",
                "observed_in_binary",
                "CD_READY/media-ready evidence",
                "Media/disc/ready-related strings found; not treated as auth evidence",
                value=ready_contexts,
                tags=["cd_ready", "media_ready", "not_auth"],
                metadata={"cd_ready_semantics": "disc_or_media_ready_not_auth_ready_unless_proven"},
            )

    def access_hint(self, line: AsmLine) -> str:
        text = line.text.lower()
        if re.search(r"\([^)]*\)\s*,\s*d[0-3]", text):
            return "read_like"
        if re.search(r"d[0-3]\s*,\s*\([^)]*\)", text):
            return "write_like"
        if line.mnemonic.startswith("mov") or line.mnemonic.startswith("st") or line.mnemonic.startswith("ld"):
            return "move_or_store_related"
        return "unknown"

    def analyze_donor_auth(self) -> None:
        donor = is_probable_donor(self.item)
        hits = []

        for label, needles in DONOR_AUTH_TARGETS.items():
            for needle in needles:
                off = self.data.find(needle)
                if off != -1:
                    hits.append({"label": label, "offset": f"0x{off:X}", "value": needle.decode("ascii", errors="replace")})

        for const, label in SHA1_CONSTANTS.items():
            for endian in ("be", "le"):
                needle = const.to_bytes(4, "big" if endian == "be" else "little")
                off = self.data.find(needle)
                if off != -1:
                    hits.append({"label": label, "offset": f"0x{off:X}", "value": f"0x{const:08X}", "endian": endian})
                    break

        # Command pattern combination relevant to donor auth.
        auth_cmd_hits = []
        for line in self.asm_lines:
            if line.address is None:
                continue
            for val in line.numbers:
                if (val & 0xFF) in (0x5A, 0xAD, 0xC0, 0xE7, 0xFC, 0xFD) and (0 <= val <= 255 or -128 <= val < 0):
                    auth_cmd_hits.append({
                        "line": line.idx + 1,
                        "addr": f"0x{line.address:X}",
                        "value": f"0x{(val & 0xFF):02X}",
                        "text": line.text,
                    })
                    break

        scope = target_scope_from_item(self.item)
        if donor:
            if hits or auth_cmd_hits:
                self.emitter.add(
                    "donor_auth",
                    "donor_xbox_auth_evidence",
                    "medium" if hits else "low",
                    "observed_in_binary" if hits else "observed_in_asm",
                    "Donor Xbox authentication evidence",
                    "Donor firmware contains auth-like strings/constants/command paths",
                    value={"binary_hits": hits[:64], "auth_command_hits": auth_cmd_hits[:self.args.max_hits_per_kind]},
                    tags=["donor", "auth", "xbox"],
                    metadata={"donor_auth_expected": True},
                )
            else:
                self.emitter.add(
                    "donor_auth",
                    "donor_auth_expected_but_not_found",
                    "low",
                    "absence_observed",
                    "Donor auth evidence not found by current heuristics",
                    "Expected donor auth logic was not located by this pass; manual review needed",
                    tags=["donor", "auth", "review"],
                    metadata={"absence_is_not_proof": True},
                )
        else:
            if hits or auth_cmd_hits:
                kind = "auth_like_evidence_in_validation_reference" if scope["is_validation_reference"] else "auth_like_evidence_in_recipient_candidate"
                title = "Auth-like evidence in validation reference" if scope["is_validation_reference"] else "Auth-like evidence in recipient candidate"
                evidence_text = (
                    "Validation-reference payload contains auth-like constants or command hits; use for tool sanity, not transplant assumptions"
                    if scope["is_validation_reference"]
                    else "Recipient-candidate payload contains auth-like constants or command hits; review before interpretation"
                )
                self.emitter.add(
                    "donor_auth",
                    kind,
                    "low",
                    "observed_in_binary_or_asm",
                    title,
                    evidence_text,
                    value={"binary_hits": hits[:64], "auth_command_hits": auth_cmd_hits[:self.args.max_hits_per_kind]},
                    tags=["auth", scope["target_scope"], "review"],
                    metadata={
                        "recipient_auth_absence_is_failure": False,
                        "target_scope": scope["target_scope"],
                        "tool_validation_target": scope["tool_validation_target"],
                    },
                )
            else:
                # Keep a machine-readable non-finding note only if requested.
                if self.args.emit_expected_absence_notes:
                    self.emitter.add(
                        "donor_auth",
                        "auth_absence_expected_for_non_donor",
                        "info",
                        "scope_rule",
                        "No donor auth evidence found; expected for this target scope",
                        "Only GDR-8050L is expected to contain original Xbox donor auth logic",
                        tags=["auth", "expected_absence", scope["target_scope"]],
                        metadata={"target_scope": scope["target_scope"]},
                    )

    def analyze_flash_candidates(self) -> None:
        hits = []
        for i, line in enumerate(self.asm_lines):
            if line.address is None:
                continue
            nums = set(n & 0xFFFFFFFF for n in line.numbers)
            matched = {n: FLASH_CONSTANTS[n] for n in nums if n in FLASH_CONSTANTS}
            if matched:
                window = self.asm_lines[max(0, i - 12):min(len(self.asm_lines), i + 13)]
                window_nums = set()
                window_text = "\n".join(w.text.lower() for w in window)
                for w in window:
                    for n in w.numbers:
                        if n in FLASH_CONSTANTS:
                            window_nums.add(n)
                score = len(window_nums)
                if score >= 3 or ("flash" in window_text):
                    hits.append({
                        "line": i + 1,
                        "addr": f"0x{line.address:X}",
                        "matched_constants": {f"0x{k:X}": v for k, v in matched.items()},
                        "window_constant_count": score,
                        "context": context_lines(self.raw_lines, i, self.args.context_radius + 4),
                    })
        if hits:
            self.emitter.add(
                "flash_repacker",
                "flash_routine_candidate",
                "medium",
                "observed_in_asm",
                "Flash write/erase routine candidates",
                f"{len(hits)} flash unlock/program pattern window(s)",
                asm_addr=int(hits[0]["addr"], 16),
                value=hits[:self.args.max_hits_per_kind],
                context=hits[0]["context"],
                tags=["flash", "repacker", "patching"],
            )

    def analyze_checksum_candidates(self) -> None:
        # Candidate discovery only; no verification here.
        loop_hits = []
        crc_hits = []
        for i, line in enumerate(self.asm_lines):
            if line.address is None:
                continue
            text = line.text.lower()

            for val in line.numbers:
                if val in CRC_CONSTANTS:
                    crc_hits.append({
                        "line": i + 1,
                        "addr": f"0x{line.address:X}",
                        "constant": f"0x{val:X}",
                        "label": CRC_CONSTANTS[val],
                        "context": context_lines(self.raw_lines, i, self.args.context_radius),
                    })

            if re.search(r"\badd\b", text) and re.search(r"d[0-3]\s*,\s*d[0-3]", text):
                window = "\n".join(l.text.lower() for l in self.asm_lines[max(0, i - 30):min(len(self.asm_lines), i + 30)])
                if "cmp" in window and re.search(r"\b(bne|bcc|bcs|bra|beq)\b", window):
                    stride = "unknown"
                    if "inc4" in window:
                        stride = "32bit_or_4byte"
                    elif "movhu" in window or "exthu" in window:
                        stride = "16bit_candidate"
                    elif "movbu" in window:
                        stride = "8bit_candidate"
                    loop_hits.append({
                        "line": i + 1,
                        "addr": f"0x{line.address:X}",
                        "stride_hint": stride,
                        "context": context_lines(self.raw_lines, i, self.args.context_radius + 8),
                    })

        if loop_hits:
            self.emitter.add(
                "checksum",
                "checksum_loop_candidate",
                "medium",
                "observed_in_asm",
                "Checksum loop candidates",
                f"{len(loop_hits)} accumulator+loop pattern(s)",
                asm_addr=int(loop_hits[0]["addr"], 16),
                value=loop_hits[:self.args.max_hits_per_kind],
                context=loop_hits[0]["context"],
                tags=["checksum", "candidate_not_verified"],
                metadata={"verification_status": "not_verified", "stage6_required": True},
            )
        if crc_hits:
            self.emitter.add(
                "checksum",
                "crc_constant_candidate",
                "low",
                "observed_in_asm",
                "CRC polynomial constants observed",
                f"{len(crc_hits)} CRC-like constant hit(s)",
                asm_addr=int(crc_hits[0]["addr"], 16),
                value=crc_hits[:self.args.max_hits_per_kind],
                context=crc_hits[0]["context"],
                tags=["checksum", "crc", "candidate_not_verified"],
                metadata={"verification_status": "not_verified", "stage6_required": True},
            )

    def analyze_servo_candidates(self) -> None:
        # Servo evidence is intentionally broad. It collects hints for later review.
        string_hits = []
        strings = extract_ascii_strings(self.data, 5, 1000)
        servo_re = re.compile(r"(servo|focus|track|tracking|spindle|laser|gain|offset|bh6590|an221|dac)", re.I)
        for s in strings:
            if servo_re.search(s["text"]):
                string_hits.append(s)
                if len(string_hits) >= self.args.max_hits_per_kind:
                    break

        const_hits = []
        for i, line in enumerate(self.asm_lines):
            if line.address is None:
                continue
            matched = {}
            for val in line.numbers:
                if val in SERVO_HINT_CONSTANTS:
                    matched[f"0x{val:X}"] = SERVO_HINT_CONSTANTS[val]
            if matched:
                const_hits.append({
                    "line": i + 1,
                    "addr": f"0x{line.address:X}",
                    "matched": matched,
                    "text": line.text,
                    "context": context_lines(self.raw_lines, i, self.args.context_radius),
                })

        if string_hits or const_hits:
            self.emitter.add(
                "servo_hardware",
                "servo_focus_tracking_candidate",
                "low",
                "observed_in_binary_or_asm",
                "Servo/focus/tracking evidence candidates",
                "Servo-related strings or project-note register-offset constants observed",
                value={"string_hits": string_hits, "constant_hits": const_hits[:self.args.max_hits_per_kind]},
                context=const_hits[0]["context"] if const_hits else [],
                tags=["servo", "focus", "tracking", "project_note_unverified"],
            )

    def analyze_memory_repacker_candidates(self) -> None:
        # RAM/SRAM and code cave evidence.
        ranges = [
            (0x40000000, 0x40020000, "sram_ram_reference"),
            (0x10C00000, 0x10D00000, "dram_buffer_reference"),
        ]
        hits = []
        for i, line in enumerate(self.asm_lines):
            if line.address is None:
                continue
            for val in line.numbers:
                for lo, hi, label in ranges:
                    if lo <= val < hi:
                        hits.append({
                            "line": i + 1,
                            "addr": f"0x{line.address:X}",
                            "value": f"0x{val:08X}",
                            "label": label,
                            "text": line.text,
                            "context": context_lines(self.raw_lines, i, self.args.context_radius),
                        })
                        break
        if hits:
            counts = Counter(h["label"] for h in hits)
            self.emitter.add(
                "memory_repacker",
                "ram_sram_dram_reference_candidates",
                "medium",
                "observed_in_asm",
                "RAM/SRAM/DRAM reference candidates",
                f"{len(hits)} memory-region reference(s)",
                value={"counts": dict(counts), "hits": hits[:self.args.max_hits_per_kind]},
                context=hits[0]["context"],
                tags=["memory_map", "sram", "ram", "dram", "repacker"],
            )

        # Simple cave list from padding runs.
        cave_runs = []
        for value in (0x00, 0xFF):
            for r in find_runs(self.data, value, min_len=self.args.min_cave_size, max_results=64):
                off = r["offset"]
                cave_runs.append({
                    "offset": f"0x{off:X}",
                    "vma": f"0x{offset_to_vma(off, self.base_addr):X}" if self.base_addr is not None else None,
                    "length": r["length"],
                    "fill": r["value"],
                })
        if cave_runs:
            self.emitter.add(
                "memory_repacker",
                "code_cave_candidates",
                "medium",
                "observed_in_binary",
                "Padding/code-cave candidates",
                f"{len(cave_runs)} padding run(s) >= {self.args.min_cave_size} bytes",
                value=cave_runs[:self.args.max_padding_runs],
                tags=["cave", "repacker", "patching"],
            )

    def analyze_utilities(self) -> None:
        patterns = [
            ("memcpy_like", re.compile(r"mov(?:bu|hu)?\s+\(a[0-3]\).*?\n.*mov(?:bu|hu)?\s+d[0-3]\s*,\s*\(a[0-3]\)", re.I | re.S)),
            ("memset_like", re.compile(r"mov(?:bu|hu)?\s+d[0-3]\s*,\s*\(a[0-3]\).*?\n.*inc", re.I | re.S)),
            ("delay_loop_like", re.compile(r"(add\s+-1|sub\s+1|inc).*\n.*\b(bne|bgt|bls|bcc|bcs)\b", re.I | re.S)),
        ]
        joined_windows = []
        for i in range(0, len(self.asm_lines), 4):
            text = "\n".join(l.text for l in self.asm_lines[i:i + 12])
            addr = next((l.address for l in self.asm_lines[i:i + 12] if l.address is not None), None)
            if addr is not None:
                joined_windows.append((i, addr, text))

        for name, pat in patterns:
            hits = []
            for i, addr, text in joined_windows:
                if pat.search(text):
                    hits.append({
                        "line": i + 1,
                        "addr": f"0x{addr:X}",
                        "context": context_lines(self.raw_lines, i, self.args.context_radius),
                    })
                    if len(hits) >= self.args.max_hits_per_kind:
                        break
            if hits:
                self.emitter.add(
                    "native_utilities",
                    name,
                    "low",
                    "observed_in_asm",
                    f"Native utility candidate: {name}",
                    f"{len(hits)} pattern window(s)",
                    asm_addr=int(hits[0]["addr"], 16),
                    value=hits,
                    context=hits[0]["context"],
                    tags=["native_utility", name],
                )


def analyze_payload_worker(payload: Tuple[str, Dict[str, Any], int, argparse.Namespace]) -> Dict[str, Any]:
    """ProcessPool worker wrapper."""
    workspace_str, item, idx, args = payload
    try:
        analyzer = PayloadAnalyzer(Path(workspace_str), item, idx, args)
        return analyzer.analyze()
    except Exception as e:
        model, version = model_version_from_item(item)
        return {
            "record_id": f"stage5b_{idx:04d}",
            "record_type": "stage5b_payload_analysis",
            "input_index": idx,
            "model": model,
            "version": version,
            "role": item.get("role_class") or item.get("role") or "unknown",
            "container_type": item.get("container_type", "unknown"),
            "analysis_scope": target_scope_from_item(item),
            "plaintext_rel": item.get("plaintext_rel", ""),
            "asm_rel": item.get("asm_rel", ""),
            "input": item,
            "status": "WORKER_ERROR",
            "notes": [f"{type(e).__name__}: {e}"],
            "binary_profile": {},
            "asm_profile": {},
            "findings": [],
        }


class Stage5BAnalyzer:
    def __init__(self, args: argparse.Namespace):
        self.args = args
        self.workspace = Path(args.workspace).resolve()
        self.inputs_path = Path(args.inputs) if args.inputs else self.workspace / "stage5b_analyzer_inputs.json"
        if not self.inputs_path.is_absolute():
            self.inputs_path = self.workspace / self.inputs_path

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

        self.counts = {
            "analysis_inputs_seen": 0,
            "inputs_selected": 0,
            "payloads_analyzed": 0,
            "missing_binary": 0,
            "missing_asm": 0,
            "sha256_mismatches": 0,
            "total_findings": 0,
            "donor_payloads": 0,
            "validation_reference_payloads": 0,
            "recipient_candidate_payloads": 0,
            "recipient_or_other_payloads": 0,
            "smc_gpio_findings": 0,
            "donor_auth_findings": 0,
            "checksum_candidates": 0,
            "flash_candidates": 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_stage5b_analyzer_{ts}.log"
        self.log(f"[INFO] {PRODUCER} started {utc_iso()}")
        self.log("[INFO] Stage 5B collects evidence only; no patching and no checksum verification")

    def load_inputs(self) -> None:
        self.log(f"[INFO] loading Stage 5B analyzer inputs: {self.inputs_path}")
        with self.inputs_path.open("r", encoding="utf-8") as f:
            self.inputs_doc = json.load(f)
        self.counts["analysis_inputs_seen"] = len(self.inputs_doc.get("analysis_inputs", []))

    def selected_inputs(self) -> List[Dict[str, Any]]:
        items = list(self.inputs_doc.get("analysis_inputs", []))
        if self.args.roles:
            allowed = set(self.args.roles)
            items = [x for x in items if (x.get("role") in allowed or x.get("role_class") in allowed)]
        if self.args.models:
            pats = [p.upper() for p in self.args.models]
            filtered = []
            for x in items:
                model, _ = model_version_from_item(x)
                text = " ".join([model, x.get("plaintext_rel", ""), x.get("asm_rel", "")]).upper()
                if any(p in text for p in pats):
                    filtered.append(x)
            items = filtered
        return items

    def run(self) -> None:
        self.setup()
        self.load_inputs()
        self.process()
        self.write_outputs()
        self.print_summary()

    def process(self) -> None:
        items = self.selected_inputs()
        self.counts["inputs_selected"] = len(items)
        worker_count = self.resolve_worker_count()
        self.log(f"[INFO] selected {len(items)} payload(s); workers={worker_count}")

        if worker_count <= 1 or len(items) <= 1:
            for idx, item in enumerate(items):
                record = analyze_payload_worker((str(self.workspace), item, idx, self.args))
                self.handle_record(record)
        else:
            payloads = [(str(self.workspace), item, idx, self.args) for idx, item in enumerate(items)]
            completed: Dict[int, Dict[str, Any]] = {}
            with ProcessPoolExecutor(max_workers=worker_count) as ex:
                futures = [ex.submit(analyze_payload_worker, payload) for payload in payloads]
                for fut in as_completed(futures):
                    record = fut.result()
                    completed[int(record.get("input_index", -1))] = record
                    self.log(
                        f"[INFO] completed index={record.get('input_index')} model={record.get('model')} "
                        f"role={record.get('role')} status={record.get('status')} findings={len(record.get('findings', []))}"
                    )

            for idx in sorted(completed):
                self.handle_record(completed[idx], already_logged=True)

    def resolve_worker_count(self) -> int:
        if self.args.workers is not None:
            return max(1, int(self.args.workers))
        cpu = os.cpu_count() or 2
        return max(1, min(cpu - 1, self.args.max_workers_default))

    def handle_record(self, record: Dict[str, Any], already_logged: bool = False) -> None:
        self.records.append(record)
        if record.get("status") == "WORKER_ERROR":
            self.counts["errors"] += 1
        self.update_counts(record)
        self.add_finding_rows(record)
        if not already_logged:
            self.log(
                f"[INFO] analyzed {record.get('plaintext_rel')} model={record.get('model')} "
                f"role={record.get('role')} status={record.get('status')} findings={len(record.get('findings', []))}"
            )

    def update_counts(self, record: Dict[str, Any]) -> None:
        status = record.get("status")
        if status == "MISSING_BINARY":
            self.counts["missing_binary"] += 1
            return
        if status == "MISSING_ASM":
            self.counts["missing_asm"] += 1
            return
        if status == "ANALYZED":
            self.counts["payloads_analyzed"] += 1

        if "sha256_mismatch" in record.get("notes", []):
            self.counts["sha256_mismatches"] += 1

        scope = record.get("analysis_scope") or {}
        if scope.get("is_donor_xbox_firmware") or scope.get("is_probable_donor_xbox_firmware"):
            self.counts["donor_payloads"] += 1
        elif scope.get("is_validation_reference"):
            self.counts["validation_reference_payloads"] += 1
            self.counts["recipient_or_other_payloads"] += 1
        elif scope.get("is_recipient_candidate"):
            self.counts["recipient_candidate_payloads"] += 1
            self.counts["recipient_or_other_payloads"] += 1
        else:
            self.counts["recipient_or_other_payloads"] += 1

        findings = record.get("findings", [])
        self.counts["total_findings"] += len(findings)
        for f in findings:
            cat = f.get("category")
            if cat == "smc_gpio_state_model":
                self.counts["smc_gpio_findings"] += 1
            if cat == "donor_auth":
                self.counts["donor_auth_findings"] += 1
            if cat == "checksum":
                self.counts["checksum_candidates"] += 1
            if cat == "flash_repacker":
                self.counts["flash_candidates"] += 1

    def add_finding_rows(self, record: Dict[str, Any]) -> None:
        for idx, f in enumerate(record.get("findings", [])):
            self.finding_rows.append({
                "record_id": record.get("record_id"),
                "finding_index": idx,
                "model": record.get("model"),
                "version": record.get("version"),
                "role": record.get("role"),
                "container_type": record.get("container_type"),
                "plaintext_rel": record.get("plaintext_rel"),
                "asm_rel": record.get("asm_rel"),
                "category": f.get("category"),
                "kind": f.get("kind"),
                "confidence": f.get("confidence"),
                "source_authority": f.get("source_authority"),
                "title": f.get("title"),
                "offset": f.get("offset"),
                "vma": f.get("vma"),
                "asm_addr": f.get("asm_addr"),
                "tags": ",".join(f.get("tags", [])),
                "evidence": f.get("evidence"),
            })

    def build_smc_gpio_doc(self) -> Dict[str, Any]:
        candidates = []
        for record in self.records:
            for f in record.get("findings", []):
                if f.get("category") == "smc_gpio_state_model":
                    candidates.append({
                        "record_id": record.get("record_id"),
                        "model": record.get("model"),
                        "version": record.get("version"),
                        "role": record.get("role"),
                        "plaintext_rel": record.get("plaintext_rel"),
                        "asm_rel": record.get("asm_rel"),
                        "finding": f,
                    })
        return {
            "schema_version": 1,
            "producer": PRODUCER,
            "created_utc": utc_iso(),
            "workspace": str(self.workspace),
            "source_manifest_rel": "stage5b_analysis_manifest.json",
            "project_reference_model": PROJECT_REFERENCE_MODEL,
            "candidate_count": len(candidates),
            "candidates": candidates,
        }

    def build_stage6_profile_candidates(self) -> Dict[str, Any]:
        per_payload: Dict[str, Dict[str, Any]] = {}
        for record in self.records:
            key = f"{record.get('model')}_{record.get('version')}_{record.get('role')}_{record.get('record_id')}"
            buckets: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
            for f in record.get("findings", []):
                buckets[f.get("category", "unknown")].append({
                    "kind": f.get("kind"),
                    "confidence": f.get("confidence"),
                    "title": f.get("title"),
                    "offset": f.get("offset"),
                    "vma": f.get("vma"),
                    "asm_addr": f.get("asm_addr"),
                    "source_authority": f.get("source_authority"),
                    "tags": f.get("tags", []),
                    "value": f.get("value"),
                    "metadata": f.get("metadata", {}),
                })
            per_payload[key] = {
                "model": record.get("model"),
                "version": record.get("version"),
                "role": record.get("role"),
                "container_type": record.get("container_type"),
                "plaintext_rel": record.get("plaintext_rel"),
                "asm_rel": record.get("asm_rel"),
                "base_addr": record.get("base_addr"),
                "analysis_scope": record.get("analysis_scope"),
                "binary_profile": record.get("binary_profile"),
                "candidate_buckets": dict(buckets),
                "profile_status": "candidate_not_verified",
                "stage6_required": True,
            }

        return {
            "schema_version": 1,
            "producer": PRODUCER,
            "created_utc": utc_iso(),
            "workspace": str(self.workspace),
            "source_manifest_rel": "stage5b_analysis_manifest.json",
            "profile_policy": {
                "not_final_profiles": True,
                "checksum_not_verified": True,
                "cd_ready_semantics": "media_disc_ready_not_auth_ready_unless_proven",
                "donor_auth_expected_only_in_gdr_8050l": True,
                "gdr_3120l_validation_reference_not_recipient": True,
                "html_notes_loose_reference_only": True,
            },
            "payload_count": len(per_payload),
            "payloads": per_payload,
        }

    def write_outputs(self) -> None:
        manifest = {
            "schema_version": 1,
            "producer": PRODUCER,
            "created_utc": utc_iso(),
            "workspace": str(self.workspace),
            "inputs_rel": relpath_str(self.inputs_path, self.workspace),
            "log_rel": relpath_str(self.log_path, self.workspace) if self.log_path else "",
            "counts": self.counts,
            "stage_boundaries": {
                "stage5b_does": [
                    "collect_binary_and_asm_evidence",
                    "parallel_payload_analysis",
                    "collect_smc_gpio_tray_cd_ready_candidates",
                    "tighten_sfr_gpio_to_direct_memory_operands",
                    "collect_donor_auth_candidates_with_scope_rules",
                    "collect_scsi_atapi_dispatch_candidates",
                    "collect_flash_checksum_memory_repacker_candidates",
                    "prepare_stage6_profile_candidates",
                ],
                "stage5b_does_not": [
                    "patch_firmware",
                    "verify_checksums",
                    "generate_final_profiles",
                    "assume_cd_ready_means_auth_ready",
                    "penalize_recipient_firmware_for_missing_donor_auth",
                ],
            },
            "locked_rules": {
                "cd_ready_is_media_ready_not_auth_ready_unless_proven": True,
                "donor_auth_expected_only_in_gdr_8050l": True,
                "gdr_3120l_is_validation_reference_not_recipient": True,
                "all_other_firmware_is_recipient_candidate_unless_excluded": True,
                "recipient_auth_absence_is_not_failure": True,
                "xbox_auth_state_clears_on_eject": True,
                "project_html_notes_are_loose_reference_only": True,
                "checksum_verification_deferred_to_stage6": True,
                "sfr_gpio_generic_immediates_are_demoted": True,
            },
            "project_reference_model": PROJECT_REFERENCE_MODEL,
            "records": self.records,
        }

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

        inv_path = self.workspace / "stage5b_findings_inventory.csv"
        fields = [
            "record_id", "finding_index", "model", "version", "role", "container_type",
            "plaintext_rel", "asm_rel", "category", "kind", "confidence",
            "source_authority", "title", "offset", "vma", "asm_addr", "tags", "evidence",
        ]
        with inv_path.open("w", encoding="utf-8", newline="") as f:
            w = csv.DictWriter(f, fieldnames=fields)
            w.writeheader()
            for row in self.finding_rows:
                w.writerow(row)
        self.log(f"[INFO] wrote Stage 5B findings inventory: {relpath_str(inv_path, self.workspace)}")

        smc_path = self.workspace / "stage5b_smc_gpio_candidates.json"
        smc_path.write_text(json.dumps(self.build_smc_gpio_doc(), indent=4), encoding="utf-8")
        self.log(f"[INFO] wrote SMC/GPIO candidates: {relpath_str(smc_path, self.workspace)}")

        stage6_path = self.workspace / "stage6_profile_candidates.json"
        stage6_path.write_text(json.dumps(self.build_stage6_profile_candidates(), indent=4), encoding="utf-8")
        self.log(f"[INFO] wrote Stage 6 profile candidates: {relpath_str(stage6_path, self.workspace)}")

    def print_summary(self) -> None:
        print()
        print("=" * 72)
        print("HLDS STAGE 5B ANALYZER SUMMARY")
        print("=" * 72)
        print(f"Workspace              : {self.workspace}")
        print(f"Inputs                 : {self.inputs_path}")
        print(f"Manifest               : {self.workspace / 'stage5b_analysis_manifest.json'}")
        print(f"Findings CSV           : {self.workspace / 'stage5b_findings_inventory.csv'}")
        print(f"SMC/GPIO candidates    : {self.workspace / 'stage5b_smc_gpio_candidates.json'}")
        print(f"Stage6 candidates      : {self.workspace / 'stage6_profile_candidates.json'}")
        print(f"Log file               : {self.log_path}")
        print("-" * 72)
        for k, v in self.counts.items():
            print(f"{k:24}: {v}")
        print("=" * 72)


def build_arg_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(
        description="Stage 5B HLDS/LG evidence analyzer. Collects evidence; does not patch or verify checksums."
    )
    ap.add_argument(
        "--workspace",
        default=r"D:\vscode-workspace\xbox-dvd-tools-v2",
        help="Workspace root containing stage5b_analyzer_inputs.json, binaries, and disassembly.",
    )
    ap.add_argument(
        "--inputs",
        default=None,
        help="Path to stage5b_analyzer_inputs.json. Defaults to <workspace>\\stage5b_analyzer_inputs.json.",
    )
    ap.add_argument("--roles", nargs="*", choices=["firmware", "stub", "unknown"], help="Optional role filter.")
    ap.add_argument("--models", nargs="*", help="Optional case-insensitive model/path substring filter.")
    ap.add_argument("--min-string-len", type=int, default=6)
    ap.add_argument("--max-strings", type=int, default=256)
    ap.add_argument("--max-identity-strings", type=int, default=64)
    ap.add_argument("--min-padding-run", type=int, default=256)
    ap.add_argument("--min-cave-size", type=int, default=512)
    ap.add_argument("--max-padding-runs", type=int, default=64)
    ap.add_argument("--vector-scan-size", type=int, default=0x200)
    ap.add_argument("--min-vector-hits", type=int, default=3)
    ap.add_argument("--min-pointer-cluster", type=int, default=8)
    ap.add_argument("--max-pointer-clusters", type=int, default=64)
    ap.add_argument("--min-dispatch-table-entries", type=int, default=64)
    ap.add_argument("--context-radius", type=int, default=5)
    ap.add_argument("--max-hits-per-kind", type=int, default=64)
    ap.add_argument(
        "--workers",
        type=int,
        default=None,
        help="Number of parallel worker processes. Default: min(cpu_count-1, --max-workers-default). Use 1 for sequential.",
    )
    ap.add_argument(
        "--max-workers-default",
        type=int,
        default=8,
        help="Cap for automatic worker selection when --workers is omitted. Default: 8.",
    )
    ap.add_argument(
        "--emit-expected-absence-notes",
        action="store_true",
        help="Emit explicit expected-absence findings for non-donor auth searches.",
    )
    return ap


def main(argv: Optional[Sequence[str]] = None) -> int:
    args = build_arg_parser().parse_args(argv)
    s = Stage5BAnalyzer(args)
    try:
        s.run()
        if s.counts.get("errors") or s.counts.get("missing_binary") or s.counts.get("missing_asm"):
            return 2
        return 0
    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())
