from __future__ import annotations

import os
import re
import struct
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple

from .core import (
    CANFrame,
    IsoTpMessage,
    LogAnalysis,
    analyze_conversations,
    clean_vin,
    decode_isotp_messages,
    detect_direction_from_data,
    guess_direction,
    infer_rx_id,
    is_valid_vin_char,
    request_sid_from_response,
)


CANDUMP_REGEX = re.compile(r"^\((\d+\.\d+)\)\s+(\S+)\s+([0-9A-Fa-f]+)#([0-9A-Fa-f]*)$")
CANDUMP_CSV_HEADER_REGEX = re.compile(r"^\s*id\s*,\s*data\s*(bytes)?\s*$", re.IGNORECASE)
ECUCONNECT_TIMESTAMP = re.compile(r"^(\d+):(\d+):(\d+)\.(\d+)$")
CL1000_WITH_T = re.compile(r"^(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})$")
CL1000_NO_T = re.compile(r"^(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})$")


ProgressCallback = Optional[Callable[[Dict[str, Any]], None]]


def _emit_progress(
    callback: ProgressCallback,
    *,
    phase: str,
    current: Optional[int] = None,
    total: Optional[int] = None,
    unit: Optional[str] = None,
    message: Optional[str] = None,
    source_type: Optional[str] = None,
) -> None:
    if 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
    if source_type:
        payload["sourceType"] = source_type
    try:
        callback(payload)
    except Exception:
        pass


def _progress_step(total: int, updates: int = 180) -> int:
    if total <= 0:
        return 1
    return max(1, total // updates)


@dataclass
class InterfaceInfo:
    link_type: int
    ts_resolution: int
    ts_offset: int
    name: Optional[str]


def _load_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return path.read_text(encoding="latin-1", errors="replace")


def _looks_like_ecuconnect(text: str) -> bool:
    sample = text[:1200]
    return "#META," in sample and ("appInfo" in sample or "adapterInfo" in sample)


def _looks_like_candump(text: str) -> bool:
    for line in text.splitlines()[:20]:
        if CANDUMP_REGEX.match(line.strip()):
            return True
    return False


def _looks_like_candump_csv(text: str) -> bool:
    matched = 0
    for line in text.splitlines()[:20]:
        trimmed = line.replace("\ufeff", "").strip()
        if not trimmed:
            continue
        if CANDUMP_CSV_HEADER_REGEX.match(trimmed):
            return True
        if "," not in trimmed:
            continue
        left, right = trimmed.split(",", 1)
        left = left.strip()
        right = right.strip()
        if left.lower() == "id" and "data" in right.lower():
            return True
        id_clean = left[2:] if left.lower().startswith("0x") else left
        if re.fullmatch(r"[0-9A-Fa-f]+", id_clean):
            matched += 1
    return matched >= 2


def _looks_like_cl1000(text: str) -> bool:
    head = "\n".join(text.splitlines()[:50])
    if "Timestamp;Type;ID;Data" in head:
        return True
    if "Logger type: CL1000" in head:
        return True
    return False


def detect_source_type(path: str, content: Optional[bytes] = None) -> str:
    suffix = Path(path).suffix.lower()
    if suffix in {".pcapng"}:
        return "pcapng"
    if suffix in {".pcap", ".cap"}:
        return "pcap"

    if content is None:
        blob = Path(path).read_bytes()
    else:
        blob = content

    if len(blob) >= 4:
        magic = blob[:4]
        if magic == b"\x0a\x0d\x0d\x0a":
            return "pcapng"
        if magic in {
            b"\xd4\xc3\xb2\xa1",
            b"\xa1\xb2\xc3\xd4",
            b"\x4d\x3c\xb2\xa1",
            b"\xa1\xb2\x3c\x4d",
        }:
            return "pcap"

    text = blob.decode("utf-8", errors="replace")
    if _looks_like_ecuconnect(text):
        return "ecuconnect"
    if _looks_like_candump(text):
        return "candump"
    if _looks_like_candump_csv(text):
        return "candump_csv"
    if _looks_like_cl1000(text):
        return "cl1000"
    return "unknown"


def _parse_hex_bytes(value: str) -> List[int]:
    cleaned = value.strip()
    if cleaned.lower().startswith("0x"):
        cleaned = cleaned[2:]
    cleaned = re.sub(r"[^0-9A-Fa-f]", "", cleaned)
    if len(cleaned) < 2:
        return []
    if len(cleaned) % 2 == 1:
        cleaned = cleaned[:-1]
    out: List[int] = []
    for index in range(0, len(cleaned), 2):
        try:
            out.append(int(cleaned[index : index + 2], 16))
        except ValueError:
            pass
    return out


def parse_candump(content: str, progress_callback: ProgressCallback = None) -> Tuple[List[CANFrame], Dict[str, Any]]:
    frames: List[CANFrame] = []
    warnings = 0
    interface: Optional[str] = None
    lines = content.splitlines()
    total_lines = len(lines)
    step = _progress_step(total_lines)

    for line_number, line in enumerate(lines, start=1):
        trimmed = line.strip()
        if not trimmed or trimmed.startswith("#"):
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing candump frames",
                )
            continue
        match = CANDUMP_REGEX.match(trimmed)
        if not match:
            warnings += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing candump frames",
                )
            continue

        ts_raw, iface, can_id_raw, data_raw = match.groups()
        if interface is None:
            interface = iface
        try:
            timestamp_us = int(round(float(ts_raw) * 1_000_000))
            can_id = int(can_id_raw, 16)
        except ValueError:
            warnings += 1
            continue

        data = _parse_hex_bytes(data_raw)
        extended = len(can_id_raw) > 3 or can_id > 0x7FF
        frames.append(
            CANFrame(
                timestamp_us=timestamp_us,
                can_id=can_id,
                extended=extended,
                dlc=len(data),
                data=data,
            )
        )
        if line_number == 1 or line_number == total_lines or line_number % step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=line_number,
                total=total_lines,
                unit="lines",
                message="Parsing candump frames",
            )

    metadata: Dict[str, Any] = {
        "format": "candump",
        "frameCount": len(frames),
        "warnings": warnings,
    }
    if interface:
        metadata["interface"] = interface
    return frames, metadata


