from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping, TextIO

try:  # Keep this module useful even when copied away from the decompiler tree.
    from . import protocol_trace as _protocol_trace
except ImportError:  # pragma: no cover - exercised only outside package imports.
    _protocol_trace = None


CHECKSUM_SEED = getattr(_protocol_trace, "CHECKSUM_SEED", 0x5A)
FRAME_LENGTH = getattr(_protocol_trace, "FRAME_LENGTH", 6)
CAPTURE_LINE_RE = re.compile(
    r"^\s*(?P<time>\d{1,2}:\d{2}:\d{2}(?:\.\d{1,6})?)\s+"
    r"(?P<direction>RX|TX|FRAME)\s+"
    r"(?P<count>\d+)(?:\s+bytes?)?\s+"
    r"(?P<byte_text>.*?)\s*$",
    re.IGNORECASE,
)
HEX_BYTE_RE = re.compile(r"\b[0-9A-Fa-f]{2}\b")

_FALLBACK_OBSERVED_TX_REPORT_CANDIDATES: dict[tuple[int, int], dict[str, str]] = {
    (0x0000, 0x0080): {
        "name_candidate": "heartbeat_alive_candidate",
    },
    (0x0015, 0x8000): {
        "name_candidate": "call_button_candidate",
        "state_candidate": "active",
    },
    (0x0015, 0x0000): {
        "name_candidate": "call_button_candidate",
        "state_candidate": "inactive",
    },
    (0x0007, 0x8000): {
        "name_candidate": "cam_power_button_candidate",
        "state_candidate": "active",
    },
}
OBSERVED_TX_REPORT_CANDIDATES = getattr(
    _protocol_trace,
    "OBSERVED_TX_REPORT_CANDIDATES",
    _FALLBACK_OBSERVED_TX_REPORT_CANDIDATES,
)


@dataclass(frozen=True)
class CaptureChunk:
    chunk_index: int
    timestamp: str
    timestamp_ms: int
    analyzer_direction: str
    device_direction: str
    declared_count: int
    bytes: tuple[int, ...]
    raw_line: str


def checksum_for(frame_prefix: Iterable[int]) -> int:
    if _protocol_trace is not None and hasattr(_protocol_trace, "checksum_for"):
        return int(_protocol_trace.checksum_for(frame_prefix))
    value = CHECKSUM_SEED
    for byte in frame_prefix:
        value ^= byte & 0xFF
    return value & 0xFF


def parse_capture_text(text: str) -> list[CaptureChunk]:
    chunks: list[CaptureChunk] = []
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line:
            continue
        match = CAPTURE_LINE_RE.match(line)
        if not match:
            continue
        byte_values = tuple(int(token, 16) for token in HEX_BYTE_RE.findall(match.group("byte_text")))
        raw_direction = match.group("direction").lower()
        analyzer_direction = "rx" if raw_direction == "frame" else raw_direction
        chunks.append(
            CaptureChunk(
                chunk_index=len(chunks),
                timestamp=match.group("time"),
                timestamp_ms=_timestamp_ms(match.group("time")),
                analyzer_direction=analyzer_direction,
                device_direction=_device_direction(analyzer_direction),
                declared_count=int(match.group("count")),
                bytes=byte_values,
                raw_line=raw_line,
            )
        )
        if len(byte_values) != int(match.group("count")):
            # Preserve the chunk and expose the mismatch in analysis instead of dropping capture evidence.
            continue
    return chunks


def analyze_capture_text(text: str) -> dict[str, Any]:
    return analyze_capture_chunks(parse_capture_text(text))


