from __future__ import annotations

import json
import re
from dataclasses import asdict
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set

from .core import ConversationCandidate, ExportResult, ExportedSpec, IsoTpMessage, build_ecu_spec, clean_vin, request_sid_of_message


DEFAULT_VIN = "UNKNOWN00000000000"


def _slugify(value: str) -> str:
    cleaned = re.sub(r"[^A-Za-z0-9]+", "-", value.strip())
    cleaned = cleaned.strip("-").lower()
    return cleaned or "vehicle"


def _safe_ecu_name(candidate: ConversationCandidate, index: int) -> str:
    protocol = candidate.protocol_hint.upper()
    return f"{protocol}_{candidate.request_id:X}_{candidate.response_id:X}_{index + 1}"


def _candidate_to_public_dict(candidate: ConversationCandidate) -> dict:
    payload = asdict(candidate)
    payload.pop("request_response_map", None)
    payload["requestIdHex"] = f"0x{candidate.request_id:X}"
    payload["responseIdHex"] = f"0x{candidate.response_id:X}"
    return payload


def summarize_candidates(candidates: Iterable[ConversationCandidate]) -> List[dict]:
    return [_candidate_to_public_dict(candidate) for candidate in candidates]


def _infer_fanout_by_request(
    messages: Optional[Iterable[IsoTpMessage]],
    timeout_us: int = 500_000,
) -> Dict[int, Set[int]]:
    if messages is None:
        return {}

    ordered = sorted(
        [message for message in messages if message.complete and message.data],
        key=lambda message: message.timestamp_us,
    )
    responses_by_sid: Dict[int, List[IsoTpMessage]] = {}
    for message in ordered:
        if message.direction != "response":
            continue
        sid = request_sid_of_message(message)
        if sid is None:
            continue
        responses_by_sid.setdefault(sid, []).append(message)

    fanout: Dict[int, Set[int]] = {}
    for message in ordered:
        if message.direction != "request":
            continue
        sid = request_sid_of_message(message)
        if sid is None:
            continue
        for response in responses_by_sid.get(sid, []):
            delta = response.timestamp_us - message.timestamp_us
            if delta < 0:
                continue
            if delta > timeout_us:
                break
            fanout.setdefault(message.tx_id, set()).add(response.tx_id)
    return fanout


def summarize_for_ui(
    candidates: Iterable[ConversationCandidate],
    metadata_vin: Optional[str] = None,
    messages: Optional[Iterable[IsoTpMessage]] = None,
) -> dict:
    candidate_list = list(candidates)
    conversation_rows = []
    vins: set[str] = set()
    protocols: set[str] = set()
    best_by_pair: dict[tuple[int, int], tuple[int, int]] = {}
    response_ids_by_request: dict[int, set[int]] = {}
    observed_fanout = _infer_fanout_by_request(messages)

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

    def _looks_functional_request(request_id: int) -> bool:
        if request_id == 0x7DF:
            return True
        # 0x7E0-0x7E7 is the common physical tester range for 11-bit UDS/OBD.
        if 0x7E0 <= request_id <= 0x7E7:
            return False
        return True

    for index, candidate in enumerate(candidate_list):
        if candidate.vin:
            cleaned = clean_vin(candidate.vin)
            if cleaned:
                vins.add(cleaned)
        protocols.add(candidate.protocol_hint.lower())
        response_ids_by_request.setdefault(candidate.request_id, set()).add(candidate.response_id)
        pair_key = (candidate.request_id, candidate.response_id)
        rank = (_confidence_rank(candidate.confidence), candidate.score)
        existing = best_by_pair.get(pair_key)
        if existing is None or rank > existing[1]:
            best_by_pair[pair_key] = (index, rank)

    representative_indices = [value[0] for value in best_by_pair.values()]
    representative_indices.sort(
        key=lambda idx: (_confidence_rank(candidate_list[idx].confidence), candidate_list[idx].score),
        reverse=True,
    )

    for candidate_index in representative_indices:
        candidate = candidate_list[candidate_index]
        request_id = candidate.request_id
        response_id = candidate.response_id
        candidate_responder_count = len(response_ids_by_request.get(request_id, {response_id}))
        observed_responder_count = len(observed_fanout.get(request_id, set()))
        responder_count = max(candidate_responder_count, observed_responder_count, 1)
        is_broadcast = _looks_functional_request(request_id) and responder_count > 1
        is_recommended = (not is_broadcast) and _confidence_rank(candidate.confidence) >= 2
        conversation_rows.append(
            {
                "index": candidate_index,
                "pair": f"0x{request_id:X} -> 0x{response_id:X}",
                "requestIdHex": f"0x{request_id:X}",
                "responseIdHex": f"0x{response_id:X}",
                "protocol": candidate.protocol_hint.lower(),
                "confidence": candidate.confidence,
                "isBroadcast": is_broadcast,
                "broadcastGroup": f"0x{request_id:X}" if is_broadcast else None,
                "responderCount": responder_count if is_broadcast else 1,
                "isRecommended": is_recommended,
            }
        )

    metadata_vin_clean = clean_vin(metadata_vin)
    if metadata_vin_clean:
        vins.add(metadata_vin_clean)

    vin_consistent = len(vins) <= 1
    protocol_consistent = len(protocols) <= 1
    tamper_suspected = not vin_consistent or not protocol_consistent

    header = {
        "vin": (sorted(vins)[0] if vin_consistent and vins else None),
        "protocol": (sorted(protocols)[0] if protocol_consistent and protocols else "mixed"),
        "vinConsistent": vin_consistent,
        "protocolConsistent": protocol_consistent,
        "tamperSuspected": tamper_suspected,
        "vinValues": sorted(vins),
        "protocolValues": sorted(protocols),
    }

    if tamper_suspected:
        details = []
        if not vin_consistent:
            details.append("VIN mismatch across detected conversations")
        if not protocol_consistent:
            details.append("Protocol mismatch across detected conversations")
        header["tamperWarning"] = "; ".join(details)

    return {
        "header": header,
        "conversations": conversation_rows,
    }


