from __future__ import annotations

import argparse
import json
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional


DEFAULT_BUS = {"mode": "virtual", "bitrate": 500000, "timingFactor": 0}
SUPPORTED_PROTOCOLS = {"uds", "kwp", "obd2"}


class SpecToolError(Exception):
    pass


@dataclass
class ExplodeResult:
    output_dir: Path
    vehicle_path: Path
    ecu_count: int


def _slugify(value: str) -> str:
    cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value.strip())
    compact = "-".join(part for part in cleaned.split("-") if part)
    return compact or "ecu"


def _load_json(path: Path) -> dict[str, Any]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise SpecToolError(f"Failed to parse JSON: {path}: {exc}") from exc


def _write_json(path: Path, payload: dict[str, Any], overwrite: bool) -> None:
    if path.exists() and not overwrite:
        raise SpecToolError(f"Refusing to overwrite existing file: {path}")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def _encode_kwp_p2_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    linear_max = 0xF0 * 25.0
    if ms <= linear_max:
        return min(0xF0, max(0x01, int(round(ms / 25.0))))
    nibble = min(0x0E, max(0x01, int(round(ms / (0x100 * 25.0)))))
    return 0xF0 | nibble


def _encode_kwp_p3_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    if ms >= 5000:
        return 0xFF
    return min(0xFE, max(0x00, int(round(ms / 0.5))))


def _encode_kwp_p4_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    return min(0xFF, max(0x00, int(round(ms / 0.5))))


def _normalize_kwp_timing(values: Any) -> Any:
    if not isinstance(values, list):
        return values
    if not values:
        return []
    if all(isinstance(item, int) and 0 <= item <= 0xFF for item in values):
        return values
    try:
        p2 = float(values[0]) if len(values) > 0 else 0.0
        p3 = float(values[1]) if len(values) > 1 else 0.0
        p4 = float(values[2]) if len(values) > 2 else 0.0
    except (TypeError, ValueError):
        return values
    return [_encode_kwp_p2_ms(p2), _encode_kwp_p3_ms(p3), _encode_kwp_p4_ms(p4)]


def _resolve_vehicle_input(source: Path) -> Path:
    if source.is_file():
        return source
    if source.is_dir():
        candidates = [
            source / "vehicle.vehicle",
            source / "vehicle.json",
        ]
        for candidate in candidates:
            if candidate.exists():
                return candidate
        raise SpecToolError(
            f"Could not find vehicle manifest in directory {source}. "
            "Expected vehicle.vehicle or vehicle.json."
        )
    raise SpecToolError(f"Input path does not exist: {source}")


def _to_legacy_arbitration(arbitration: Any) -> dict[str, Any]:
    source = arbitration if isinstance(arbitration, dict) else {}
    return {
        "request": source.get("request", source.get("requestId", 0x7E0)),
        "reply": source.get("reply", source.get("replyId", 0x7E8)),
        "extendedRequest": source.get("extendedRequest", source.get("extendedRequestId")),
        "extendedReply": source.get("extendedReply", source.get("extendedReplyId")),
        "auxiliary": source.get("auxiliary", source.get("auxiliaryIds", [])),
        "auxiliaryRequestExtensions": source.get("auxiliaryRequestExtensions", []),
    }


def _inline_ui_to_legacy_ecu(entry: dict[str, Any], vehicle_vin: str) -> dict[str, Any]:
    protocol = str(entry.get("protocol", "")).strip().lower()
    if protocol not in SUPPORTED_PROTOCOLS:
        raise SpecToolError(
            f"Unsupported inline ECU protocol '{entry.get('protocol')}'. "
            f"Expected one of: {', '.join(sorted(SUPPORTED_PROTOCOLS))}"
        )
    name = str(entry.get("name") or entry.get("role") or "ECU")
    arbitration = _to_legacy_arbitration(entry.get("arbitration"))

    if protocol == "obd2":
        block = dict(entry.get("obd2") or {})
        block.setdefault("arbitrationInfo", arbitration)
        vin = entry.get("vin") or vehicle_vin
        if vin and "vin" not in block:
            block["vin"] = vin
        return {"name": name, "kind": "obd2", "obd2": block}

    block = dict(entry.get(protocol) or {})
    block.setdefault("arbitration", arbitration)
    if protocol == "kwp" and "timingParameters" in block:
        block["timingParameters"] = _normalize_kwp_timing(block.get("timingParameters"))
    return {"name": name, "kind": protocol, protocol: block}


