from __future__ import annotations

from bisect import bisect_right
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple


DEFAULT_TIMEOUT_US = 5_000_000


def _emit_progress(
    progress_callback: Optional[Callable[[Dict[str, Any]], None]],
    *,
    phase: str,
    current: Optional[int] = None,
    total: Optional[int] = None,
    unit: Optional[str] = None,
    message: Optional[str] = None,
) -> None:
    if progress_callback is None:
        return
    payload: Dict[str, Any] = {"phase": phase}
    if current is not None:
        payload["current"] = int(current)
    if total is not None:
        payload["total"] = int(total)
    if unit:
        payload["unit"] = unit
    if message:
        payload["message"] = message
    try:
        progress_callback(payload)
    except Exception:
        pass


@dataclass
class CANFrame:
    timestamp_us: int
    can_id: int
    extended: bool
    dlc: int
    data: List[int]


@dataclass
class IsoTpMessage:
    timestamp_us: int
    tx_id: int
    rx_id: Optional[int]
    direction: str
    data: List[int]
    frame_count: int = 1
    complete: bool = True
    frame_indices: List[int] = field(default_factory=list)
    extended_addr: Optional[int] = None
    source: str = ""


@dataclass
class ConversationCandidate:
    key: str
    request_id: int
    response_id: int
    extended_addr: Optional[int]
    message_count: int
    request_count: int
    response_count: int
    matched_pairs: int
    unmatched_requests: int
    unique_requests: int
    score: int
    confidence: str
    protocol_hint: str
    vin: Optional[str]
    auxiliary_request_ids: List[int] = field(default_factory=list)
    request_response_map: Dict[str, List[List[int]]] = field(default_factory=dict)


@dataclass
class LogAnalysis:
    source_type: str
    messages: List[IsoTpMessage]
    metadata: Dict[str, Any]
    candidates: List[ConversationCandidate]


@dataclass
class ExportedSpec:
    ecu_name: str
    path: str
    request_id: int
    response_id: int
    protocol: str


@dataclass
class ExportResult:
    vehicle_path: str
    vehicle_name: str
    vin: str
    ecus: List[ExportedSpec]


def is_valid_vin_char(value: int) -> bool:
    return (
        0x30 <= value <= 0x39
        or 0x41 <= value <= 0x48
        or 0x4A <= value <= 0x4E
        or value == 0x50
        or 0x52 <= value <= 0x5A
    )


def clean_vin(vin: Optional[str]) -> Optional[str]:
    if not vin:
        return None
    filtered = "".join(ch for ch in vin.upper() if ch.isalnum())
    if len(filtered) >= 17:
        return filtered[:17]
    return None


def bytes_to_hex(data: Iterable[int]) -> str:
    return " ".join(f"{byte:02X}" for byte in data)


def format_hex_int(value: int, width: int = 0) -> str:
    if width > 0:
        return f"0x{value:0{width}X}"
    return f"0x{value:X}"


def is_protocol_request_sid(sid: int) -> bool:
    if 0x01 <= sid <= 0x0A:
        return True
    if 0x10 <= sid <= 0x3E:
        return True
    if 0x83 <= sid <= 0x87:
        return True
    return False


def is_protocol_response_sid(sid: int) -> bool:
    if sid == 0x7F:
        return True
    if 0x41 <= sid <= 0x7E:
        return True
    if sid >= 0xC0:
        return True
    return False


def request_sid_from_response(data: List[int]) -> Optional[int]:
    if not data:
        return None
    sid = data[0]
    if sid == 0x7F:
        return data[1] if len(data) >= 2 else None
    if sid >= 0x40:
        return sid - 0x40
    return None


def request_sid_of_message(message: IsoTpMessage) -> Optional[int]:
    if not message.data:
        return None
    if message.direction == "request":
        return message.data[0]
    return request_sid_from_response(message.data)


def guess_direction(can_id: int) -> str:
    if can_id == 0x7DF or 0x7E0 <= can_id <= 0x7E7:
        return "request"
    if 0x7E8 <= can_id <= 0x7EF:
        return "response"

    if (can_id & 0x1FFF0000) == 0x18DA0000:
        target = (can_id >> 8) & 0xFF
        if target in (0xF1, 0xF2):
            return "response"
        return "request"

    if (can_id & 0x1FFF0000) == 0x18DB0000:
        return "request"

    return "request"


def infer_rx_id(tx_id: int) -> Optional[int]:
    if tx_id == 0x7DF:
        return 0x7E8
    if 0x7E0 <= tx_id <= 0x7E7:
        return tx_id + 8
    if 0x7E8 <= tx_id <= 0x7EF:
        return tx_id - 8

    if (tx_id & 0x1FFF0000) == 0x18DA0000:
        target = tx_id & 0xFF
        source = (tx_id >> 8) & 0xFF
        return (tx_id & 0x1FFF0000) | (target << 8) | source

    return None


def detect_direction_from_data(data: List[int]) -> Optional[str]:
    if not data:
        return None
    sid = data[0]
    if is_protocol_response_sid(sid):
        return "response"
    if is_protocol_request_sid(sid):
        return "request"
    return None