def analyze_capture_chunks(chunks: Iterable[CaptureChunk]) -> dict[str, Any]:
    chunk_list = list(chunks)
    frames = _recombine_frames(chunk_list)
    groups = _repeated_groups(frames)
    gate_session_hints = _gate_session_hints(frames)
    return {
        "kind": "h8536_protocol_capture",
        "frame_length": FRAME_LENGTH,
        "checksum_model": {
            "algorithm": "xor",
            "seed": CHECKSUM_SEED,
            "seed_hex": _h8(CHECKSUM_SEED),
            "covered_offsets": [0, 1, 2, 3, 4],
            "checksum_offset": 5,
        },
        "chunks": [_chunk_dict(chunk) for chunk in chunk_list],
        "chunk_count": len(chunk_list),
        "frames": frames,
        "frame_count": len(frames),
        "repeated_groups": groups,
        "repeated_group_count": len(groups),
        "gate_session_hints": gate_session_hints,
        "direction_note": (
            "Capture RX is analyzer-perspective receive; these bytes are device-perspective TX."
        ),
    }


def format_text_report(analysis: Mapping[str, Any]) -> str:
    lines = [
        "H8/536 capture log",
        (
            f"chunks={analysis.get('chunk_count', 0)} "
            f"frames={analysis.get('frame_count', 0)} "
            f"repeated_groups={analysis.get('repeated_group_count', 0)}"
        ),
    ]
    for frame in analysis.get("frames", []):
        label = ""
        report = frame.get("report_candidate") or {}
        candidate = report.get("observed_candidate") or {}
        if candidate.get("name_candidate"):
            label = f" {candidate['name_candidate']}"
            if candidate.get("state_candidate"):
                label += f" state={candidate['state_candidate']}"
        split = " split" if frame.get("source_chunk_count", 0) > 1 else ""
        lines.append(
            (
                f"[{frame['frame_index']:04d}] {frame['timestamp']} "
                f"{frame['analyzer_direction'].upper()}=>device:{frame['device_direction']} "
                f"bytes={' '.join(frame['bytes_hex'])} checksum=ok{split} "
                f"index={report.get('index_hex')} value={report.get('value_hex')}{label}"
            )
        )
    for group in analysis.get("repeated_groups", []):
        cadence = group.get("cadence_ms") or {}
        cadence_text = "n/a"
        if cadence.get("average") is not None:
            cadence_text = (
                f"avg={cadence['average']:.1f}ms "
                f"min={cadence['min']}ms max={cadence['max']}ms"
            )
        lines.append(
            (
                f"group {group['bytes']} count={group['count']} "
                f"span={group['span_ms']}ms cadence={cadence_text}"
            )
        )
    hints = analysis.get("gate_session_hints") or {}
    names = hints.get("observed_autonomous_report_names") or []
    if names:
        lines.append("observed autonomous report candidates: " + ", ".join(names))
    heartbeat = hints.get("heartbeat_cadence_ms") or {}
    if heartbeat.get("count"):
        cadence_text = "n/a"
        if heartbeat.get("average") is not None:
            cadence_text = (
                f"avg={heartbeat['average']:.1f}ms "
                f"min={heartbeat['min']}ms max={heartbeat['max']}ms"
            )
        lines.append(f"heartbeat cadence count={heartbeat['count']} cadence={cadence_text}")
    for transition in hints.get("active_inactive_transitions", []):
        lines.append(
            (
                f"transition index={transition['index_hex']} "
                f"{transition['from_state']}->{transition['to_state']} "
                f"{transition['from_timestamp']}..{transition['to_timestamp']}"
            )
        )
    for interruption in hints.get("heartbeat_interruptions", []):
        interrupted_names = ", ".join(
            item["name_candidate"] for item in interruption.get("interrupted_by", [])
        )
        lines.append(
            (
                f"heartbeat gap {interruption['from_timestamp']}..{interruption['to_timestamp']} "
                f"gap={interruption['gap_ms']}ms interrupted_by={interrupted_names}"
            )
        )
    if hints.get("caveat"):
        lines.append(f"caveat: {hints['caveat']}")
    return "\n".join(lines)