def _to_legacy_inline_ecu(
    entry: dict[str, Any], *, vehicle_vin: str, fallback_name: str
) -> dict[str, Any]:
    if isinstance(entry.get("kind"), str) and entry.get("kind"):
        legacy = dict(entry)
        legacy.setdefault("name", fallback_name)
        return legacy
    return _inline_ui_to_legacy_ecu(entry, vehicle_vin)


def _normalize_vehicle(source_doc: dict[str, Any], source_path: Path) -> dict[str, Any]:
    if isinstance(source_doc.get("kind"), str) and source_doc.get("kind"):
        kind = str(source_doc.get("kind", ""))
        name = str(source_doc.get("name") or source_path.stem or "ECU")
        vin = ""
        protocol_block = source_doc.get(kind)
        if isinstance(protocol_block, dict):
            vin = str(protocol_block.get("vin") or "")
        legacy = dict(source_doc)
        legacy.setdefault("name", name)
        legacy["role"] = name
        return {
            "formatVersion": 2,
            "name": name,
            "vin": vin,
            "bus": dict(DEFAULT_BUS),
            "ecus": [legacy],
        }

    ecu_entries = source_doc.get("ecus")
    if not isinstance(ecu_entries, list):
        raise SpecToolError(
            f"Input is neither a legacy ECU spec nor a vehicle spec: {source_path}"
        )

    vehicle_name = str(source_doc.get("name") or source_path.stem or "Vehicle")
    vehicle_vin = str(source_doc.get("vin") or "")
    source_bus = source_doc.get("bus")
    bus = (
        {
            "mode": source_bus.get("mode", DEFAULT_BUS["mode"]),
            "bitrate": source_bus.get("bitrate", DEFAULT_BUS["bitrate"]),
            "timingFactor": source_bus.get("timingFactor", DEFAULT_BUS["timingFactor"]),
        }
        if isinstance(source_bus, dict)
        else dict(DEFAULT_BUS)
    )
    base_dir = source_path.parent

    normalized_ecus: list[dict[str, Any]] = []
    for index, raw in enumerate(ecu_entries, start=1):
        if not isinstance(raw, dict):
            raise SpecToolError(f"Invalid ECU entry at index {index - 1}: expected object")

        ref_name = str(raw.get("name") or raw.get("role") or f"ECU-{index}")
        role = str(raw.get("role") or raw.get("name") or f"ECU-{index}")
        path = raw.get("path")

        source_ecu = raw
        if isinstance(path, str) and path.strip():
            source_ecu = _load_json((base_dir / path).resolve())

        legacy = _to_legacy_inline_ecu(
            source_ecu, vehicle_vin=vehicle_vin, fallback_name=ref_name
        )
        legacy["role"] = role
        legacy.setdefault("name", ref_name)
        normalized_ecus.append(legacy)

    return {
        "formatVersion": 2,
        "name": vehicle_name,
        "vin": vehicle_vin,
        "bus": bus,
        "ecus": normalized_ecus,
    }


def _default_compile_output(source: Path, normalized_vehicle: dict[str, Any]) -> Path:
    if source.is_dir():
        slug = _slugify(str(normalized_vehicle.get("name", "") or source.name))
        return source.parent / f"{slug}.vehicle"
    return source.with_suffix(".vehicle")


def compile_vehicle_spec(
    source: str, *, output: Optional[str] = None, overwrite: bool = False
) -> Path:
    source_path = Path(source).expanduser().resolve()
    input_path = _resolve_vehicle_input(source_path)
    source_doc = _load_json(input_path)
    normalized = _normalize_vehicle(source_doc, input_path)

    if output:
        output_path = Path(output).expanduser().resolve()
        if output_path.is_dir():
            slug = _slugify(str(normalized.get("name", "") or input_path.stem))
            output_path = output_path / f"{slug}.vehicle"
    else:
        output_path = _default_compile_output(source_path, normalized)

    _write_json(output_path, normalized, overwrite=overwrite)
    return output_path


