#!/usr/bin/env python3
"""
hlds_gdr_iscsi_trace_report.py - summarize live GDR iSCSI JSONL traces.

v213 purpose:
  * Keep v206 known-good behavior measurable before further upgrades.
  * Summarize Windows/DISCInfo/unlockdrive CDB surfaces.
  * Verify the v206/v208 stateful relock_keep_loaded workflow when present, and separate expected boundary-probe failures from unexpected CHECK CONDITION rows.

This is a reporting/regression helper only. It does not change emulation behavior.
"""
from __future__ import annotations

import argparse
import collections
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any, Iterable

OP_NAMES = {
    0x00: 'TEST UNIT READY',
    0x03: 'REQUEST SENSE',
    0x12: 'INQUIRY',
    0x15: 'MODE SELECT(6)',
    0x1A: 'MODE SENSE(6)',
    0x1B: 'START STOP UNIT',
    0x1D: 'SEND DIAGNOSTIC',
    0x1E: 'PREVENT/ALLOW MEDIUM REMOVAL',
    0x25: 'READ CAPACITY(10)',
    0x28: 'READ(10)',
    0x2B: 'SEEK(10)',
    0x43: 'READ TOC/PMA/ATIP',
    0x46: 'GET CONFIGURATION',
    0x4A: 'GET EVENT STATUS NOTIFICATION',
    0x51: 'READ DISC INFORMATION',
    0x55: 'MODE SELECT(10)',
    0x5A: 'MODE SENSE(10)',
    0x88: 'READ(16)',
    0xA0: 'REPORT LUNS',
    0xA8: 'READ(12)',
    0xAC: 'GET PERFORMANCE',
    0xAD: 'READ DVD STRUCTURE',
    0xBB: 'SET CD SPEED',
    0xFF: 'VENDOR/PROBE 0xFF',
}

LOCKED_MAX_LBA = 0x00001B4F
UNLOCKED_MAX_LBA = 0x00345B5F
KNOWN_GOOD_DELTA = 306080


def parse_hex(s: Any) -> bytes:
    if not s:
        return b''
    if isinstance(s, bytes):
        return s
    text = str(s).strip().replace(' ', '').replace(':', '').replace('\n', '')
    if not text:
        return b''
    try:
        return bytes.fromhex(text)
    except ValueError:
        return b''


def cdb_opcode(row: dict[str, Any]) -> int | None:
    cdb = parse_hex(row.get('cdb_hex'))
    return cdb[0] if cdb else None


def cdb_name(row: dict[str, Any]) -> str:
    op = cdb_opcode(row)
    if op is None:
        return ''
    return OP_NAMES.get(op, f'0x{op:02X}')


def read_payload(row: dict[str, Any], base_dirs: list[Path]) -> bytes:
    data = parse_hex(row.get('data_hex'))
    if data:
        return data
    preview = parse_hex(row.get('data_preview_hex'))
    try:
        data_len = int(row.get('data_len') or 0)
    except Exception:
        data_len = 0
    if preview and (not data_len or data_len <= len(preview)):
        return preview[:data_len] if data_len else preview
    fn = row.get('data_file')
    if not fn:
        return b''
    for base in base_dirs:
        p = base / str(fn)
        if p.exists():
            try:
                return p.read_bytes()
            except OSError:
                pass
        for child in base.glob('*_responses'):
            p = child / str(fn)
            if p.exists():
                try:
                    return p.read_bytes()
                except OSError:
                    pass
    return b''