def main(argv: list[str] | None = None, *, stdin: TextIO | None = None, stdout: TextIO | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Analyze timestamped H8/536 serial capture logs and recombine 6-byte frames."
    )
    parser.add_argument("input", nargs="?", help="Capture log path. Use '-' or omit to read stdin.")
    parser.add_argument("--json", action="store_true", help="Emit JSON instead of text.")
    args = parser.parse_args(argv)

    stdin = stdin or sys.stdin
    stdout = stdout or sys.stdout
    if args.input and args.input != "-":
        text = Path(args.input).read_text(encoding="utf-8")
    else:
        text = stdin.read()

    analysis = analyze_capture_text(text)
    if args.json:
        json.dump(analysis, stdout, indent=2, sort_keys=True)
        stdout.write("\n")
    else:
        stdout.write(format_text_report(analysis))
        stdout.write("\n")
    return 0


def _recombine_frames(chunks: list[CaptureChunk]) -> list[dict[str, Any]]:
    buffers: dict[str, list[dict[str, Any]]] = {}
    frames: list[dict[str, Any]] = []
    for chunk in chunks:
        key = chunk.analyzer_direction
        stream = buffers.setdefault(key, [])
        for offset, byte in enumerate(chunk.bytes):
            stream.append({"byte": byte, "chunk": chunk, "offset": offset})
            _drain_valid_frames(stream, frames)
    return frames


def _drain_valid_frames(stream: list[dict[str, Any]], frames: list[dict[str, Any]]) -> None:
    while len(stream) >= FRAME_LENGTH:
        candidate = stream[:FRAME_LENGTH]
        values = [int(item["byte"]) for item in candidate]
        if checksum_for(values[:5]) == values[5]:
            frames.append(_frame_dict(len(frames), candidate))
            del stream[:FRAME_LENGTH]
            continue

        realigned = False
        for start in range(1, len(stream) - FRAME_LENGTH + 1):
            window = stream[start : start + FRAME_LENGTH]
            values = [int(item["byte"]) for item in window]
            if checksum_for(values[:5]) == values[5]:
                del stream[:start]
                realigned = True
                break
        if not realigned:
            break


def _frame_dict(frame_index: int, items: list[dict[str, Any]]) -> dict[str, Any]:
    values = [int(item["byte"]) for item in items]
    chunks = [item["chunk"] for item in items]
    first: CaptureChunk = chunks[0]
    source_chunk_indexes = sorted({chunk.chunk_index for chunk in chunks})
    return {
        "frame_index": frame_index,
        "timestamp": first.timestamp,
        "timestamp_ms": first.timestamp_ms,
        "analyzer_direction": first.analyzer_direction,
        "device_direction": first.device_direction,
        "bytes": values,
        "bytes_hex": [_h8(value) for value in values],
        "checksum": {
            "valid": True,
            "expected": values[5],
            "expected_hex": _h8(values[5]),
            "actual": values[5],
            "actual_hex": _h8(values[5]),
        },
        "source_chunk_indexes": source_chunk_indexes,
        "source_chunk_count": len(source_chunk_indexes),
        "report_candidate": _tx_report_candidate(values),
    }


def _tx_report_candidate(frame: list[int]) -> dict[str, Any]:
    index = (frame[0] << 16) | (frame[1] << 8) | frame[2]
    value = (frame[3] << 8) | frame[4]
    candidate = OBSERVED_TX_REPORT_CANDIDATES.get((index, value))
    return {
        "encoding": "observed_tx_index_value_report_candidate",
        "confidence": "observed_candidate" if candidate else "unknown",
        "index": index,
        "index_hex": f"0x{index:06X}" if index > 0xFFFF else _h16(index),
        "value": value,
        "value_hex": _h16(value),
        "observed_candidate": dict(candidate) if candidate else None,
        "caveat": "Observed TX report names are capture labels, not proven protocol facts.",
    }