class IsoTpDecoder:
    def __init__(self, addressing: str = "normal") -> None:
        self.addressing = addressing
        self.pending: Dict[str, Dict[str, Any]] = {}
        self.messages: List[IsoTpMessage] = []
        self.pending_requests: List[Tuple[int, int, IsoTpMessage]] = []
        self.frame_index = 0

    def _pending_key(self, can_id: int, ext_addr: Optional[int]) -> str:
        if ext_addr is None:
            return str(can_id)
        return f"{can_id}:{ext_addr}"

    def process(self, frame: CANFrame) -> Optional[IsoTpMessage]:
        if not frame.data:
            return None

        idx = self.frame_index
        self.frame_index += 1

        use_extended = self.addressing == "extended"
        offset = 1 if use_extended else 0
        ext_addr = frame.data[0] if use_extended and frame.data else None

        if len(frame.data) <= offset:
            return None

        pci_type = (frame.data[offset] >> 4) & 0x0F

        if pci_type == 0x0:
            return self._single_frame(frame, idx, offset, ext_addr)
        if pci_type == 0x1:
            return self._first_frame(frame, idx, offset, ext_addr)
        if pci_type == 0x2:
            return self._consecutive_frame(frame, idx, offset, ext_addr)
        if pci_type == 0x3:
            self._flow_control(frame, ext_addr)
            return None
        return None

    def _single_frame(
        self,
        frame: CANFrame,
        frame_index: int,
        offset: int,
        ext_addr: Optional[int],
    ) -> Optional[IsoTpMessage]:
        length = frame.data[offset] & 0x0F
        max_len = 7 - offset
        if length == 0 or length > max_len:
            return None
        if len(frame.data) < offset + 1 + length:
            return None

        payload = frame.data[offset + 1 : offset + 1 + length]
        direction = detect_direction_from_data(payload) or guess_direction(frame.can_id)
        message = IsoTpMessage(
            timestamp_us=frame.timestamp_us,
            tx_id=frame.can_id,
            rx_id=infer_rx_id(frame.can_id),
            direction=direction,
            data=payload,
            frame_count=1,
            complete=True,
            frame_indices=[frame_index],
            extended_addr=ext_addr,
        )
        self.messages.append(message)
        self._match_request_response(message)
        return message

    def _first_frame(
        self,
        frame: CANFrame,
        frame_index: int,
        offset: int,
        ext_addr: Optional[int],
    ) -> Optional[IsoTpMessage]:
        if len(frame.data) < offset + 2:
            return None

        length = ((frame.data[offset] & 0x0F) << 8) | frame.data[offset + 1]
        if length == 0:
            return None

        data = frame.data[offset + 2 :]
        direction = detect_direction_from_data(data) or guess_direction(frame.can_id)

        self.pending[self._pending_key(frame.can_id, ext_addr)] = {
            "timestamp_us": frame.timestamp_us,
            "tx_id": frame.can_id,
            "rx_id": infer_rx_id(frame.can_id),
            "direction": direction,
            "expected": length,
            "data": list(data),
            "next_seq": 1,
            "frame_count": 1,
            "frame_indices": [frame_index],
            "extended_addr": ext_addr,
            "dlc": frame.dlc,
        }
        return None

    def _consecutive_frame(
        self,
        frame: CANFrame,
        frame_index: int,
        offset: int,
        ext_addr: Optional[int],
    ) -> Optional[IsoTpMessage]:
        key = self._pending_key(frame.can_id, ext_addr)
        state = self.pending.get(key)
        if state is None:
            return None

        seq = frame.data[offset] & 0x0F
        if seq != (state["next_seq"] & 0x0F):
            self.pending.pop(key, None)
            return None

        remaining = state["expected"] - len(state["data"])
        read_len = min(remaining, len(frame.data) - offset - 1)
        if read_len > 0:
            state["data"].extend(frame.data[offset + 1 : offset + 1 + read_len])
        state["next_seq"] += 1
        state["frame_count"] += 1
        state["frame_indices"].append(frame_index)

        if len(state["data"]) < state["expected"]:
            return None

        self.pending.pop(key, None)
        payload = state["data"][: state["expected"]]
        direction = detect_direction_from_data(payload) or state["direction"]
        message = IsoTpMessage(
            timestamp_us=state["timestamp_us"],
            tx_id=state["tx_id"],
            rx_id=state["rx_id"],
            direction=direction,
            data=payload,
            frame_count=state["frame_count"],
            complete=True,
            frame_indices=list(state["frame_indices"]),
            extended_addr=state["extended_addr"],
        )
        self.messages.append(message)
        self._match_request_response(message)
        return message

    def _flow_control(self, frame: CANFrame, ext_addr: Optional[int]) -> None:
        for state in sorted(self.pending.values(), key=lambda item: item["timestamp_us"], reverse=True):
            if state.get("extended_addr") != ext_addr:
                continue
            if state.get("rx_id") in (None, frame.can_id):
                state["rx_id"] = frame.can_id
                return

    def _match_request_response(self, message: IsoTpMessage) -> None:
        sid = request_sid_of_message(message)
        if sid is None:
            return

        if message.direction == "request":
            self.pending_requests.append((sid, message.timestamp_us, message))
            cutoff = message.timestamp_us - DEFAULT_TIMEOUT_US
            self.pending_requests = [item for item in self.pending_requests if item[1] >= cutoff]
            if len(self.pending_requests) > 200:
                self.pending_requests = self.pending_requests[-200:]
            return

        inferred_requester = infer_rx_id(message.tx_id)
        supports_functional_response = 0x7E8 <= message.tx_id <= 0x7EF
        best_index: Optional[int] = None
        best_rank: Optional[Tuple[int, int, int]] = None

        for index in range(len(self.pending_requests) - 1, -1, -1):
            pending_sid, pending_ts, request_message = self.pending_requests[index]
            if pending_sid != sid:
                continue
            delta = message.timestamp_us - pending_ts
            if delta < 0:
                continue
            if delta > DEFAULT_TIMEOUT_US:
                break

            requester = request_message.tx_id
            if inferred_requester is not None and requester == inferred_requester:
                penalty = 0
            elif supports_functional_response and requester == 0x7DF:
                penalty = 20_000
            else:
                penalty = 200_000 if inferred_requester is not None else 60_000

            rank = (delta + penalty, delta, -pending_ts)
            if best_rank is None or rank < best_rank:
                best_rank = rank
                best_index = index

        if best_index is None:
            return

        _, _, request_message = self.pending_requests[best_index]
        message.rx_id = request_message.tx_id
        request_message.rx_id = message.tx_id