def explode_vehicle_spec(
    source: str, *, output_dir: Optional[str] = None, overwrite: bool = False
) -> ExplodeResult:
    source_path = Path(source).expanduser().resolve()
    input_path = _resolve_vehicle_input(source_path)
    source_doc = _load_json(input_path)
    normalized = _normalize_vehicle(source_doc, input_path)

    if output_dir:
        target_dir = Path(output_dir).expanduser().resolve()
    else:
        target_dir = input_path.with_suffix(".vehicleproj")

    if target_dir.exists():
        if not overwrite:
            raise SpecToolError(f"Refusing to overwrite existing directory: {target_dir}")
        if target_dir.is_file():
            raise SpecToolError(f"Output path is a file, expected directory: {target_dir}")
        shutil.rmtree(target_dir)

    ecus_dir = target_dir / "ecus"
    ecus_dir.mkdir(parents=True, exist_ok=True)

    refs: list[dict[str, str]] = []
    inline_ecus = normalized.get("ecus")
    if not isinstance(inline_ecus, list):
        raise SpecToolError("Normalized vehicle does not contain an ECU list")

    for index, entry in enumerate(inline_ecus, start=1):
        if not isinstance(entry, dict):
            raise SpecToolError(f"Invalid inline ECU at index {index - 1}")
        ecu_name = str(entry.get("name") or entry.get("role") or f"ECU-{index}")
        role = str(entry.get("role") or ecu_name)
        file_name = f"ecu-{_slugify(role)}.json"
        ecu_path = ecus_dir / file_name
        ecu_payload = dict(entry)
        ecu_payload.pop("role", None)
        _write_json(ecu_path, ecu_payload, overwrite=True)
        refs.append({"name": role, "path": f"ecus/{file_name}"})

    manifest = {
        "formatVersion": 2,
        "name": normalized.get("name", "Vehicle"),
        "vin": normalized.get("vin", ""),
        "bus": normalized.get("bus", dict(DEFAULT_BUS)),
        "ecus": refs,
    }
    vehicle_path = target_dir / "vehicle.vehicle"
    _write_json(vehicle_path, manifest, overwrite=True)

    return ExplodeResult(output_dir=target_dir, vehicle_path=vehicle_path, ecu_count=len(refs))


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="epy spec",
        description="Convert ECUmulator vehicle specs between standalone (.vehicle) and project directory forms",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    compile_parser = subparsers.add_parser(
        "compile",
        help="Compile a vehicle spec or project directory into standalone .vehicle",
    )
    compile_parser.add_argument("source", help="Input vehicle file or project directory")
    compile_parser.add_argument(
        "-o",
        "--output",
        default=None,
        help="Output .vehicle path (or directory where the .vehicle file will be placed)",
    )
    compile_parser.add_argument(
        "--overwrite", action="store_true", help="Allow overwriting existing output file"
    )

    explode_parser = subparsers.add_parser(
        "explode",
        help="Explode a vehicle spec into a project directory with one ECU JSON per file",
    )
    explode_parser.add_argument("source", help="Input vehicle file or project directory")
    explode_parser.add_argument(
        "-o",
        "--output-dir",
        default=None,
        help="Output project directory (default: <input>.vehicleproj)",
    )
    explode_parser.add_argument(
        "--overwrite", action="store_true", help="Allow overwriting existing output directory"
    )

    return parser


def main(argv: Optional[list[str]] = None) -> None:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if args.command == "compile":
            output = compile_vehicle_spec(
                args.source, output=args.output, overwrite=bool(args.overwrite)
            )
            print(output)
            return
        if args.command == "explode":
            result = explode_vehicle_spec(
                args.source, output_dir=args.output_dir, overwrite=bool(args.overwrite)
            )
            print(result.output_dir)
            return
    except SpecToolError as exc:
        sys.exit(str(exc))
    parser.print_help()
    sys.exit(1)


if __name__ == "__main__":
    main()