def _repeated_groups(frames: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
    by_bytes: dict[tuple[int, ...], list[Mapping[str, Any]]] = {}
    for frame in frames:
        by_bytes.setdefault(tuple(frame["bytes"]), []).append(frame)

    groups: list[dict[str, Any]] = []
    for values, members in by_bytes.items():
        if len(members) < 2:
            continue
        timestamps = [int(member["timestamp_ms"]) for member in members]
        deltas = [right - left for left, right in zip(timestamps, timestamps[1:])]
        groups.append(
            {
                "bytes": " ".join(_h8(value) for value in values),
                "count": len(members),
                "frame_indexes": [member["frame_index"] for member in members],
                "first_timestamp": members[0]["timestamp"],
                "last_timestamp": members[-1]["timestamp"],
                "span_ms": timestamps[-1] - timestamps[0],
                "cadence_ms": {
                    "samples": deltas,
                    "average": (sum(deltas) / len(deltas)) if deltas else None,
                    "min": min(deltas) if deltas else None,
                    "max": max(deltas) if deltas else None,
                },
            }
        )
    return sorted(groups, key=lambda group: (-int(group["count"]), str(group["bytes"])))


def _gate_session_hints(frames: list[Mapping[str, Any]]) -> dict[str, Any]:
    observed = [_observed_report_frame(frame) for frame in frames]
    observed = [item for item in observed if item is not None]
    by_name: dict[str, list[dict[str, Any]]] = {}
    for item in observed:
        by_name.setdefault(str(item["name_candidate"]), []).append(item)

    observed_reports = []
    for name, members in sorted(by_name.items()):
        observed_reports.append(
            {
                "name_candidate": name,
                "count": len(members),
                "first_timestamp": members[0]["timestamp"],
                "last_timestamp": members[-1]["timestamp"],
                "frame_indexes": [member["frame_index"] for member in members],
                "indexes_hex": sorted({str(member["index_hex"]) for member in members}),
                "values_hex": sorted({str(member["value_hex"]) for member in members}),
                "states": sorted(
                    {
                        str(member["state_candidate"])
                        for member in members
                        if member.get("state_candidate")
                    }
                ),
            }
        )

    heartbeat_frames = [
        item for item in observed if item.get("name_candidate") == "heartbeat_alive_candidate"
    ]
    heartbeat_timestamps = [int(item["timestamp_ms"]) for item in heartbeat_frames]
    heartbeat_deltas = [
        right - left for left, right in zip(heartbeat_timestamps, heartbeat_timestamps[1:])
    ]

    return {
        "observed_autonomous_report_names": sorted(by_name),
        "observed_reports": observed_reports,
        "active_inactive_transitions": _active_inactive_transitions(observed),
        "heartbeat_cadence_ms": {
            "count": len(heartbeat_frames),
            "samples": heartbeat_deltas,
            "average": (sum(heartbeat_deltas) / len(heartbeat_deltas)) if heartbeat_deltas else None,
            "min": min(heartbeat_deltas) if heartbeat_deltas else None,
            "max": max(heartbeat_deltas) if heartbeat_deltas else None,
        },
        "heartbeat_interruptions": _heartbeat_interruptions(observed),
        "caveat": (
            "Missing autonomous reports for other controls may reflect host/session gating "
            "or capture timing, not proof that local control state did not change."
        ),
        "evidence_scope": "capture_side_observation_only",
    }


def _observed_report_frame(frame: Mapping[str, Any]) -> dict[str, Any] | None:
    report = frame.get("report_candidate") or {}
    candidate = report.get("observed_candidate") or {}
    name = candidate.get("name_candidate")
    if not name:
        return None
    return {
        "frame_index": frame.get("frame_index"),
        "timestamp": frame.get("timestamp"),
        "timestamp_ms": frame.get("timestamp_ms"),
        "analyzer_direction": frame.get("analyzer_direction"),
        "device_direction": frame.get("device_direction"),
        "name_candidate": name,
        "state_candidate": candidate.get("state_candidate"),
        "index": report.get("index"),
        "index_hex": report.get("index_hex"),
        "value": report.get("value"),
        "value_hex": report.get("value_hex"),
    }


def _active_inactive_transitions(observed: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
    by_index: dict[int, list[Mapping[str, Any]]] = {}
    for item in observed:
        state = item.get("state_candidate")
        index = item.get("index")
        if state not in {"active", "inactive"} or not isinstance(index, int):
            continue
        by_index.setdefault(index, []).append(item)

    transitions: list[dict[str, Any]] = []
    for index, members in sorted(by_index.items()):
        previous: Mapping[str, Any] | None = None
        for member in sorted(members, key=lambda item: int(item.get("frame_index") or 0)):
            if previous is not None and previous.get("state_candidate") != member.get("state_candidate"):
                transitions.append(
                    {
                        "index": index,
                        "index_hex": member.get("index_hex"),
                        "name_candidate": member.get("name_candidate"),
                        "from_state": previous.get("state_candidate"),
                        "to_state": member.get("state_candidate"),
                        "from_timestamp": previous.get("timestamp"),
                        "to_timestamp": member.get("timestamp"),
                        "from_frame_index": previous.get("frame_index"),
                        "to_frame_index": member.get("frame_index"),
                    }
                )
            previous = member
    return transitions


def _heartbeat_interruptions(observed: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
    interruptions: list[dict[str, Any]] = []
    heartbeat_positions = [
        index
        for index, item in enumerate(observed)
        if item.get("name_candidate") == "heartbeat_alive_candidate"
    ]
    for left, right in zip(heartbeat_positions, heartbeat_positions[1:]):
        between = [
            item
            for item in observed[left + 1 : right]
            if item.get("name_candidate") != "heartbeat_alive_candidate"
        ]
        if not between:
            continue
        start = observed[left]
        end = observed[right]
        interruptions.append(
            {
                "from_frame_index": start.get("frame_index"),
                "to_frame_index": end.get("frame_index"),
                "from_timestamp": start.get("timestamp"),
                "to_timestamp": end.get("timestamp"),
                "gap_ms": int(end.get("timestamp_ms") or 0) - int(start.get("timestamp_ms") or 0),
                "interrupted_by": [
                    {
                        "frame_index": item.get("frame_index"),
                        "timestamp": item.get("timestamp"),
                        "name_candidate": item.get("name_candidate"),
                        "state_candidate": item.get("state_candidate"),
                        "index_hex": item.get("index_hex"),
                        "value_hex": item.get("value_hex"),
                    }
                    for item in between
                ],
            }
        )
    return interruptions


def _chunk_dict(chunk: CaptureChunk) -> dict[str, Any]:
    return {
        "chunk_index": chunk.chunk_index,
        "timestamp": chunk.timestamp,
        "timestamp_ms": chunk.timestamp_ms,
        "analyzer_direction": chunk.analyzer_direction,
        "device_direction": chunk.device_direction,
        "declared_count": chunk.declared_count,
        "byte_count": len(chunk.bytes),
        "count_matches": chunk.declared_count == len(chunk.bytes),
        "bytes": list(chunk.bytes),
        "bytes_hex": [_h8(byte) for byte in chunk.bytes],
    }


def _device_direction(analyzer_direction: str) -> str:
    if analyzer_direction == "rx":
        return "tx"
    if analyzer_direction == "tx":
        return "rx"
    return "unknown"


def _timestamp_ms(value: str) -> int:
    head, _, fraction = value.partition(".")
    hours, minutes, seconds = [int(part) for part in head.split(":")]
    millis = int((fraction + "000")[:3]) if fraction else 0
    return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis


def _h8(value: int) -> str:
    return f"0x{value & 0xFF:02X}"


def _h16(value: int) -> str:
    return f"0x{value & 0xFFFF:04X}"


__all__ = [
    "CaptureChunk",
    "analyze_capture_chunks",
    "analyze_capture_text",
    "checksum_for",
    "format_text_report",
    "main",
    "parse_capture_text",
]