def decode_isotp_messages(
    frames: List[CANFrame],
    addressing: str = "auto",
    progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Tuple[str, List[IsoTpMessage]]:
    total_frames = len(frames)
    progress_step = max(1, total_frames // 180) if total_frames > 0 else 1

    if addressing in {"normal", "extended"}:
        decoder = IsoTpDecoder(addressing=addressing)
        for index, frame in enumerate(frames, start=1):
            decoder.process(frame)
            if index == 1 or index == total_frames or index % progress_step == 0:
                _emit_progress(
                    progress_callback,
                    phase="decode",
                    current=index,
                    total=total_frames,
                    unit="frames",
                    message=f"Decoding ISO-TP ({addressing})",
                )
        return addressing, decoder.messages

    decoder_normal = IsoTpDecoder(addressing="normal")
    decoder_extended = IsoTpDecoder(addressing="extended")

    for index, frame in enumerate(frames, start=1):
        decoder_normal.process(frame)
        decoder_extended.process(frame)
        if index == 1 or index == total_frames or index % progress_step == 0:
            _emit_progress(
                progress_callback,
                phase="decode",
                current=index,
                total=total_frames,
                unit="frames",
                message="Decoding ISO-TP (auto addressing)",
            )

    normal_candidates = analyze_conversations(decoder_normal.messages)
    extended_candidates = analyze_conversations(decoder_extended.messages)

    normal_quality = _conversation_quality_score(normal_candidates)
    extended_quality = _conversation_quality_score(extended_candidates)

    if normal_quality > extended_quality:
        return "normal", decoder_normal.messages
    if extended_quality > normal_quality:
        return "extended", decoder_extended.messages

    normal_score = _isotp_score(decoder_normal.messages, addressing="normal")
    extended_score = _isotp_score(decoder_extended.messages, addressing="extended")

    if extended_score > normal_score:
        return "extended", decoder_extended.messages
    return "normal", decoder_normal.messages


def _isotp_score(messages: List[IsoTpMessage], addressing: str) -> int:
    score = 0
    ext_addr_counts: Dict[int, int] = {}
    for message in messages:
        if not message.data:
            continue
        sid = request_sid_of_message(message)
        if sid is None:
            continue
        score += 1
        if is_protocol_request_sid(sid):
            score += 1
        if message.rx_id is not None:
            score += 2
        if _likely_diagnostic_can_id(message.tx_id):
            score += 2
        elif message.rx_id is not None and _likely_diagnostic_can_id(message.rx_id):
            score += 1

        if message.extended_addr is not None:
            ext_addr_counts[message.extended_addr] = ext_addr_counts.get(message.extended_addr, 0) + 1

    if addressing == "extended" and ext_addr_counts:
        unique = len(ext_addr_counts)
        top = max(ext_addr_counts.values())
        total = sum(ext_addr_counts.values())
        dominance = top / max(total, 1)
        # In false-positive extended decoding, ext addr spread is usually large and non-dominant.
        score -= unique * 12
        if dominance < 0.6:
            score -= 120
    return score


def _likely_diagnostic_can_id(can_id: int) -> bool:
    if can_id == 0x7DF:
        return True
    if 0x7E0 <= can_id <= 0x7EF:
        return True
    if (can_id & 0x1FFF0000) in {0x18DA0000, 0x18DB0000, 0x18CE0000}:
        return True
    return False


def _confidence_rank(value: str) -> int:
    if value == "high":
        return 3
    if value == "medium":
        return 2
    return 1


def _conversation_quality_score(candidates: List[ConversationCandidate]) -> int:
    if not candidates:
        return 0

    best_by_pair: Dict[Tuple[int, int], int] = {}
    total = 0
    for candidate in candidates:
        pair_key = (candidate.request_id, candidate.response_id)
        confidence_bonus = _confidence_rank(candidate.confidence) * 120
        vin_bonus = 220 if candidate.vin else 0
        same_id_penalty = 180 if candidate.request_id == candidate.response_id else 0
        score = (
            candidate.matched_pairs * 90
            + candidate.unique_requests * 8
            + candidate.request_count
            + candidate.response_count
            + confidence_bonus
            + vin_bonus
            - same_id_penalty
        )

        existing = best_by_pair.get(pair_key)
        if existing is None or score > existing:
            if existing is not None:
                total -= existing
            best_by_pair[pair_key] = score
            total += score

    duplicates = max(0, len(candidates) - len(best_by_pair))
    total -= duplicates * 150
    return total


def _latest_timestamp_not_after(timestamps: List[int], timestamp_us: int) -> Optional[int]:
    if not timestamps:
        return None
    index = bisect_right(timestamps, timestamp_us) - 1
    if index < 0:
        return None
    return timestamps[index]


def _infer_functional_auxiliary(
    candidate: ConversationCandidate,
    messages: List[IsoTpMessage],
    timeout_us: int = 300_000,
) -> List[int]:
    if candidate.request_id == 0x7DF:
        return []

    functional_requests: Dict[int, List[int]] = {}
    primary_requests: Dict[int, List[int]] = {}
    candidate_responses: List[Tuple[int, int]] = []

    for message in messages:
        if not message.complete or not message.data:
            continue
        sid = request_sid_of_message(message)
        if sid is None or not is_protocol_request_sid(sid):
            continue

        if message.direction == "request":
            if message.tx_id == 0x7DF:
                functional_requests.setdefault(sid, []).append(message.timestamp_us)
            elif message.tx_id == candidate.request_id:
                primary_requests.setdefault(sid, []).append(message.timestamp_us)
        elif message.direction == "response" and message.tx_id == candidate.response_id:
            candidate_responses.append((message.timestamp_us, sid))

    if not candidate_responses:
        return []

    # OBD-style ECU ranges commonly respond to functional 0x7DF addressing.
    # If 0x7DF requests are present in the capture, advertise it as auxiliary
    # even when strict SID matching is sparse.
    has_any_functional_request = any(functional_requests.values())
    if has_any_functional_request and 0x7E8 <= candidate.response_id <= 0x7EF:
        return [0x7DF]

    functional_hits = 0
    coincidental_hits = 0
    for response_timestamp, sid in candidate_responses:
        func_ts = _latest_timestamp_not_after(functional_requests.get(sid, []), response_timestamp)
        if func_ts is None:
            continue

        primary_ts = _latest_timestamp_not_after(primary_requests.get(sid, []), response_timestamp)
        delta_func = response_timestamp - func_ts
        if delta_func < 0 or delta_func > timeout_us:
            continue
        coincidental_hits += 1

        if primary_ts is None:
            functional_hits += 1
            continue

        delta_primary = response_timestamp - primary_ts
        if delta_primary < 0:
            continue
        if delta_primary > timeout_us or delta_func < delta_primary:
            functional_hits += 1

    if functional_hits >= 1 or coincidental_hits >= 2:
        return [0x7DF]
    return []


def _pair_key(message: IsoTpMessage) -> Optional[Tuple[int, int, Optional[int]]]:
    if message.rx_id is None:
        return None
    a = min(message.tx_id, message.rx_id)
    b = max(message.tx_id, message.rx_id)
    return (a, b, message.extended_addr)


def _request_response_pair_key(message: IsoTpMessage) -> Optional[str]:
    key = _pair_key(message)
    if key is None:
        return None
    a, b, ext_addr = key
    if ext_addr is None:
        return f"{a:X}-{b:X}"
    return f"{a:X}-{b:X}-x{ext_addr:02X}"


def _candidate_confidence(matched_pairs: int, request_count: int, response_count: int) -> str:
    if matched_pairs >= 5 and request_count >= 5 and response_count >= 5:
        return "high"
    if matched_pairs >= 2 and request_count >= 2 and response_count >= 2:
        return "medium"
    return "low"


def _extract_vin_from_message_data(data: List[int]) -> Optional[str]:
    if len(data) >= 20 and data[0] == 0x62 and data[1] == 0xF1 and data[2] == 0x90:
        vin_bytes = data[3:20]
        if all(is_valid_vin_char(item) for item in vin_bytes):
            return bytes(vin_bytes).decode("ascii", errors="ignore")

    if len(data) >= 3 and data[0] == 0x49 and data[1] == 0x02:
        ascii_bytes = [byte for byte in data[2:] if 0x20 <= byte <= 0x7E]
        candidate = "".join(chr(byte) for byte in ascii_bytes).upper()
        clean = "".join(ch for ch in candidate if ch.isalnum())
        if len(clean) >= 17:
            sample = clean[:17]
            if all(is_valid_vin_char(ord(ch)) for ch in sample):
                return sample

    return None


def _infer_broadcast_responder_candidates(
    messages: List[IsoTpMessage],
    existing_pairs: set[Tuple[int, int, Optional[int]]],
    timeout_us: int = 500_000,
) -> List[ConversationCandidate]:
    def _is_likely_diag_request_id(can_id: int) -> bool:
        if can_id == 0x7DF:
            return True
        if 0x700 <= can_id <= 0x7FF:
            return True
        # Common DoIP tester source range (e.g. 0x0E80).
        if 0x0E00 <= can_id <= 0x0EFF:
            return True
        # Common HSFZ tester source range (1-byte node IDs).
        if 0xF0 <= can_id <= 0xFF:
            return True
        prefix = can_id & 0x1FFF0000
        if prefix in (0x18DA0000, 0x18DB0000):
            return True
        return False

    ordered = sorted(
        [message for message in messages if message.complete and message.data],
        key=lambda item: item.timestamp_us,
    )
    if not ordered:
        return []

    requests_by_tx: Dict[int, List[IsoTpMessage]] = {}
    responses_by_sid: Dict[int, List[IsoTpMessage]] = {}
    response_timestamps_by_sid: Dict[int, List[int]] = {}

    for message in ordered:
        sid = request_sid_of_message(message)
        if sid is None or not is_protocol_request_sid(sid):
            continue
        if message.direction == "request":
            requests_by_tx.setdefault(message.tx_id, []).append(message)
        elif message.direction == "response":
            responses_by_sid.setdefault(sid, []).append(message)

    for sid, response_list in responses_by_sid.items():
        response_timestamps_by_sid[sid] = [item.timestamp_us for item in response_list]

    pair_stats: Dict[Tuple[int, int], Dict[str, Any]] = {}
    responders_by_request: Dict[int, set[int]] = {}

    for request_id, request_messages in requests_by_tx.items():
        if len(request_messages) < 1:
            continue
        if not _is_likely_diag_request_id(request_id):
            continue

        for index, request in enumerate(request_messages):
            sid = request_sid_of_message(request)
            if sid is None:
                continue

            responses = responses_by_sid.get(sid)
            if not responses:
                continue

            response_timestamps = response_timestamps_by_sid[sid]
            next_request_ts = (
                request_messages[index + 1].timestamp_us
                if index + 1 < len(request_messages)
                else request.timestamp_us + timeout_us
            )
            window_end = min(request.timestamp_us + timeout_us, next_request_ts)
            start = bisect_right(response_timestamps, request.timestamp_us - 1)

            matched_by_responder: Dict[int, IsoTpMessage] = {}
            for response in responses[start:]:
                if response.timestamp_us > window_end:
                    break
                if response.tx_id == request_id:
                    continue
                # Keep arbitration coherent: only pair responses that target the requester
                # (or where target is unknown/unspecified in the parsed source).
                if response.rx_id is not None and response.rx_id != request_id:
                    continue
                if response.tx_id not in matched_by_responder:
                    matched_by_responder[response.tx_id] = response

            if not matched_by_responder:
                continue

            request_hex = bytes_to_hex(request.data)
            for responder_id, response in matched_by_responder.items():
                responders_by_request.setdefault(request_id, set()).add(responder_id)
                pair_key = (request_id, responder_id)
                stats = pair_stats.get(pair_key)
                if stats is None:
                    stats = {
                        "matched_pairs": 0,
                        "request_count": 0,
                        "response_count": 0,
                        "unique_requests": {},
                        "request_response_map": {},
                        "vin": None,
                        "uds_score": 0,
                        "kwp_score": 0,
                        "obd_score": 0,
                    }
                    pair_stats[pair_key] = stats

                stats["matched_pairs"] += 1
                stats["request_count"] += 1
                stats["response_count"] += 1
                stats["unique_requests"].setdefault(request_hex, list(request.data))
                stats["request_response_map"].setdefault(request_hex, []).append(list(response.data))

                if stats["vin"] is None:
                    stats["vin"] = _extract_vin_from_message_data(response.data)

                if 0x01 <= sid <= 0x0A:
                    stats["obd_score"] += 2
                elif sid == 0x1A:
                    stats["kwp_score"] += 3
                else:
                    stats["uds_score"] += 2

                if response.data and response.data[0] == 0x5A:
                    stats["kwp_score"] += 2
                if response.data and 0x41 <= response.data[0] <= 0x4A:
                    stats["obd_score"] += 1
                if response.data and (response.data[0] == 0x7F or response.data[0] >= 0x50):
                    stats["uds_score"] += 1

    synthesized: List[ConversationCandidate] = []
    for (request_id, response_id), stats in pair_stats.items():
        pair_ext = (request_id, response_id, None)
        if pair_ext in existing_pairs:
            continue

        matched_pairs = int(stats["matched_pairs"])
        request_count = int(stats["request_count"])
        response_count = int(stats["response_count"])
        if matched_pairs <= 0 or request_count <= 0 or response_count <= 0:
            continue

        unique_requests = len(stats["unique_requests"])
        protocol_hint = "uds"
        kwp_score = int(stats["kwp_score"])
        uds_score = int(stats["uds_score"])
        obd_score = int(stats["obd_score"])
        if kwp_score > uds_score and kwp_score >= obd_score:
            protocol_hint = "kwp"
        elif obd_score > uds_score and obd_score > kwp_score:
            protocol_hint = "obd2"

        score = matched_pairs * 10 + unique_requests * 2 + request_count + response_count
        confidence = _candidate_confidence(matched_pairs, request_count, response_count)
        synthesized.append(
            ConversationCandidate(
                key=f"{request_id:X}-{response_id:X}",
                request_id=request_id,
                response_id=response_id,
                extended_addr=None,
                message_count=request_count + response_count,
                request_count=request_count,
                response_count=response_count,
                matched_pairs=matched_pairs,
                unmatched_requests=max(0, request_count - matched_pairs),
                unique_requests=unique_requests,
                score=score,
                confidence=confidence,
                protocol_hint=protocol_hint,
                vin=clean_vin(stats["vin"]),
                auxiliary_request_ids=[],
                request_response_map=stats["request_response_map"],
            )
        )

    return synthesized


def analyze_conversations(
    messages: List[IsoTpMessage],
    progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> List[ConversationCandidate]:
    groups: Dict[Tuple[int, int, Optional[int]], List[IsoTpMessage]] = {}
    total_messages = len(messages)
    message_step = max(1, total_messages // 180) if total_messages > 0 else 1

    for index, message in enumerate(messages, start=1):
        if not message.complete or not message.data:
            if index == 1 or index == total_messages or index % message_step == 0:
                _emit_progress(
                    progress_callback,
                    phase="analyze-grouping",
                    current=index,
                    total=total_messages,
                    unit="messages",
                    message="Grouping request/response traffic",
                )
            continue
        sid = request_sid_of_message(message)
        if sid is None:
            if index == 1 or index == total_messages or index % message_step == 0:
                _emit_progress(
                    progress_callback,
                    phase="analyze-grouping",
                    current=index,
                    total=total_messages,
                    unit="messages",
                    message="Grouping request/response traffic",
                )
            continue
        if not is_protocol_request_sid(sid):
            if index == 1 or index == total_messages or index % message_step == 0:
                _emit_progress(
                    progress_callback,
                    phase="analyze-grouping",
                    current=index,
                    total=total_messages,
                    unit="messages",
                    message="Grouping request/response traffic",
                )
            continue
        key = _pair_key(message)
        if key is None:
            if index == 1 or index == total_messages or index % message_step == 0:
                _emit_progress(
                    progress_callback,
                    phase="analyze-grouping",
                    current=index,
                    total=total_messages,
                    unit="messages",
                    message="Grouping request/response traffic",
                )
            continue
        groups.setdefault(key, []).append(message)
        if index == 1 or index == total_messages or index % message_step == 0:
            _emit_progress(
                progress_callback,
                phase="analyze-grouping",
                current=index,
                total=total_messages,
                unit="messages",
                message="Grouping request/response traffic",
            )

    candidates: List[ConversationCandidate] = []
    total_groups = len(groups)
    group_step = max(1, total_groups // 160) if total_groups > 0 else 1

    for group_index, (key, group_messages) in enumerate(groups.items(), start=1):
        ordered = sorted(group_messages, key=lambda item: item.timestamp_us)
        pending_requests: List[Dict[str, Any]] = []
        request_count = 0
        response_count = 0
        matched_pairs = 0
        unmatched_requests = 0
        request_response_map: Dict[str, List[List[int]]] = {}
        unique_requests: Dict[str, List[int]] = {}
        request_ids: Dict[int, int] = {}
        response_ids: Dict[int, int] = {}
        vin: Optional[str] = None
        kwp_score = 0
        obd_score = 0
        uds_score = 0

        for message in ordered:
            sid = request_sid_of_message(message)
            if sid is None:
                continue

            if message.direction == "request":
                request_count += 1
                request_ids[message.tx_id] = request_ids.get(message.tx_id, 0) + 1
                request_hex = bytes_to_hex(message.data)
                unique_requests.setdefault(request_hex, message.data)
                pending_requests.append(
                    {
                        "sid": sid,
                        "timestamp_us": message.timestamp_us,
                        "data": list(message.data),
                        "hex": request_hex,
                        "tx_id": message.tx_id,
                    }
                )

                if 0x01 <= sid <= 0x0A:
                    obd_score += 2
                elif sid == 0x1A:
                    kwp_score += 3
                else:
                    uds_score += 2

            else:
                response_count += 1
                response_ids[message.tx_id] = response_ids.get(message.tx_id, 0) + 1

                if message.data and message.data[0] == 0x5A:
                    kwp_score += 2
                if message.data and 0x41 <= message.data[0] <= 0x4A:
                    obd_score += 1
                if message.data and (message.data[0] == 0x7F or message.data[0] >= 0x50):
                    uds_score += 1

                vin = vin or _extract_vin_from_message_data(message.data)

                match_index = -1
                for index in range(len(pending_requests) - 1, -1, -1):
                    pending = pending_requests[index]
                    if pending["sid"] != sid:
                        continue
                    if message.timestamp_us - pending["timestamp_us"] > DEFAULT_TIMEOUT_US:
                        break
                    match_index = index
                    break

                if match_index >= 0:
                    matched = pending_requests[match_index]
                    request_response_map.setdefault(matched["hex"], []).append(list(message.data))

                    is_response_pending = (
                        len(message.data) >= 3 and message.data[0] == 0x7F and message.data[2] == 0x78
                    )
                    if is_response_pending:
                        # Keep the originating request pending until a final response arrives.
                        matched["timestamp_us"] = message.timestamp_us
                    else:
                        pending_requests.pop(match_index)
                        matched_pairs += 1

                cutoff = message.timestamp_us - DEFAULT_TIMEOUT_US
                while pending_requests and pending_requests[0]["timestamp_us"] < cutoff:
                    pending_requests.pop(0)
                    unmatched_requests += 1

        unmatched_requests += len(pending_requests)

        if request_count == 0 or response_count == 0 or matched_pairs == 0:
            continue

        match_ratio = matched_pairs / max(request_count, 1)
        strict_candidate = matched_pairs >= 2 and match_ratio >= 0.3
        weak_candidate = matched_pairs >= 1 and (request_count + response_count) >= 4
        if not strict_candidate and not weak_candidate:
            continue

        request_id = max(request_ids.items(), key=lambda item: item[1])[0] if request_ids else key[0]
        response_id = max(response_ids.items(), key=lambda item: item[1])[0] if response_ids else key[1]

        if request_id == response_id:
            request_id = key[0]
            response_id = key[1]

        protocol_hint = "uds"
        if kwp_score > uds_score and kwp_score >= obd_score:
            protocol_hint = "kwp"
        elif obd_score > uds_score and obd_score > kwp_score:
            protocol_hint = "obd2"

        score = matched_pairs * 10 + len(unique_requests) * 2 + request_count + response_count
        confidence = _candidate_confidence(matched_pairs, request_count, response_count)
        candidates.append(
            ConversationCandidate(
                key=_request_response_pair_key(ordered[0]) or f"{key[0]:X}-{key[1]:X}",
                request_id=request_id,
                response_id=response_id,
                extended_addr=key[2],
                message_count=request_count + response_count,
                request_count=request_count,
                response_count=response_count,
                matched_pairs=matched_pairs,
                unmatched_requests=unmatched_requests,
                unique_requests=len(unique_requests),
                score=score,
                confidence=confidence,
                protocol_hint=protocol_hint,
                vin=clean_vin(vin),
                auxiliary_request_ids=[],
                request_response_map=request_response_map,
            )
        )
        if group_index == 1 or group_index == total_groups or group_index % group_step == 0:
            _emit_progress(
                progress_callback,
                phase="analyze-pairs",
                current=group_index,
                total=total_groups,
                unit="pairs",
                message="Scoring ECU conversation pairs",
            )

    existing_pairs = {(candidate.request_id, candidate.response_id, candidate.extended_addr) for candidate in candidates}
    candidates.extend(_infer_broadcast_responder_candidates(messages, existing_pairs))

    _emit_progress(
        progress_callback,
        phase="analyze-finalize",
        current=len(candidates),
        total=max(len(candidates), 1),
        unit="candidates",
        message="Finalizing detected conversations",
    )

    for candidate in candidates:
        candidate.auxiliary_request_ids = _infer_functional_auxiliary(candidate, messages)

    candidates.sort(key=lambda item: item.score, reverse=True)
    return candidates


def _common_did_name(did: int) -> str:
    names = {
        0xF186: "ActiveDiagnosticSession",
        0xF187: "SparePartNumber",
        0xF188: "SoftwareVersion",
        0xF189: "ApplicationSoftwareVersion",
        0xF18A: "SystemSupplier",
        0xF18B: "ECUManufacturingDate",
        0xF18C: "ECUSerialNumber",
        0xF190: "VIN",
        0xF191: "HardwareVersion",
        0xF192: "SystemSupplierECUSoftwareNumber",
        0xF193: "SystemSupplierECUSoftwareVersionNumber",
        0xF194: "SystemSupplierECUHardwareNumber",
        0xF195: "SystemSupplierECUHardwareVersionNumber",
        0xF197: "SystemName",
        0xF198: "RepairShopCode",
        0xF199: "ProgrammingDate",
        0xF19E: "ASAM_ODX_FileIdentifier",
        0xF1A0: "ASAM_ODX_FileVersion",
    }
    return names.get(did, f"Identifier_{did:04X}")


def _collect_unique_payloads(payloads: List[List[int]]) -> List[List[int]]:
    seen: set[Tuple[int, ...]] = set()
    ordered: List[List[int]] = []
    for payload in payloads:
        key = tuple(payload)
        if key in seen:
            continue
        seen.add(key)
        ordered.append(payload)
    return ordered


def _build_uds_security_levels(
    request_entries: List[Tuple[str, List[int], List[List[int]]]]
) -> Tuple[List[Dict[str, Any]], set[str]]:
    pending_seed_by_level: Dict[int, Dict[str, Any]] = {}
    seen_pairs: Dict[int, set[Tuple[Tuple[int, ...], Tuple[int, ...]]]] = {}
    levels: Dict[int, Dict[str, Any]] = {}
    handled_requests: set[str] = set()

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 2 or request_bytes[0] != 0x27:
            continue

        level = request_bytes[1]
        positive = [rsp for rsp in responses if len(rsp) >= 2 and rsp[0] == 0x67 and rsp[1] == level]
        if not positive:
            continue

        if level % 2 == 1:
            seeds = _collect_unique_payloads([rsp[2:] for rsp in positive])
            if seeds:
                pending_seed_by_level[level] = {"seed": list(seeds[0]), "request_hex": request_hex}
            continue

        if len(request_bytes) < 3:
            continue

        base_level = level - 1
        key = request_bytes[2:]
        if not key:
            continue

        pending = pending_seed_by_level.get(base_level, {})
        seed = list(pending.get("seed", []))
        seed_key = (tuple(seed), tuple(key))
        if seed_key in seen_pairs.setdefault(base_level, set()):
            continue

        seen_pairs[base_level].add(seed_key)
        level_entry = levels.setdefault(
            base_level,
            {
                "level": format_hex_int(base_level, 2),
                "seedKeyPairs": [],
                "delay": 0,
            },
        )
        level_entry["seedKeyPairs"].append(
            {
                "seed": bytes_to_hex(seed),
                "key": bytes_to_hex(key),
            }
        )
        handled_requests.add(request_hex)
        seed_request_hex = pending.get("request_hex")
        if isinstance(seed_request_hex, str) and seed_request_hex:
            handled_requests.add(seed_request_hex)

    ordered_levels = [levels[level] for level in sorted(levels)]
    return ordered_levels, handled_requests


def _build_uds_custom_services(
    request_entries: List[Tuple[str, List[int], List[List[int]]]],
    handled_requests: set[str],
) -> List[Dict[str, Any]]:
    custom_services: List[Dict[str, Any]] = []

    for request_hex, request_bytes, responses in request_entries:
        if request_hex in handled_requests:
            continue
        if not request_bytes or not responses:
            continue

        unique_responses = _collect_unique_payloads([list(rsp) for rsp in responses])
        if not unique_responses:
            continue

        item: Dict[str, Any] = {
            "name": f"Service_{request_hex.replace(' ', '_')}",
            "request": bytes_to_hex(request_bytes),
            "matchType": "strict",
            "delay": 0,
        }

        if len(unique_responses) == 1:
            item["response"] = bytes_to_hex(unique_responses[0])
        else:
            replay: List[Dict[str, Any]] = []
            for response in unique_responses:
                count = sum(1 for candidate in responses if candidate == response)
                replay.append({"count": count, "response": bytes_to_hex(response)})
            item["replay"] = replay
            item["replayLoop"] = True

        custom_services.append(item)

    return custom_services


def _infer_uds_session_timings(
    request_entries: List[Tuple[str, List[int], List[List[int]]]]
) -> Tuple[Optional[float], Optional[float]]:
    p2_values: List[float] = []
    p2ext_values: List[float] = []

    for _, request_bytes, responses in request_entries:
        if len(request_bytes) < 2 or request_bytes[0] != 0x10:
            continue

        for response in responses:
            if len(response) < 6:
                continue
            if response[0] != 0x50 or response[1] != request_bytes[1]:
                continue
            p2_ms = (response[2] << 8) | response[3]
            p2ext_10ms = (response[4] << 8) | response[5]
            p2_values.append(p2_ms / 1000.0)
            p2ext_values.append(p2ext_10ms / 100.0)

    p2_server_max = max(p2_values) if p2_values else None
    p2ext_server_max = max(p2ext_values) if p2ext_values else None
    return p2_server_max, p2ext_server_max


def _infer_uds_memory_layout(
    request_entries: List[Tuple[str, List[int], List[List[int]]]]
) -> Tuple[List[Dict[str, Any]], set[str]]:
    memory_blocks: List[Dict[str, Any]] = []
    handled_requests: set[str] = set()
    seen_blocks: set[Tuple[int, int, int]] = set()

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 4 or request_bytes[0] != 0x34:
            continue

        length_format = request_bytes[2]
        address_length = length_format & 0x0F
        size_length = (length_format >> 4) & 0x0F
        if address_length <= 0 or size_length <= 0:
            continue

        expected = 3 + address_length + size_length
        if len(request_bytes) < expected:
            continue

        address_bytes = request_bytes[3:3 + address_length]
        size_bytes = request_bytes[3 + address_length:expected]
        address = int.from_bytes(bytes(address_bytes), "big")
        size = int.from_bytes(bytes(size_bytes), "big")
        if size <= 0:
            continue

        mtu: Optional[int] = None
        for response in responses:
            if len(response) < 2 or response[0] != 0x74:
                continue
            length_info = response[1]
            mtu_length = (length_info >> 4) & 0x0F
            if mtu_length == 0:
                mtu_length = length_info & 0x0F
            if mtu_length <= 0:
                mtu_length = max(len(response) - 2, 0)
            if len(response) < 2 + mtu_length or mtu_length <= 0:
                continue
            mtu = int.from_bytes(bytes(response[2:2 + mtu_length]), "big")
            break
        if mtu is None or mtu <= 0:
            continue

        key = (address, size, mtu)
        if key in seen_blocks:
            handled_requests.add(request_hex)
            continue
        seen_blocks.add(key)

        block_index = len(memory_blocks) + 1
        memory_blocks.append(
            {
                "id": format_hex_int(block_index, 2),
                "name": f"MemoryBlock_{block_index:02d}",
                "address": format_hex_int(address, max(address_length * 2, 2)),
                "size": format_hex_int(size, max(size_length * 2, 2)),
                "mtu": format_hex_int(mtu),
            }
        )
        handled_requests.add(request_hex)

    return memory_blocks, handled_requests


def _dtc_status_labels(status: int) -> List[str]:
    labels: List[str] = []
    bit_labels = [
        (0x01, "testFailed"),
        (0x02, "testFailedThisOperationCycle"),
        (0x04, "pendingDTC"),
        (0x08, "confirmedDTC"),
        (0x10, "testNotCompletedSinceLastClear"),
        (0x20, "testFailedSinceLastClear"),
        (0x40, "testNotCompletedThisOperationCycle"),
        (0x80, "warningIndicatorRequested"),
    ]
    for bit, label in bit_labels:
        if status & bit:
            labels.append(label)
    if not labels:
        labels.append("none")
    return labels


def _infer_uds_dtcs(
    request_entries: List[Tuple[str, List[int], List[List[int]]]]
) -> Tuple[List[Dict[str, Any]], set[str]]:
    dtc_status_map: Dict[Tuple[int, int, int], int] = {}
    handled_requests: set[str] = set()
    # Subfunctions whose positive response contains DTC/status records after a status availability byte.
    list_subfunctions = {0x02, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E}

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 2 or request_bytes[0] != 0x19:
            continue

        subfunction = request_bytes[1]
        positive = [rsp for rsp in responses if len(rsp) >= 2 and rsp[0] == 0x59 and rsp[1] == subfunction]
        if not positive:
            continue

        parsed_any = False
        for response in positive:
            if subfunction in list_subfunctions and len(response) >= 7:
                index = 3
                while index + 3 < len(response):
                    dtc_key = (response[index], response[index + 1], response[index + 2])
                    status = response[index + 3]
                    if dtc_key != (0x00, 0x00, 0x00):
                        dtc_status_map[dtc_key] = dtc_status_map.get(dtc_key, 0) | status
                        parsed_any = True
                    index += 4
                continue

            # Fallback for subfunctions that target a single DTC (e.g. 0x06 report extended data).
            if len(response) >= 6:
                dtc_key = (response[2], response[3], response[4])
                status = response[5]
                if dtc_key != (0x00, 0x00, 0x00):
                    dtc_status_map[dtc_key] = dtc_status_map.get(dtc_key, 0) | status
                    parsed_any = True

        if parsed_any:
            handled_requests.add(request_hex)

    sorted_dtcs = sorted(dtc_status_map.items(), key=lambda item: (item[0][0] << 16) | (item[0][1] << 8) | item[0][2])
    entries: List[Dict[str, Any]] = []
    for dtc_bytes, status in sorted_dtcs:
        entries.append(
            {
                "bytes": [dtc_bytes[0], dtc_bytes[1], dtc_bytes[2]],
                "status": _dtc_status_labels(status),
            }
        )
    return entries, handled_requests


def build_uds_spec(candidate: ConversationCandidate, ecu_name: str, default_vin: str) -> Dict[str, Any]:
    identifiers: Dict[int, Dict[str, Any]] = {}
    routines: Dict[int, Dict[str, Any]] = {}
    routine_option_rows: Dict[Tuple[int, str], Dict[str, Any]] = {}
    handled_requests: set[str] = set()
    vin_value = candidate.vin or default_vin

    request_entries: List[Tuple[str, List[int], List[List[int]]]] = []
    for request_hex, responses in candidate.request_response_map.items():
        request_bytes = [int(part, 16) for part in request_hex.split() if part]
        if not request_bytes:
            continue
        request_entries.append((request_hex, request_bytes, responses))

    security_levels, security_handled = _build_uds_security_levels(request_entries)
    handled_requests.update(security_handled)
    inferred_write_levels = [int(item["level"], 16) for item in security_levels if isinstance(item.get("level"), str)]

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 3 or request_bytes[0] != 0x22:
            continue

        did = (request_bytes[1] << 8) | request_bytes[2]
        positive = [rsp for rsp in responses if len(rsp) >= 3 and rsp[0] == 0x62 and rsp[1] == request_bytes[1] and rsp[2] == request_bytes[2]]
        if not positive:
            continue

        payloads = _collect_unique_payloads([rsp[3:] for rsp in positive])
        if not payloads:
            continue

        if did == 0xF190:
            possible_vin = "".join(chr(byte) for byte in payloads[0] if 0x20 <= byte <= 0x7E)
            cleaned = clean_vin(possible_vin)
            if cleaned:
                vin_value = cleaned

        item: Dict[str, Any] = identifiers.get(
            did,
            {
                "id": format_hex_int(did, 4),
                "name": _common_did_name(did),
                "type": "unspecified",
            },
        )
        if len(payloads) == 1:
            item["bytes"] = bytes_to_hex(payloads[0])
        else:
            item["sequence"] = {
                "responses": [bytes_to_hex(payload) for payload in payloads],
                "repeat": "loop",
            }
        identifiers[did] = item
        handled_requests.add(request_hex)

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 3 or request_bytes[0] != 0x2E:
            continue

        did = (request_bytes[1] << 8) | request_bytes[2]
        positive = [rsp for rsp in responses if len(rsp) >= 3 and rsp[0] == 0x6E and rsp[1] == request_bytes[1] and rsp[2] == request_bytes[2]]
        if not positive:
            continue

        item = identifiers.get(
            did,
            {
                "id": format_hex_int(did, 4),
                "name": _common_did_name(did),
                "type": "unspecified",
                "bytes": "",
            },
        )
        if inferred_write_levels:
            existing_levels = item.get("writeLevels")
            if isinstance(existing_levels, list):
                normalized_existing = {
                    int(level, 0) if isinstance(level, str) else int(level)
                    for level in existing_levels
                }
                merged = sorted(normalized_existing | set(inferred_write_levels))
                item["writeLevels"] = merged
            else:
                item["writeLevels"] = list(inferred_write_levels)

        identifiers[did] = item
        handled_requests.add(request_hex)

    for request_hex, request_bytes, responses in request_entries:
        if len(request_bytes) < 4 or request_bytes[0] != 0x31:
            continue

        control_type = request_bytes[1]
        routine_id = (request_bytes[2] << 8) | request_bytes[3]
        positive = [
            rsp
            for rsp in responses
            if len(rsp) >= 4 and rsp[0] == 0x71 and rsp[1] == control_type and rsp[2] == request_bytes[2] and rsp[3] == request_bytes[3]
        ]
        if not positive:
            continue

        response_field = ""
        if control_type == 0x01:
            response_field = "startResponse"
        elif control_type == 0x02:
            response_field = "stopResponse"
        elif control_type == 0x03:
            response_field = "resultsResponse"
        if not response_field:
            continue

        payloads = _collect_unique_payloads([rsp[4:] for rsp in positive])
        if not payloads:
            continue

        option_record = request_bytes[4:]
        option_key = bytes_to_hex(option_record)
        row_key = (routine_id, option_key)
        row = routine_option_rows.setdefault(
            row_key,
            {"optionRecord": bytes_to_hex(option_record) if option_record else [], "delay": 0},
        )
        row[response_field] = bytes_to_hex(payloads[0])
        routines.setdefault(
            routine_id,
            {
                "id": format_hex_int(routine_id, 4),
                "name": f"Routine_{routine_id:04X}",
                "ignoreOptionRecord": False,
                "responses": [],
            },
        )
        handled_requests.add(request_hex)

    identifiers_list = [identifiers[did] for did in sorted(identifiers)]
    routine_rows_by_id: Dict[int, List[Dict[str, Any]]] = {}
    for (routine_id, _), row in routine_option_rows.items():
        routine_rows_by_id.setdefault(routine_id, []).append(row)

    for routine_id, routine in routines.items():
        rows = routine_rows_by_id.get(routine_id, [])
        if len(rows) == 1 and rows[0].get("optionRecord", []) in ([], ""):
            routine["ignoreOptionRecord"] = True
        routine["responses"] = rows

    ordered_routines = [routines[routine_id] for routine_id in sorted(routines)]
    memory_layout, memory_handled = _infer_uds_memory_layout(request_entries)
    handled_requests.update(memory_handled)
    dtcs, dtc_handled = _infer_uds_dtcs(request_entries)
    handled_requests.update(dtc_handled)
    custom_services = _build_uds_custom_services(request_entries, handled_requests)
    p2_server_max, p2ext_server_max = _infer_uds_session_timings(request_entries)

    return {
        "name": ecu_name,
        "kind": "uds",
        "uds": {
            "arbitration": {
                "request": format_hex_int(candidate.request_id),
                "reply": format_hex_int(candidate.response_id),
                "auxiliary": [format_hex_int(value) for value in candidate.auxiliary_request_ids],
            },
            "vin": vin_value,
            "p2ServerMax": p2_server_max if p2_server_max is not None else 0.05,
            "p2extServerMax": p2ext_server_max if p2ext_server_max is not None else 5.0,
            "s3SessionTimeout": 5.0,
            "identifiers": identifiers_list,
            "routines": ordered_routines,
            "securityLevels": security_levels,
            "memoryLayout": memory_layout,
            "customServices": custom_services,
            "dtcs": dtcs,
            "startupActions": [],
        },
    }


def build_kwp_spec(candidate: ConversationCandidate, ecu_name: str, default_vin: str) -> Dict[str, Any]:
    common_identifiers: List[Dict[str, Any]] = []
    vin_value = candidate.vin or default_vin

    for request_hex, responses in candidate.request_response_map.items():
        request_bytes = [int(part, 16) for part in request_hex.split() if part]
        if len(request_bytes) < 2 or request_bytes[0] != 0x1A:
            continue

        identifier = request_bytes[1]
        positive = [rsp for rsp in responses if len(rsp) >= 2 and rsp[0] == 0x5A and rsp[1] == identifier]
        if not positive:
            continue

        payloads = _collect_unique_payloads([rsp[2:] for rsp in positive])
        if not payloads:
            continue

        if identifier == 0x90:
            possible_vin = "".join(chr(byte) for byte in payloads[0] if 0x20 <= byte <= 0x7E)
            cleaned = clean_vin(possible_vin)
            if cleaned:
                vin_value = cleaned

        item: Dict[str, Any] = {
            "id": format_hex_int(identifier, 2),
            "name": f"KWP_Identifier_{identifier:02X}",
            "type": "unspecified",
        }
        if len(payloads) == 1:
            item["bytes"] = bytes_to_hex(payloads[0])
        else:
            item["sequence"] = {
                "responses": [bytes_to_hex(payload) for payload in payloads],
                "repeat": "loop",
            }
        common_identifiers.append(item)

    common_identifiers.sort(key=lambda item: int(item["id"], 16))

    return {
        "name": ecu_name,
        "kind": "kwp",
        "kwp": {
            "arbitration": {
                "request": format_hex_int(candidate.request_id),
                "reply": format_hex_int(candidate.response_id),
                "auxiliary": [format_hex_int(value) for value in candidate.auxiliary_request_ids],
            },
            "vin": vin_value,
            "p2ServerMax": 0.05,
            "p2extServerMax": 2.0,
            "s3SessionTimeout": 5.0,
            "commonIdentifiers": common_identifiers,
            "ecuIdentifiers": [],
            "localIdentifiers": [],
            "routines": [],
            "securityLevels": [],
            "memoryLayout": [],
            "customServices": [],
            "dtcs": [],
            "startupActions": [],
        },
    }


def build_obd2_spec(candidate: ConversationCandidate, ecu_name: str, default_vin: str) -> Dict[str, Any]:
    vin_value = candidate.vin or default_vin
    for responses in candidate.request_response_map.values():
        for response in responses:
            vin = _extract_vin_from_message_data(response)
            if vin:
                vin_value = vin
                break

    return {
        "name": ecu_name,
        "kind": "obd2",
        "obd2": {
            "arbitrationInfo": {
                "request": format_hex_int(candidate.request_id),
                "reply": format_hex_int(candidate.response_id),
                "auxiliary": [format_hex_int(value) for value in candidate.auxiliary_request_ids],
            },
            "vin": vin_value,
            "frames": [],
            "informational": [],
            "oxygenSensorMonitoring": [],
            "onBoardMonitoring": [],
            "controlOperations": [],
            "dtcs": [],
        },
    }


def build_ecu_spec(candidate: ConversationCandidate, ecu_name: str, default_vin: str) -> Dict[str, Any]:
    protocol = candidate.protocol_hint
    if protocol == "kwp":
        return build_kwp_spec(candidate, ecu_name=ecu_name, default_vin=default_vin)
    if protocol == "obd2":
        return build_obd2_spec(candidate, ecu_name=ecu_name, default_vin=default_vin)
    return build_uds_spec(candidate, ecu_name=ecu_name, default_vin=default_vin)