def load_rows(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    with path.open('r', encoding='utf-8', errors='replace') as f:
        for lineno, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                rows.append({'index': None, 'tag': f'json_error_line_{lineno}', 'ok': False, 'error': str(e), 'raw_line': line[:200]})
                continue
            if isinstance(obj, dict):
                rows.append(obj)
    return rows


def row_index(row: dict[str, Any], fallback: int) -> int:
    try:
        return int(row.get('index'))
    except Exception:
        return fallback


def is_zero_lun(row: dict[str, Any]) -> bool:
    lun = parse_hex(row.get('lun_hex'))
    return not lun or all(b == 0 for b in lun)


def summarize_trace(path: Path, response_dir: Path | None = None) -> dict[str, Any]:
    rows = load_rows(path)
    base_dirs = [path.parent]
    if response_dir:
        base_dirs.insert(0, response_dir)

    cmd_rows = [r for r in rows if cdb_opcode(r) is not None]
    status_counts = collections.Counter(str(r.get('scsi_status') or '') for r in cmd_rows)
    source_counts = collections.Counter(str(r.get('source') or '') for r in cmd_rows)
    opcode_counts = collections.Counter(cdb_name(r) for r in cmd_rows)
    check_rows = [r for r in cmd_rows if str(r.get('scsi_status')) == 'CHECK CONDITION' or not bool(r.get('ok', True))]

    state_events: list[dict[str, Any]] = []
    reads: list[dict[str, Any]] = []
    read_caps: list[dict[str, Any]] = []
    seek10_rows: list[dict[str, Any]] = []
    nonzero_lun_good: list[dict[str, Any]] = []

    for i, r in enumerate(cmd_rows):
        op = cdb_opcode(r)
        idx = row_index(r, i)
        if r.get('state_event') or str(r.get('source') or '').startswith(('v204_stateful_auth_event', 'v206_stateful_start_stop')):
            state_events.append({
                'index': idx,
                'event': r.get('state_event') or r.get('source'),
                'auth_stage': r.get('dynamic_media_auth_stage'),
                'phase': r.get('dynamic_media_auth_phase') or r.get('auth_phase'),
                'sticky': r.get('dynamic_media_sticky_descramble'),
                'tray': r.get('dynamic_media_tray_state'),
                'media_present': r.get('dynamic_media_present'),
                'policy': r.get('dynamic_media_eject_policy'),
                'cdb_hex': r.get('cdb_hex'),
            })
        if op in (0x28, 0xA8, 0x88):
            reads.append({
                'index': idx,
                'kind': cdb_name(r),
                'host_lba': r.get('host_lba'),
                'blocks': r.get('blocks'),
                'media_lba': r.get('media_lba'),
                'effective_delta': r.get('effective_media_lba_delta'),
                'auth_stage': r.get('dynamic_media_auth_stage'),
                'source': r.get('source'),
                'status': r.get('scsi_status'),
                'data_len': r.get('data_len'),
                'read_map_note': r.get('read_map_note'),
                'read_map_mode': r.get('read_map_mode'),
                'media_profile_loaded': r.get('media_profile_loaded'),
                'media_is_redump_style': r.get('media_is_redump_style'),
                'media_layout_type': r.get('media_layout_type'),
            })
        if op == 0x25:
            payload = read_payload(r, base_dirs)
            max_lba = int.from_bytes(payload[:4], 'big') if len(payload) >= 4 else None
            block_size = int.from_bytes(payload[4:8], 'big') if len(payload) >= 8 else None
            read_caps.append({
                'index': idx,
                'max_lba': max_lba,
                'block_size': block_size,
                'auth_stage': r.get('dynamic_media_auth_stage'),
                'tray': r.get('dynamic_media_tray_state'),
                'media_present': r.get('dynamic_media_present'),
                'source': r.get('source'),
                'status': r.get('scsi_status'),
            })
        if op == 0x2B:
            seek10_rows.append({
                'index': idx,
                'seek_lba': r.get('seek_lba'),
                'visible_max_lba': r.get('visible_max_lba'),
                'auth_stage': r.get('dynamic_media_auth_stage'),
                'source': r.get('source'),
                'status': r.get('scsi_status'),
                'expected_boundary_failure': bool(r.get('expected_boundary_failure')),
                'error': r.get('error'),
            })
        if row_index(r, i) >= 0 and not is_zero_lun(r) and str(r.get('scsi_status')) == 'GOOD':
            nonzero_lun_good.append({'index': idx, 'lun_hex': r.get('lun_hex'), 'cdb': r.get('cdb_hex'), 'source': r.get('source')})

    events = [str(e.get('event') or '') for e in state_events]
    unlocked_idx = None
    eject_idx = None
    for e in state_events:
        if e.get('event') == 'partition1_unlock' and unlocked_idx is None:
            unlocked_idx = int(e.get('index') or 0)
        if e.get('event') == 'start_stop_eject' and eject_idx is None:
            eject_idx = int(e.get('index') or 0)

    def after(idx: int | None, row: dict[str, Any]) -> bool:
        return idx is not None and int(row.get('index') or 0) > idx

    locked_cap_seen = any(rc.get('max_lba') == LOCKED_MAX_LBA for rc in read_caps)
    unlocked_cap_seen = any(rc.get('max_lba') == UNLOCKED_MAX_LBA for rc in read_caps)
    post_unlock_unlocked_cap = any(after(unlocked_idx, rc) and rc.get('max_lba') == UNLOCKED_MAX_LBA for rc in read_caps)
    def _is_unlocked_game_read(r: dict[str, Any]) -> bool:
        if r.get('status') != 'GOOD' or str(r.get('auth_stage')) != 'unlocked':
            return False
        # Legacy contiguous/hybrid path.
        if r.get('effective_delta') == KNOWN_GOOD_DELTA:
            return True
        # Corrected Redump-style media-profile path.
        note = str(r.get('read_map_note') or '')
        if bool(r.get('media_profile_loaded')) and bool(r.get('media_is_redump_style')) and note.startswith('redump_unlocked_'):
            return True
        if str(r.get('media_layout_type') or '') == 'original_xbox_xgd1_redump_style_2048' and note.startswith('redump_unlocked_'):
            return True
        return False

    unlocked_delta_read_seen = any(_is_unlocked_game_read(r) for r in reads)
    locked_direct_read_seen = any((r.get('effective_delta') in (0, '0') or r.get('media_lba') == r.get('host_lba')) and r.get('status') == 'GOOD' for r in reads)
    relock_eject_seen = any(e.get('event') == 'start_stop_eject' and e.get('auth_stage') == 'locked' for e in state_events)
    keep_loaded_eject_seen = any(e.get('event') == 'start_stop_eject' and bool(e.get('media_present')) and str(e.get('policy')) in ('relock_keep_loaded','keep_loaded','virtual_keep_loaded') for e in state_events)
    post_eject_locked_read_seen = any(after(eject_idx, r) and (r.get('effective_delta') in (0, '0') or r.get('media_lba') == r.get('host_lba')) and r.get('status') == 'GOOD' for r in reads)

    def _is_expected_boundary_check(r: dict[str, Any]) -> bool:
        # v211+ bridge rows may either set an explicit expected_boundary_failure flag
        # or identify the failure by source.  Treat both forms as expected so the
        # local fleet does not flag intentional locked/relocked LBA-boundary probes.
        src = str(r.get('source') or '')
        if src in ('v213_seek10_lba_out_of_range', 'v213_read_lba_out_of_range'):
            return True
        return bool(r.get('expected_boundary_failure')) and src in ('v213_seek10_lba_out_of_range', 'v213_read_lba_out_of_range')

    expected_boundary_check_rows = [r for r in check_rows if _is_expected_boundary_check(r)]
    unexpected_check_rows = [r for r in check_rows if r not in expected_boundary_check_rows]

    regression = {
        'single_lun_only': not nonzero_lun_good,
        'locked_capacity_seen': locked_cap_seen,
        'auth_challenge1_seen': 'challenge1' in events,
        'auth_challenge2_seen': 'challenge2' in events,
        'partition1_unlock_seen': 'partition1_unlock' in events,
        'sticky_descramble_seen': 'sticky_descramble_mode31' in events,
        'unlocked_capacity_seen': unlocked_cap_seen,
        'post_unlock_unlocked_capacity_seen': post_unlock_unlocked_cap,
        'locked_direct_read_seen': locked_direct_read_seen,
        'unlocked_delta_read_seen': unlocked_delta_read_seen,
        'relock_eject_seen': relock_eject_seen,
        'keep_loaded_eject_seen': keep_loaded_eject_seen,
        'post_eject_locked_direct_read_seen': post_eject_locked_read_seen,
    }

    return {
        'trace': str(path),
        'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
        'total_rows': len(rows),
        'scsi_command_rows': len(cmd_rows),
        'status_counts': dict(status_counts),
        'source_counts': dict(source_counts),
        'opcode_counts': dict(opcode_counts),
        'check_condition_count': len(check_rows),
        'expected_boundary_check_condition_count': len(expected_boundary_check_rows),
        'unexpected_check_condition_count': len(unexpected_check_rows),
        'check_conditions': [
            {'index': row_index(r, i), 'cdb_name': cdb_name(r), 'cdb_hex': r.get('cdb_hex'), 'sense_key': r.get('sense_key'), 'asc': r.get('asc'), 'ascq': r.get('ascq'), 'error': r.get('error'), 'source': r.get('source')}
            for i, r in enumerate(check_rows[:100])
        ],
        'state_events': state_events,
        'read_capacity': read_caps,
        'seek10': seek10_rows[:1000],
        'reads': reads[:1000],
        'nonzero_lun_good': nonzero_lun_good[:100],
        'regression': regression,
        'known_good_v206_complete': all(regression[k] for k in (
            'single_lun_only',
            'locked_capacity_seen',
            'auth_challenge1_seen',
            'auth_challenge2_seen',
            'partition1_unlock_seen',
            'sticky_descramble_seen',
            'unlocked_capacity_seen',
            'post_unlock_unlocked_capacity_seen',
            'locked_direct_read_seen',
            'unlocked_delta_read_seen',
        )),
        'known_good_v206_eject_cycle_complete': all(regression[k] for k in (
            'single_lun_only',
            'relock_eject_seen',
            'keep_loaded_eject_seen',
            'post_eject_locked_direct_read_seen',
        )),
    }


def write_md(report: dict[str, Any], path: Path) -> None:
    lines: list[str] = []
    lines.append('# GDR-8050L live iSCSI trace report')
    lines.append('')
    lines.append(f"trace: `{report['trace']}`")
    lines.append(f"rows: {report['total_rows']} total / {report['scsi_command_rows']} SCSI commands")
    lines.append(f"known_good_v206_complete: `{str(report['known_good_v206_complete']).lower()}`")
    lines.append(f"known_good_v206_eject_cycle_complete: `{str(report['known_good_v206_eject_cycle_complete']).lower()}`")
    lines.append(f"check_condition_count: `{report.get('check_condition_count', 0)}`")
    lines.append(f"expected_boundary_check_condition_count: `{report.get('expected_boundary_check_condition_count', 0)}`")
    lines.append(f"unexpected_check_condition_count: `{report.get('unexpected_check_condition_count', 0)}`")
    lines.append('')
    lines.append('## Regression checks')
    lines.append('')
    lines.append('| check | result |')
    lines.append('|---|---|')
    for k, v in report['regression'].items():
        lines.append(f'| `{k}` | `{str(v).lower()}` |')
    lines.append('')
    lines.append('## Command counts')
    lines.append('')
    lines.append('| opcode | count |')
    lines.append('|---|---:|')
    for k, v in sorted(report['opcode_counts'].items(), key=lambda kv: (-kv[1], kv[0])):
        lines.append(f'| `{k}` | {v} |')
    lines.append('')
    lines.append('## Source counts')
    lines.append('')
    lines.append('| source | count |')
    lines.append('|---|---:|')
    for k, v in sorted(report['source_counts'].items(), key=lambda kv: (-kv[1], kv[0])):
        lines.append(f'| `{k}` | {v} |')
    lines.append('')
    lines.append('## State events')
    lines.append('')
    lines.append('| index | event | auth_stage | sticky | tray | media_present | policy |')
    lines.append('|---:|---|---|---|---|---|---|')
    for e in report['state_events']:
        lines.append(f"| {e.get('index')} | `{e.get('event')}` | `{e.get('auth_stage')}` | `{e.get('sticky')}` | `{e.get('tray')}` | `{e.get('media_present')}` | `{e.get('policy')}` |")
    lines.append('')
    lines.append('## READ CAPACITY observations')
    lines.append('')
    lines.append('| index | max_lba | sectors | block_size | auth_stage | media_present | source |')
    lines.append('|---:|---:|---:|---:|---|---|---|')
    for rc in report['read_capacity']:
        ml = rc.get('max_lba')
        sectors = (int(ml) + 1) if isinstance(ml, int) else ''
        lines.append(f"| {rc.get('index')} | {'' if ml is None else ml} | {sectors} | {'' if rc.get('block_size') is None else rc.get('block_size')} | `{rc.get('auth_stage')}` | `{rc.get('media_present')}` | `{rc.get('source')}` |")
    lines.append('')
    lines.append('## READ map sample')
    lines.append('')
    lines.append('| index | kind | host_lba | blocks | media_lba | delta | auth_stage |')
    lines.append('|---:|---|---:|---:|---:|---:|---|')
    for r in report['reads'][:50]:
        lines.append(f"| {r.get('index')} | `{r.get('kind')}` | {r.get('host_lba')} | {r.get('blocks')} | {r.get('media_lba')} | {r.get('effective_delta')} | `{r.get('auth_stage')}` |")
    if report.get('seek10'):
        lines.append('')
        lines.append('## SEEK(10) boundary observations')
        lines.append('')
        lines.append('| index | seek_lba | visible_max_lba | status | auth_stage | expected_boundary_failure | source |')
        lines.append('|---:|---:|---:|---|---|---|---|')
        for r in report.get('seek10', [])[:100]:
            lines.append(f"| {r.get('index')} | {r.get('seek_lba')} | {r.get('visible_max_lba')} | `{r.get('status')}` | `{r.get('auth_stage')}` | `{str(r.get('expected_boundary_failure')).lower()}` | `{r.get('source')}` |")
    if report['check_conditions']:
        lines.append('')
        lines.append('## CHECK CONDITION rows')
        lines.append('')
        lines.append('| index | cdb | sense | asc/ascq | error | source |')
        lines.append('|---:|---|---|---|---|---|')
        for r in report['check_conditions']:
            lines.append(f"| {r.get('index')} | `{r.get('cdb_name')}` `{r.get('cdb_hex')}` | `{r.get('sense_key')}` | `{r.get('asc')}/{r.get('ascq')}` | `{r.get('error')}` | `{r.get('source')}` |")
    lines.append('')
    path.write_text('\n'.join(lines), encoding='utf-8')


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description='summarize GDR-8050L live iSCSI JSONL traces and check v206 known-good regressions')
    ap.add_argument('jsonl', type=Path, help='live iSCSI JSONL log')
    ap.add_argument('--response-dir', type=Path, default=None, help='optional response directory containing data_file payloads')
    ap.add_argument('--json', type=Path, default=None, help='write machine-readable report JSON')
    ap.add_argument('--md', type=Path, default=None, help='write markdown report')
    ap.add_argument('--fail-on-regression', action='store_true', help='exit 2 if known_good_v206_complete is false')
    args = ap.parse_args(argv)

    report = summarize_trace(args.jsonl, args.response_dir)
    if args.json:
        args.json.write_text(json.dumps(report, indent=2), encoding='utf-8')
    if args.md:
        write_md(report, args.md)
    print(f"[TRACE-REPORT] rows={report['total_rows']} scsi={report['scsi_command_rows']} checks={report['check_condition_count']} expected_boundary={report.get('expected_boundary_check_condition_count', 0)} unexpected={report.get('unexpected_check_condition_count', 0)} known_good={str(report['known_good_v206_complete']).lower()} eject_cycle={str(report['known_good_v206_eject_cycle_complete']).lower()}")
    if args.fail_on_regression and not report['known_good_v206_complete']:
        return 2
    return 0


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