def parse_candump_csv(content: str, progress_callback: ProgressCallback = None) -> Tuple[List[CANFrame], Dict[str, Any]]:
    frames: List[CANFrame] = []
    warnings = 0
    frame_index = 0
    lines = content.splitlines()
    total_lines = len(lines)
    step = _progress_step(total_lines)

    for line_number, line in enumerate(lines, start=1):
        trimmed = line.replace("\ufeff", "").strip()
        if not trimmed:
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CSV CAN frames",
                )
            continue
        if CANDUMP_CSV_HEADER_REGEX.match(trimmed):
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CSV CAN frames",
                )
            continue
        if "," not in trimmed:
            warnings += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CSV CAN frames",
                )
            continue

        id_raw, data_raw = trimmed.split(",", 1)
        id_raw = id_raw.strip()
        data_raw = data_raw.strip()
        if id_raw.lower() == "id" and "data" in data_raw.lower():
            continue

        try:
            can_id = int(id_raw, 16) if id_raw.lower().startswith("0x") else int(id_raw, 16)
        except ValueError:
            warnings += 1
            continue

        bytes_out = _parse_hex_bytes(data_raw)
        frames.append(
            CANFrame(
                timestamp_us=frame_index * 1000,
                can_id=can_id,
                extended=can_id > 0x7FF,
                dlc=len(bytes_out),
                data=bytes_out,
            )
        )
        frame_index += 1
        if line_number == 1 or line_number == total_lines or line_number % step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=line_number,
                total=total_lines,
                unit="lines",
                message="Parsing CSV CAN frames",
            )

    metadata: Dict[str, Any] = {
        "format": "candump_csv",
        "frameCount": len(frames),
        "warnings": warnings,
        "syntheticTimestamps": True,
    }
    return frames, metadata


def _parse_cl1000_timestamp(value: str) -> Optional[int]:
    match = CL1000_WITH_T.match(value) or CL1000_NO_T.match(value)
    if not match:
        return None

    days, hours, minutes, seconds, millis = [int(part) for part in match.groups()]
    total_ms = ((days - 1) * 86400 + hours * 3600 + minutes * 60 + seconds) * 1000 + millis
    return total_ms * 1000


def parse_cl1000(content: str, progress_callback: ProgressCallback = None) -> Tuple[List[CANFrame], Dict[str, Any]]:
    frames: List[CANFrame] = []
    warnings = 0
    in_data = False
    lines = content.splitlines()
    total_lines = len(lines)
    step = _progress_step(total_lines)

    for line_number, line in enumerate(lines, start=1):
        trimmed = line.strip()
        if not trimmed:
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CL1000 frames",
                )
            continue
        if trimmed.startswith("#"):
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CL1000 frames",
                )
            continue
        if trimmed == "Timestamp;Type;ID;Data":
            in_data = True
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CL1000 frames",
                )
            continue
        if not in_data:
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing CL1000 frames",
                )
            continue

        parts = trimmed.split(";")
        if len(parts) < 4:
            warnings += 1
            continue

        timestamp_us = _parse_cl1000_timestamp(parts[0].strip())
        if timestamp_us is None:
            warnings += 1
            continue

        type_raw = parts[1].strip()
        id_raw = parts[2].strip()
        data_raw = parts[3].strip()

        if type_raw not in {"0", "1"}:
            warnings += 1
            continue

        try:
            can_id = int(id_raw, 16)
        except ValueError:
            warnings += 1
            continue

        data = _parse_hex_bytes(data_raw)
        frames.append(
            CANFrame(
                timestamp_us=timestamp_us,
                can_id=can_id,
                extended=(type_raw == "1"),
                dlc=len(data),
                data=data,
            )
        )
        if line_number == 1 or line_number == total_lines or line_number % step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=line_number,
                total=total_lines,
                unit="lines",
                message="Parsing CL1000 frames",
            )

    metadata: Dict[str, Any] = {
        "format": "cl1000",
        "frameCount": len(frames),
        "warnings": warnings,
    }
    return frames, metadata


def _parse_ecuconnect_timestamp(value: str) -> Optional[int]:
    match = ECUCONNECT_TIMESTAMP.match(value.strip())
    if not match:
        return None
    hours, minutes, seconds, millis = match.groups()
    ms = int(millis.ljust(3, "0")[:3])
    total_us = ((int(hours) * 3600 + int(minutes) * 60 + int(seconds)) * 1000 + ms) * 1000
    return total_us


def _read_u32_be(data: List[int], offset: int) -> Optional[int]:
    if len(data) < offset + 4:
        return None
    return (
        (data[offset] << 24)
        | (data[offset + 1] << 16)
        | (data[offset + 2] << 8)
        | data[offset + 3]
    )


def _parse_pdu_frame(hex_payload: str) -> Optional[Tuple[int, List[int]]]:
    data = _parse_hex_bytes(hex_payload)
    if len(data) < 4:
        return None
    if data[0] != 0x1F:
        return None
    pdu_type = data[1]
    payload_length = (data[2] << 8) | data[3]
    if len(data) < 4 + payload_length:
        return None
    return pdu_type, data[4 : 4 + payload_length]