def export_selected_candidates(
    candidates: List[ConversationCandidate],
    output_dir: str,
    vehicle_name: str,
    base_vin: Optional[str] = None,
    selected_indices: Optional[List[int]] = None,
    overwrite: bool = False,
) -> ExportResult:
    if not candidates:
        raise ValueError("No conversation candidates available for export")

    if selected_indices is None:
        chosen = list(range(len(candidates)))
    else:
        chosen = [index for index in selected_indices if 0 <= index < len(candidates)]

    if not chosen:
        raise ValueError("No valid candidate selection")

    vehicle_slug = _slugify(vehicle_name)
    destination = Path(output_dir)
    destination.mkdir(parents=True, exist_ok=True)

    vin = clean_vin(base_vin)
    if vin is None:
        for index in chosen:
            vin = clean_vin(candidates[index].vin)
            if vin:
                break
    if vin is None:
        vin = DEFAULT_VIN

    exported: List[ExportedSpec] = []
    vehicle_ecus: List[dict] = []

    for local_index, candidate_index in enumerate(chosen):
        candidate = candidates[candidate_index]
        ecu_name = _safe_ecu_name(candidate, local_index)
        file_name = f"{vehicle_slug}-ecu-{local_index + 1}.json"
        file_path = destination / file_name

        if file_path.exists() and not overwrite:
            raise FileExistsError(f"Refusing to overwrite existing file: {file_path}")

        spec = build_ecu_spec(candidate, ecu_name=ecu_name, default_vin=vin)
        file_path.write_text(json.dumps(spec, indent=2), encoding="utf-8")

        exported.append(
            ExportedSpec(
                ecu_name=ecu_name,
                path=str(file_path),
                request_id=candidate.request_id,
                response_id=candidate.response_id,
                protocol=candidate.protocol_hint,
            )
        )
        vehicle_ecus.append({"name": ecu_name, "path": file_name})

    vehicle_file_name = f"{vehicle_slug}.vehicle"
    vehicle_path = destination / vehicle_file_name
    if vehicle_path.exists() and not overwrite:
        raise FileExistsError(f"Refusing to overwrite existing file: {vehicle_path}")

    vehicle_spec = {
        "name": vehicle_name,
        "vin": vin,
        "ecus": vehicle_ecus,
    }
    vehicle_path.write_text(json.dumps(vehicle_spec, indent=2), encoding="utf-8")

    return ExportResult(
        vehicle_path=str(vehicle_path),
        vehicle_name=vehicle_name,
        vin=vin,
        ecus=exported,
    )