def _parse_arbitration_payload(payload: List[int], offset: int) -> Optional[Dict[str, int]]:
    if len(payload) < offset + 14:
        return None
    request = _read_u32_be(payload, offset)
    request_ext = payload[offset + 4]
    reply_pattern = _read_u32_be(payload, offset + 5)
    reply_mask = _read_u32_be(payload, offset + 9)
    reply_ext = payload[offset + 13]
    if request is None or reply_pattern is None or reply_mask is None:
        return None
    return {
        "request": request,
        "request_extension": request_ext,
        "reply_pattern": reply_pattern,
        "reply_mask": reply_mask,
        "reply_extension": reply_ext,
    }


def parse_ecuconnect(content: str, progress_callback: ProgressCallback = None) -> Tuple[List[IsoTpMessage], Dict[str, Any]]:
    metadata: Dict[str, Any] = {
        "format": "ecuconnect",
        "messageCount": 0,
        "errorCount": 0,
        "oobCount": 0,
        "unsolCount": 0,
        "malformedLineCount": 0,
        "ecuAddresses": [],
    }

    raw_messages: List[Dict[str, Any]] = []
    arbitration_entries: List[Dict[str, Any]] = []
    address_request_count: Dict[int, int] = {}
    address_response_count: Dict[int, int] = {}
    ecu_addresses: set[int] = set()

    first_ts: Optional[int] = None
    last_ts: Optional[int] = None

    lines = content.splitlines()
    total_lines = len(lines)
    step = _progress_step(total_lines)

    for line_number, line in enumerate(lines, start=1):
        trimmed = line.strip()
        if not trimmed:
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if trimmed.startswith("#META,"):
            parts = trimmed[6:].split(",")
            if len(parts) >= 2:
                key = parts[0]
                value = ",".join(parts[1:])
                metadata[key] = value
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if trimmed.startswith("#"):
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        parts = [item.strip() for item in trimmed.split(",")]
        if len(parts) < 2:
            metadata["malformedLineCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        timestamp_us = _parse_ecuconnect_timestamp(parts[0])
        if timestamp_us is None:
            metadata["malformedLineCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if first_ts is None:
            first_ts = timestamp_us
        last_ts = timestamp_us

        verb = parts[1].upper()
        if verb in {"READ", "WRITE", "WROTE"}:
            if verb in {"WRITE", "WROTE"} and len(parts) >= 3:
                parsed = _parse_pdu_frame(parts[2])
                if parsed is not None:
                    pdu_type, payload = parsed
                    if pdu_type in {0x34, 0x35}:
                        arbitration = _parse_arbitration_payload(payload, 1)
                        if arbitration is not None:
                            arbitration["timestamp_us"] = timestamp_us
                            arbitration_entries.append(arbitration)
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if verb.startswith("ERROR"):
            metadata["errorCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if verb == "OOB":
            metadata["oobCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if verb == "UNSOL":
            metadata["unsolCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        if len(parts) < 3:
            metadata["malformedLineCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        try:
            source_addr = int(parts[1], 16)
        except ValueError:
            metadata["malformedLineCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        data = _parse_hex_bytes(parts[2])
        if not data:
            metadata["malformedLineCount"] += 1
            if line_number == 1 or line_number == total_lines or line_number % step == 0:
                _emit_progress(
                    progress_callback,
                    phase="parse",
                    current=line_number,
                    total=total_lines,
                    unit="lines",
                    message="Parsing ECUconnect messages",
                )
            continue

        ecu_addresses.add(source_addr)

        sid = data[0]
        direction = detect_direction_from_data(data) or "request"
        request_sid = sid if direction == "request" else request_sid_from_response(data)

        if direction == "request":
            address_request_count[source_addr] = address_request_count.get(source_addr, 0) + 1
        else:
            address_response_count[source_addr] = address_response_count.get(source_addr, 0) + 1

        raw_messages.append(
            {
                "timestamp_us": timestamp_us,
                "source": source_addr,
                "data": data,
                "direction": direction,
                "request_sid": request_sid,
            }
        )
        if line_number == 1 or line_number == total_lines or line_number % step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=line_number,
                total=total_lines,
                unit="lines",
                message="Parsing ECUconnect messages",
            )

    tester_addr = -1
    max_score = -1.0
    for address, req_count in address_request_count.items():
        resp_count = address_response_count.get(address, 0)
        total = req_count + resp_count
        if total <= 0:
            continue
        ratio = req_count / total
        if ratio <= 0.5:
            continue
        adjusted = ratio + (0.1 if 0xF0 <= address <= 0xFF else 0.0)
        if adjusted > max_score:
            max_score = adjusted
            tester_addr = address

    if tester_addr < 0:
        for address in sorted(ecu_addresses):
            if 0xF0 <= address <= 0xFF:
                tester_addr = address
                break

    metadata["testerAddress"] = f"0x{tester_addr:X}" if tester_addr >= 0 else None
    metadata["isotpAddressing"] = (
        "extended"
        if any(item.get("request_extension", 0) != 0 or item.get("reply_extension", 0) != 0 for item in arbitration_entries)
        else "normal"
    )

    messages: List[IsoTpMessage] = []

    def find_arbitration(timestamp_us: int, direction: str, tx_id: int) -> Optional[Dict[str, int]]:
        cutoff = timestamp_us + 500_000
        last_before: Optional[Dict[str, int]] = None
        first_after: Optional[Dict[str, int]] = None

        for entry in arbitration_entries:
            entry_ts = int(entry.get("timestamp_us", 0))
            if entry_ts > cutoff:
                break

            if direction == "request":
                matches = entry.get("request") == tx_id
            else:
                reply_mask = int(entry.get("reply_mask", 0))
                reply_pattern = int(entry.get("reply_pattern", 0))
                matches = (tx_id & reply_mask) == (reply_pattern & reply_mask)

            if not matches:
                continue

            if entry_ts <= timestamp_us:
                last_before = entry
            elif first_after is None:
                first_after = entry

        return last_before or first_after

    for index, raw in enumerate(raw_messages):
        direction = raw["direction"]
        source = raw["source"]

        if direction == "response":
            tx_id = source
            rx_id = tester_addr if tester_addr >= 0 else source
        else:
            tx_id = source
            if source == tester_addr:
                response_window = 5_000_000
                responders: set[int] = set()
                for future in raw_messages[index + 1 :]:
                    delta = int(future["timestamp_us"]) - int(raw["timestamp_us"])
                    if delta > response_window:
                        break
                    if future["source"] == tester_addr and future["direction"] == "request":
                        break
                    if future["direction"] == "response" and future.get("request_sid") == raw.get("request_sid"):
                        responders.add(future["source"])
                if len(responders) == 1:
                    rx_id = next(iter(responders))
                else:
                    rx_id = tester_addr
            else:
                rx_id = tester_addr if tester_addr >= 0 else source

        arbitration = find_arbitration(raw["timestamp_us"], direction, tx_id)
        ext_addr = None
        if arbitration is not None:
            ext_addr = (
                arbitration.get("request_extension")
                if direction == "request"
                else arbitration.get("reply_extension")
            )

        messages.append(
            IsoTpMessage(
                timestamp_us=int(raw["timestamp_us"]),
                tx_id=tx_id,
                rx_id=rx_id,
                direction=direction,
                data=list(raw["data"]),
                frame_count=1,
                complete=True,
                frame_indices=[],
                extended_addr=ext_addr,
                source="ecuconnect",
            )
        )

    metadata["messageCount"] = len(messages)
    metadata["ecuAddresses"] = [f"0x{value:X}" for value in sorted(ecu_addresses)]
    if first_ts is not None and last_ts is not None and last_ts >= first_ts:
        metadata["durationUs"] = last_ts - first_ts

    if not metadata.get("vin"):
        for message in messages:
            vin = _extract_vin_from_data(message.data)
            if vin:
                metadata["vin"] = vin
                break

    return messages, metadata


def _extract_vin_from_data(data: List[int]) -> Optional[str]:
    if len(data) >= 20 and data[0] == 0x62 and data[1] == 0xF1 and data[2] == 0x90:
        payload = data[3:20]
        if all(is_valid_vin_char(item) for item in payload):
            return "".join(chr(item) for item in payload)

    if len(data) >= 3 and data[0] == 0x49 and data[1] == 0x02:
        ascii_chars = [chr(item) for item in data[2:] if 0x20 <= item <= 0x7E]
        cleaned = clean_vin("".join(ascii_chars))
        if cleaned:
            return cleaned

    return None


VIN_CHECK_DIGIT_WEIGHTS = (8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2)
VIN_TRANSLITERATION: Dict[str, int] = {
    **{str(index): index for index in range(10)},
    "A": 1,
    "B": 2,
    "C": 3,
    "D": 4,
    "E": 5,
    "F": 6,
    "G": 7,
    "H": 8,
    "J": 1,
    "K": 2,
    "L": 3,
    "M": 4,
    "N": 5,
    "P": 7,
    "R": 9,
    "S": 2,
    "T": 3,
    "U": 4,
    "V": 5,
    "W": 6,
    "X": 7,
    "Y": 8,
    "Z": 9,
}
VIN_PLACEHOLDER_VALUES = {"A" * 17, "0" * 17, "F" * 17, "X" * 17, "?" * 17}


def _normalize_vin_candidate(vin: Optional[str]) -> Optional[str]:
    cleaned = clean_vin(vin)
    if not cleaned or len(cleaned) != 17:
        return None
    return cleaned


def _expected_vin_check_digit(vin: str) -> Optional[str]:
    if len(vin) != 17:
        return None
    total = 0
    for char, weight in zip(vin, VIN_CHECK_DIGIT_WEIGHTS):
        value = VIN_TRANSLITERATION.get(char)
        if value is None:
            return None
        total += value * weight
    remainder = total % 11
    return "X" if remainder == 10 else str(remainder)


def _score_vin_candidate(vin: str) -> int:
    if len(vin) != 17:
        return -1000

    score = 10
    if all(is_valid_vin_char(ord(char)) for char in vin):
        score += 10
    else:
        score -= 12

    unique_chars = len(set(vin))
    if unique_chars <= 1:
        score -= 16
    elif unique_chars <= 3:
        score -= 8

    if vin in VIN_PLACEHOLDER_VALUES:
        score -= 16

    expected_check_digit = _expected_vin_check_digit(vin)
    if expected_check_digit is not None:
        if vin[8] == expected_check_digit:
            score += 4
        else:
            score -= 2

    return score


def _pick_best_vin(candidates: Iterable[str], min_score: int = 0) -> Optional[str]:
    normalized: List[str] = []
    first_seen: Dict[str, int] = {}

    for candidate in candidates:
        vin = _normalize_vin_candidate(candidate)
        if not vin:
            continue
        if vin not in first_seen:
            first_seen[vin] = len(normalized)
        normalized.append(vin)

    if not normalized:
        return None

    counts = Counter(normalized)
    best = max(
        counts,
        key=lambda value: (_score_vin_candidate(value), counts[value], -first_seen[value]),
    )
    if _score_vin_candidate(best) < min_score:
        return None
    return best


def _choose_metadata_vin(diagnostic_vins: Iterable[str], discovery_vins: Iterable[str]) -> Optional[str]:
    # Explicit diagnostic service responses (e.g. UDS 22F190, OBD-II 0902)
    # are preferred over passive discovery broadcasts.
    diagnostic = _pick_best_vin(diagnostic_vins, min_score=0)
    if diagnostic:
        return diagnostic
    return _pick_best_vin(discovery_vins, min_score=8)


def _read_u16(data: bytes, offset: int, byteorder: str) -> int:
    return int.from_bytes(data[offset : offset + 2], byteorder=byteorder, signed=False)


def _read_u32(data: bytes, offset: int, byteorder: str) -> int:
    return int.from_bytes(data[offset : offset + 4], byteorder=byteorder, signed=False)


def _read_i64(data: bytes, offset: int, byteorder: str) -> int:
    return int.from_bytes(data[offset : offset + 8], byteorder=byteorder, signed=True)


def _parse_pcapng_options(data: bytes, byteorder: str) -> Dict[int, bytes]:
    result: Dict[int, bytes] = {}
    offset = 0
    while offset + 4 <= len(data):
        code = _read_u16(data, offset, byteorder)
        length = _read_u16(data, offset + 2, byteorder)
        offset += 4
        if code == 0:
            break
        if offset + length > len(data):
            break
        value = data[offset : offset + length]
        result[code] = value
        offset += ((length + 3) // 4) * 4
    return result


def _ts_resolution(options: Dict[int, bytes]) -> int:
    value = options.get(9)
    if not value:
        return 1_000_000
    first = value[0]
    is_binary = (first & 0x80) != 0
    power = first & 0x7F
    if is_binary:
        return 1 << power
    return 10 ** power


def _ts_offset(options: Dict[int, bytes], byteorder: str) -> int:
    value = options.get(14)
    if not value or len(value) < 8:
        return 0
    result = _read_i64(value, 0, byteorder)
    return result if result > 0 else 0


def _decode_name(options: Dict[int, bytes]) -> Optional[str]:
    for code in (2, 3):
        value = options.get(code)
        if not value:
            continue
        try:
            text = value.decode("utf-8", errors="replace").strip()
        except Exception:
            continue
        if text:
            return text
    return None


def _format_ip_v4(data: bytes, offset: int) -> str:
    return ".".join(str(part) for part in data[offset : offset + 4])


def _format_ip_v6(data: bytes, offset: int) -> str:
    chunks = [f"{(data[offset + i] << 8) | data[offset + i + 1]:x}" for i in range(0, 16, 2)]
    return ":".join(chunks)


def _extract_hsfz_messages(payload: bytes) -> List[Tuple[int, int, bytes]]:
    out: List[Tuple[int, int, bytes]] = []
    offset = 0
    while offset + 8 <= len(payload):
        payload_len = int.from_bytes(payload[offset : offset + 4], "big", signed=False)
        ctrl = int.from_bytes(payload[offset + 4 : offset + 6], "big", signed=False)
        source = payload[offset + 6]
        target = payload[offset + 7]
        message_size = 6 + payload_len
        if payload_len <= 2 or offset + message_size > len(payload):
            break
        if ctrl == 0x0001:
            uds = payload[offset + 8 : offset + 6 + payload_len]
            if uds:
                out.append((source, target, uds))
        offset += message_size
    return out


def _extract_doip_messages(payload: bytes) -> List[Tuple[int, int, bytes]]:
    out: List[Tuple[int, int, bytes]] = []
    offset = 0
    while offset + 8 <= len(payload):
        message_type = int.from_bytes(payload[offset + 2 : offset + 4], "big", signed=False)
        payload_len = int.from_bytes(payload[offset + 4 : offset + 8], "big", signed=False)
        message_size = 8 + payload_len
        if offset + message_size > len(payload):
            break
        if message_type == 0x8001 and payload_len >= 4:
            source = int.from_bytes(payload[offset + 8 : offset + 10], "big", signed=False)
            target = int.from_bytes(payload[offset + 10 : offset + 12], "big", signed=False)
            uds = payload[offset + 12 : offset + 8 + payload_len]
            if uds:
                out.append((source, target, uds))
        offset += message_size
    return out


def _extract_vin_from_hsfz(payload: bytes) -> Optional[str]:
    marker = b"BMWVIN"
    position = payload.find(marker)
    if position < 0:
        return None
    start = position + len(marker)
    if start + 17 > len(payload):
        return None
    vin = payload[start : start + 17]
    if all(is_valid_vin_char(item) for item in vin):
        return vin.decode("ascii", errors="ignore")
    return None


def _extract_vin_from_payload(payload: bytes) -> Optional[str]:
    if len(payload) < 17:
        return None
    upper = bytes(item if 65 <= item <= 90 or 48 <= item <= 57 else ord(" ") for item in payload.upper())

    marker = upper.find(b"VIN")
    if marker >= 0 and marker + 3 + 17 <= len(upper):
        candidate = upper[marker + 3 : marker + 20]
        if all(is_valid_vin_char(item) for item in candidate):
            return candidate.decode("ascii", errors="ignore")

    for index in range(0, len(upper) - 16):
        candidate = upper[index : index + 17]
        if all(is_valid_vin_char(item) for item in candidate):
            return candidate.decode("ascii", errors="ignore")
    return None


def _extract_packet_payload(
    data: bytes,
    link_type: int = 1,
) -> Tuple[set[str], set[str], List[Tuple[int, int, bytes]], List[Tuple[int, int, bytes]], Optional[str]]:
    source_ips: set[str] = set()
    dest_ips: set[str] = set()
    hsfz: List[Tuple[int, int, bytes]] = []
    doip: List[Tuple[int, int, bytes]] = []
    vin: Optional[str] = None

    payload = data
    force_ipv4 = False
    force_ipv6 = False

    if link_type == 1:
        if len(data) < 14:
            return source_ips, dest_ips, hsfz, doip, vin
        eth_type = int.from_bytes(data[12:14], "big", signed=False)
        payload = data[14:]
        if eth_type == 0x0800:
            force_ipv4 = True
        elif eth_type == 0x86DD:
            force_ipv6 = True
        else:
            return source_ips, dest_ips, hsfz, doip, vin
    elif link_type in {0, 108}:
        if len(data) < 4:
            return source_ips, dest_ips, hsfz, doip, vin
        family_le = int.from_bytes(data[0:4], "little", signed=False)
        family_be = int.from_bytes(data[0:4], "big", signed=False)
        payload = data[4:]
        ipv4_families = {2}
        ipv6_families = {24, 28, 30}
        if family_le in ipv4_families or family_be in ipv4_families:
            force_ipv4 = True
        elif family_le in ipv6_families or family_be in ipv6_families:
            force_ipv6 = True
        else:
            version = (payload[0] >> 4) if payload else 0
            force_ipv4 = version == 4
            force_ipv6 = version == 6
    elif link_type == 101:
        version = (payload[0] >> 4) if payload else 0
        force_ipv4 = version == 4
        force_ipv6 = version == 6
    else:
        return source_ips, dest_ips, hsfz, doip, vin

    if force_ipv4 and len(payload) >= 20:
        ip_header = payload
        ihl = (ip_header[0] & 0x0F) * 4
        if len(ip_header) < ihl or ihl < 20:
            return source_ips, dest_ips, hsfz, doip, vin
        protocol = ip_header[9]
        source_ips.add(_format_ip_v4(ip_header, 12))
        dest_ips.add(_format_ip_v4(ip_header, 16))
        transport = ip_header[ihl:]
    elif force_ipv6 and len(payload) >= 40:
        ip6_header = payload
        protocol = ip6_header[6]
        source_ips.add(_format_ip_v6(ip6_header, 8))
        dest_ips.add(_format_ip_v6(ip6_header, 24))
        transport = ip6_header[40:]
    else:
        return source_ips, dest_ips, hsfz, doip, vin

    if protocol == 6 and len(transport) >= 20:
        data_offset = ((transport[12] >> 4) & 0x0F) * 4
        if len(transport) < data_offset:
            return source_ips, dest_ips, hsfz, doip, vin
        app_payload = transport[data_offset:]
        hsfz = _extract_hsfz_messages(app_payload)
        doip = _extract_doip_messages(app_payload)
        vin = _extract_vin_from_hsfz(app_payload)
    elif protocol == 17 and len(transport) >= 8:
        app_payload = transport[8:]
        vin = _extract_vin_from_hsfz(app_payload)
        if vin is None and len(app_payload) >= 25:
            message_type = int.from_bytes(app_payload[2:4], "big", signed=False)
            payload_len = int.from_bytes(app_payload[4:8], "big", signed=False)
            if message_type == 0x0004 and len(app_payload) >= 8 + payload_len and payload_len >= 17:
                vin = _extract_vin_from_payload(app_payload[8:25])

    return source_ips, dest_ips, hsfz, doip, vin


def parse_pcapng(path: str, progress_callback: ProgressCallback = None) -> Tuple[List[IsoTpMessage], Dict[str, Any]]:
    blob = Path(path).read_bytes()
    messages: List[IsoTpMessage] = []
    metadata: Dict[str, Any] = {
        "format": "pcapng",
        "packetCount": 0,
        "messageCount": 0,
        "protocol": "unknown",
        "sourceIps": [],
        "destIps": [],
        "ecuAddresses": [],
        "interfaces": [],
    }

    interfaces: List[InterfaceInfo] = []
    source_ips: set[str] = set()
    dest_ips: set[str] = set()
    ecu_addresses: set[str] = set()
    interface_names: set[str] = set()
    has_hsfz = False
    has_doip = False
    diagnostic_vins: List[str] = []
    discovery_vins: List[str] = []
    first_ts: Optional[int] = None
    last_ts: Optional[int] = None

    offset = 0
    total_bytes = len(blob)
    byte_step = _progress_step(total_bytes, updates=220)
    byteorder = "little"
    first_block = True

    while offset + 12 <= len(blob):
        block_type_raw = blob[offset : offset + 4]
        if block_type_raw == b"\x0a\x0d\x0d\x0a":
            if offset + 12 > len(blob):
                break
            bom = blob[offset + 8 : offset + 12]
            if bom == b"\x4d\x3c\x2b\x1a":
                section_order = "little"
            elif bom == b"\x1a\x2b\x3c\x4d":
                section_order = "big"
            else:
                section_order = byteorder if not first_block else "little"
            block_type = 0x0A0D0D0A
            block_len = int.from_bytes(blob[offset + 4 : offset + 8], byteorder=section_order, signed=False)
            byteorder = section_order
        else:
            block_type = int.from_bytes(block_type_raw, byteorder=byteorder, signed=False)
            block_len = int.from_bytes(blob[offset + 4 : offset + 8], byteorder=byteorder, signed=False)

        if block_len < 12 or offset + block_len > len(blob):
            break
        body = blob[offset + 8 : offset + block_len - 4]

        if block_type == 0x0A0D0D0A and len(body) >= 4:
            bom = body[:4]
            if bom == b"\x4d\x3c\x2b\x1a":
                byteorder = "little"
            elif bom == b"\x1a\x2b\x3c\x4d":
                byteorder = "big"
            else:
                byteorder = "little"
            interfaces = []
            first_block = False

        elif block_type == 0x00000001 and len(body) >= 8:
            link_type = _read_u16(body, 0, byteorder)
            options = _parse_pcapng_options(body[8:], byteorder)
            ts_res = _ts_resolution(options)
            ts_off = _ts_offset(options, byteorder)
            name = _decode_name(options)
            if name:
                interface_names.add(name)
            interfaces.append(InterfaceInfo(link_type=link_type, ts_resolution=ts_res, ts_offset=ts_off, name=name))

        elif block_type == 0x00000006 and len(body) >= 20:
            metadata["packetCount"] += 1
            interface_id = _read_u32(body, 0, byteorder)
            if interface_id >= len(interfaces):
                offset += block_len
                continue
            iface = interfaces[interface_id]
            if iface.link_type != 1:
                offset += block_len
                continue

            ts_high = _read_u32(body, 4, byteorder)
            ts_low = _read_u32(body, 8, byteorder)
            cap_len = _read_u32(body, 12, byteorder)
            packet_data_start = 20
            packet_data_end = packet_data_start + cap_len
            if packet_data_end > len(body):
                offset += block_len
                continue
            packet_data = body[packet_data_start:packet_data_end]

            raw_ts = ((ts_high << 32) | ts_low)
            ts_resolution = max(iface.ts_resolution, 1)
            seconds = iface.ts_offset + (raw_ts // ts_resolution)
            fraction = raw_ts % ts_resolution
            timestamp_us = seconds * 1_000_000 + (fraction * 1_000_000 // ts_resolution)

            src, dst, hsfz_msgs, doip_msgs, packet_vin = _extract_packet_payload(packet_data, link_type=iface.link_type)
            source_ips.update(src)
            dest_ips.update(dst)
            if packet_vin:
                discovery_vins.append(packet_vin)

            extracted = False
            for tx, rx, uds in hsfz_msgs:
                has_hsfz = True
                ecu_addresses.add(f"0x{tx:X}")
                ecu_addresses.add(f"0x{rx:X}")
                direction = detect_direction_from_data(list(uds)) or guess_direction(tx)
                messages.append(
                    IsoTpMessage(
                        timestamp_us=timestamp_us,
                        tx_id=tx,
                        rx_id=rx,
                        direction=direction,
                        data=list(uds),
                        frame_count=1,
                        complete=True,
                        frame_indices=[],
                        source="pcapng-hsfz",
                    )
                )
                candidate_vin = _extract_vin_from_data(list(uds))
                if candidate_vin:
                    diagnostic_vins.append(candidate_vin)
                extracted = True

            for tx, rx, uds in doip_msgs:
                has_doip = True
                ecu_addresses.add(f"0x{tx:X}")
                ecu_addresses.add(f"0x{rx:X}")
                direction = detect_direction_from_data(list(uds)) or guess_direction(tx)
                messages.append(
                    IsoTpMessage(
                        timestamp_us=timestamp_us,
                        tx_id=tx,
                        rx_id=rx,
                        direction=direction,
                        data=list(uds),
                        frame_count=1,
                        complete=True,
                        frame_indices=[],
                        source="pcapng-doip",
                    )
                )
                candidate_vin = _extract_vin_from_data(list(uds))
                if candidate_vin:
                    diagnostic_vins.append(candidate_vin)
                extracted = True

            if extracted:
                if first_ts is None:
                    first_ts = timestamp_us
                last_ts = timestamp_us

        offset += block_len
        if offset == block_len or offset >= total_bytes or offset % byte_step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=min(offset, total_bytes),
                total=total_bytes,
                unit="bytes",
                message="Parsing PCAPNG packets",
            )

    protocol = "unknown"
    if has_hsfz and has_doip:
        protocol = "mixed"
    elif has_hsfz:
        protocol = "hsfz"
    elif has_doip:
        protocol = "doip"

    metadata["protocol"] = protocol
    metadata["messageCount"] = len(messages)
    metadata["sourceIps"] = sorted(source_ips)
    metadata["destIps"] = sorted(dest_ips)
    metadata["ecuAddresses"] = sorted(ecu_addresses)
    metadata["interfaces"] = sorted(interface_names)
    vin = _choose_metadata_vin(diagnostic_vins, discovery_vins)
    if vin:
        metadata["vin"] = vin
    if first_ts is not None and last_ts is not None and last_ts >= first_ts:
        metadata["durationUs"] = last_ts - first_ts
        metadata["captureStart"] = datetime.fromtimestamp(first_ts / 1_000_000, tz=timezone.utc).isoformat()
        metadata["captureEnd"] = datetime.fromtimestamp(last_ts / 1_000_000, tz=timezone.utc).isoformat()

    return messages, metadata


def parse_pcap(path: str, progress_callback: ProgressCallback = None) -> Tuple[List[IsoTpMessage], Dict[str, Any]]:
    blob = Path(path).read_bytes()
    metadata: Dict[str, Any] = {
        "format": "pcap",
        "packetCount": 0,
        "messageCount": 0,
        "protocol": "unknown",
        "sourceIps": [],
        "destIps": [],
        "ecuAddresses": [],
        "interfaces": [],
    }

    if len(blob) < 24:
        return [], metadata

    magic = blob[:4]
    if magic == b"\xd4\xc3\xb2\xa1":
        endian = "<"
        ts_nanos = False
    elif magic == b"\xa1\xb2\xc3\xd4":
        endian = ">"
        ts_nanos = False
    elif magic == b"\x4d\x3c\xb2\xa1":
        endian = "<"
        ts_nanos = True
    elif magic == b"\xa1\xb2\x3c\x4d":
        endian = ">"
        ts_nanos = True
    else:
        return [], metadata

    network = struct.unpack(endian + "I", blob[20:24])[0]
    if network != 1:
        return [], metadata

    offset = 24
    total_bytes = len(blob)
    byte_step = _progress_step(total_bytes, updates=220)
    messages: List[IsoTpMessage] = []
    source_ips: set[str] = set()
    dest_ips: set[str] = set()
    ecu_addresses: set[str] = set()
    has_hsfz = False
    has_doip = False
    diagnostic_vins: List[str] = []
    discovery_vins: List[str] = []

    while offset + 16 <= len(blob):
        ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(endian + "IIII", blob[offset : offset + 16])
        offset += 16
        if offset + incl_len > len(blob):
            break
        packet_data = blob[offset : offset + incl_len]
        offset += incl_len

        metadata["packetCount"] += 1

        timestamp_us = ts_sec * 1_000_000 + (ts_frac // 1000 if ts_nanos else ts_frac)
        src, dst, hsfz_msgs, doip_msgs, packet_vin = _extract_packet_payload(packet_data, link_type=network)
        source_ips.update(src)
        dest_ips.update(dst)
        if packet_vin:
            discovery_vins.append(packet_vin)

        for tx, rx, uds in hsfz_msgs:
            has_hsfz = True
            ecu_addresses.add(f"0x{tx:X}")
            ecu_addresses.add(f"0x{rx:X}")
            direction = detect_direction_from_data(list(uds)) or guess_direction(tx)
            messages.append(
                IsoTpMessage(
                    timestamp_us=timestamp_us,
                    tx_id=tx,
                    rx_id=rx,
                    direction=direction,
                    data=list(uds),
                    frame_count=1,
                    complete=True,
                    frame_indices=[],
                    source="pcap-hsfz",
                )
            )
            candidate_vin = _extract_vin_from_data(list(uds))
            if candidate_vin:
                diagnostic_vins.append(candidate_vin)

        if offset == 24 + 16 + incl_len or offset >= total_bytes or offset % byte_step == 0:
            _emit_progress(
                progress_callback,
                phase="parse",
                current=min(offset, total_bytes),
                total=total_bytes,
                unit="bytes",
                message="Parsing PCAP packets",
            )

        for tx, rx, uds in doip_msgs:
            has_doip = True
            ecu_addresses.add(f"0x{tx:X}")
            ecu_addresses.add(f"0x{rx:X}")
            direction = detect_direction_from_data(list(uds)) or guess_direction(tx)
            messages.append(
                IsoTpMessage(
                    timestamp_us=timestamp_us,
                    tx_id=tx,
                    rx_id=rx,
                    direction=direction,
                    data=list(uds),
                    frame_count=1,
                    complete=True,
                    frame_indices=[],
                    source="pcap-doip",
                )
            )
            candidate_vin = _extract_vin_from_data(list(uds))
            if candidate_vin:
                diagnostic_vins.append(candidate_vin)

    protocol = "unknown"
    if has_hsfz and has_doip:
        protocol = "mixed"
    elif has_hsfz:
        protocol = "hsfz"
    elif has_doip:
        protocol = "doip"

    metadata["protocol"] = protocol
    metadata["messageCount"] = len(messages)
    metadata["sourceIps"] = sorted(source_ips)
    metadata["destIps"] = sorted(dest_ips)
    metadata["ecuAddresses"] = sorted(ecu_addresses)
    vin = _choose_metadata_vin(diagnostic_vins, discovery_vins)
    if vin:
        metadata["vin"] = vin

    return messages, metadata


def analyze_log_file(path: str, progress_callback: ProgressCallback = None) -> LogAnalysis:
    _emit_progress(progress_callback, phase="detect", message="Detecting log format")
    source_type = detect_source_type(path)
    _emit_progress(
        progress_callback,
        phase="detect",
        current=1,
        total=1,
        unit="steps",
        message=f"Detected source format: {source_type}",
        source_type=source_type,
    )
    file_path = Path(path)

    if source_type in {"pcapng", "pcap"}:
        if source_type == "pcapng":
            messages, metadata = parse_pcapng(path, progress_callback=progress_callback)
        else:
            messages, metadata = parse_pcap(path, progress_callback=progress_callback)
    else:
        _emit_progress(progress_callback, phase="load", message="Loading text log content")
        text = _load_text(file_path)
        _emit_progress(
            progress_callback,
            phase="load",
            current=len(text),
            total=max(len(text), 1),
            unit="chars",
            message="Loaded text log content",
            source_type=source_type,
        )
        if source_type == "ecuconnect":
            messages, metadata = parse_ecuconnect(text, progress_callback=progress_callback)
        elif source_type == "candump":
            frames, metadata = parse_candump(text, progress_callback=progress_callback)
            addressing, messages = decode_isotp_messages(frames, addressing="auto", progress_callback=progress_callback)
            metadata["isotpAddressing"] = addressing
        elif source_type == "candump_csv":
            frames, metadata = parse_candump_csv(text, progress_callback=progress_callback)
            addressing, messages = decode_isotp_messages(frames, addressing="auto", progress_callback=progress_callback)
            metadata["isotpAddressing"] = addressing
        elif source_type == "cl1000":
            frames, metadata = parse_cl1000(text, progress_callback=progress_callback)
            addressing, messages = decode_isotp_messages(frames, addressing="auto", progress_callback=progress_callback)
            metadata["isotpAddressing"] = addressing
        else:
            raise ValueError(f"Unsupported or unknown log format for '{path}'")

    _emit_progress(
        progress_callback,
        phase="analyze",
        current=0,
        total=max(len(messages), 1),
        unit="messages",
        message="Analyzing diagnostic conversations",
    )
    candidates = analyze_conversations(messages, progress_callback=progress_callback)
    _emit_progress(
        progress_callback,
        phase="complete",
        current=1,
        total=1,
        unit="steps",
        message="Import analysis complete",
    )
    return LogAnalysis(
        source_type=source_type,
        messages=messages,
        metadata=metadata,
        candidates=candidates,
    )


def parse_selection(selection: str, total: int) -> List[int]:
    token = selection.strip().lower()
    if token in {"", "all", "*"}:
        return list(range(total))

    indices: set[int] = set()
    for part in selection.split(","):
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            left, right = part.split("-", 1)
            start = int(left)
            end = int(right)
            if start > end:
                start, end = end, start
            for value in range(start, end + 1):
                if 1 <= value <= total:
                    indices.add(value - 1)
        else:
            value = int(part)
            if 1 <= value <= total:
                indices.add(value - 1)

    return sorted(indices)
