from __future__ import annotations

import importlib.util
import json
import os
import re
import shlex
import subprocess
import sys
import sysconfig
import tempfile
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterator, Optional, Tuple

try:
    from importlib import metadata as importlib_metadata
except Exception:  # pragma: no cover - Python <3.10 fallback
    import importlib_metadata  # type: ignore

try:
    import fcntl
except Exception:  # pragma: no cover - non-POSIX platforms
    fcntl = None  # type: ignore[assignment]


@dataclass
class CommandSpec:
    name: str
    handler: Callable[[list[str] | None], None]


DEPENDENCY_MODULES = {
    "python-can": "can",
    "lz4": "lz4",
    "watchdog": "watchdog",
}

OTA_GIT_URL = os.environ.get("EPY_OTA_GIT_URL", "git@gitlab.com:a11086/ecumulator.py.git")
OTA_PIP_URL = os.environ.get("EPY_OTA_PIP_URL", "git+ssh://git@gitlab.com/a11086/ecumulator.py.git")
OTA_DISABLE = os.environ.get("EPY_OTA_DISABLE", "").lower() in {"1", "true", "yes"}
OTA_ALLOW_SYSTEM = os.environ.get("EPY_OTA_ALLOW_SYSTEM", "").lower() in {"1", "true", "yes"}
OTA_PIP_ARGS = os.environ.get("EPY_OTA_PIP_ARGS", "")
OTA_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
HOMEBREW_FORMULA = "ecumulator-py"
SERVER_LOCK_PATH = Path(os.environ.get("EPY_SERVER_LOCK_PATH", Path(tempfile.gettempdir()) / "ecumulator-epy-server.lock"))


def _missing_dependencies() -> list[str]:
    missing: list[str] = []
    for package, module in DEPENDENCY_MODULES.items():
        if importlib.util.find_spec(module) is None:
            missing.append(package)
    return missing


def _extract_attach_value(argv: list[str]) -> str | None:
    for idx, token in enumerate(argv):
        if token in {"-a", "--attach"}:
            if idx + 1 < len(argv):
                return argv[idx + 1]
            return None
        if token.startswith("--attach="):
            return token.split("=", 1)[1]
    return None


def _missing_server_can_dependencies(argv: list[str]) -> list[str]:
    attach_value = _extract_attach_value(argv)
    if not attach_value:
        return []
    attach_target = attach_value.split("@", 1)[0]
    from pymulator.core import can as can_module
    return can_module.missing_python_can_modules(attach_target)


def _requirements_path() -> Path | None:
    root = Path(__file__).resolve().parents[1]
    path = root / "requirements.txt"
    return path if path.exists() else None


def _print_missing_deps(missing: list[str]) -> None:
    script = Path(sys.argv[0]).name
    print(f"{script}: missing Python dependencies:", file=sys.stderr)
    for pkg in sorted(set(missing)):
        print(f"- {pkg}", file=sys.stderr)
    req_path = _requirements_path()
    if req_path:
        print(f"Install with: {sys.executable} -m pip install -r {req_path}", file=sys.stderr)
    else:
        print(f"Install with: {sys.executable} -m pip install ecumulator-py", file=sys.stderr)


def _externally_managed_marker() -> Optional[Path]:
    for key in ("stdlib", "platstdlib"):
        path = sysconfig.get_path(key)
        if not path:
            continue
        marker = Path(path) / "EXTERNALLY-MANAGED"
        if marker.exists():
            return marker
    return None


def _path_matches_homebrew_formula(path: Path) -> bool:
    try:
        parts = path.resolve().parts
    except Exception:
        parts = path.parts
    for idx, part in enumerate(parts):
        if part not in {"Cellar", "opt"}:
            continue
        if idx + 1 < len(parts) and parts[idx + 1] == HOMEBREW_FORMULA:
            return True
    return False


def _is_homebrew_install() -> bool:
    candidates = [
        Path(__file__).resolve(),
        Path(sys.prefix),
        Path(sys.executable),
    ]
    for candidate in candidates:
        if _path_matches_homebrew_formula(candidate):
            return True
    try:
        dist = importlib_metadata.distribution("ecumulator-py")
        if _path_matches_homebrew_formula(Path(dist.locate_file(""))):
            return True
    except Exception:
        pass
    return False


def _pip_config_allows_break_system_packages() -> bool:
    try:
        result = subprocess.run(
            [sys.executable, "-m", "pip", "config", "list"],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return False
    if result.returncode != 0:
        return False
    for line in result.stdout.splitlines():
        if not line:
            continue
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip().lower()
        value = value.strip().strip("'\"").lower()
        if key.endswith("break-system-packages") and value in {"true", "1", "yes", "on"}:
            return True
    return False


def _print_external_managed_notice() -> None:
    script = Path(sys.argv[0]).name
    print(f"{script}: Python environment is externally managed.", file=sys.stderr)
    print("Auto-update is disabled to avoid breaking system packages.", file=sys.stderr)
    print("Recommended options:", file=sys.stderr)
    print("  - Use a virtual environment (python -m venv .venv)", file=sys.stderr)
    print("  - Use pipx (pipx install git+ssh://git@gitlab.com/a11086/ecumulator.py.git)", file=sys.stderr)
    print("If you really want system updates, set EPY_OTA_ALLOW_SYSTEM=1.", file=sys.stderr)


def _print_homebrew_update_notice() -> None:
    script = Path(sys.argv[0]).name
    print(f"{script}: Homebrew installation detected.", file=sys.stderr)
    print("Auto-update is disabled for Homebrew installs.", file=sys.stderr)
    print("Use Homebrew to update:", file=sys.stderr)
    print("  - brew update", file=sys.stderr)
    print(f"  - brew upgrade {HOMEBREW_FORMULA}", file=sys.stderr)


def _current_version() -> str:
    try:
        return importlib_metadata.version("ecumulator-py")
    except Exception:
        pass
    pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
    if pyproject.exists():
        match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', pyproject.read_text(encoding="utf-8"), re.M)
        if match:
            return match.group(1).strip()
    return "0.0.0"


def _summary(script_name: str) -> str:
    version = _current_version()
    return f"{script_name} {version} — Python ECU emulation + diagnostics toolkit"


def _parse_version(tag: str) -> Optional[Tuple[int, ...]]:
    tag = tag.strip()
    if tag.startswith("v"):
        tag = tag[1:]
    if not tag:
        return None
    parts = tag.split(".")
    if not all(part.isdigit() for part in parts):
        return None
    return tuple(int(part) for part in parts)


def _compare_versions(a: Tuple[int, ...], b: Tuple[int, ...]) -> int:
    max_len = max(len(a), len(b))
    a_padded = a + (0,) * (max_len - len(a))
    b_padded = b + (0,) * (max_len - len(b))
    if a_padded == b_padded:
        return 0
    return -1 if a_padded < b_padded else 1


def _ota_cache_path() -> Path:
    cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
    return cache_root / "ecumulator.py" / "ota.json"


def _load_ota_cache() -> dict:
    path = _ota_cache_path()
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}


def _save_ota_cache(cache: dict) -> None:
    path = _ota_cache_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(cache), encoding="utf-8")


def _fetch_latest_tag() -> Optional[Tuple[str, Tuple[int, ...]]]:
    try:
        result = subprocess.run(
            ["git", "ls-remote", "--tags", OTA_GIT_URL],
            capture_output=True,
            text=True,
            check=False,
        )
    except FileNotFoundError:
        return None
    if result.returncode != 0:
        return None
    latest_tag: Optional[str] = None
    latest_version: Optional[Tuple[int, ...]] = None
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) < 2:
            continue
        ref = parts[1]
        if ref.endswith("^{}"):
            ref = ref[:-3]
        if not ref.startswith("refs/tags/"):
            continue
        tag = ref.split("/", 2)[-1]
        version = _parse_version(tag)
        if version is None:
            continue
        if latest_version is None or _compare_versions(latest_version, version) < 0:
            latest_tag = tag
            latest_version = version
    if latest_tag and latest_version:
        return latest_tag, latest_version
    return None


def _maybe_check_update(interactive: bool) -> None:
    if OTA_DISABLE:
        return
    if _is_homebrew_install():
        return
    cache = _load_ota_cache()
    last_check = cache.get("last_check", 0)
    now = time.time()
    if now - float(last_check or 0) < OTA_CHECK_INTERVAL_SECONDS:
        return
    cache["last_check"] = now
    _save_ota_cache(cache)
    latest = _fetch_latest_tag()
    if latest is None:
        return
    latest_tag, latest_version = latest
    current = _parse_version(_current_version())
    if current is None or _compare_versions(current, latest_version) >= 0:
        return
    if not interactive:
        return
    last_prompt = float(cache.get("last_prompt", 0) or 0)
    last_prompt_tag = cache.get("last_prompt_tag")
    if last_prompt_tag == latest_tag and (now - last_prompt) < OTA_CHECK_INTERVAL_SECONDS:
        return
    script = Path(sys.argv[0]).name
    prompt = f"{script}: update available ({latest_tag}). Install now? [y/N] "
    try:
        answer = input(prompt).strip().lower()
    except EOFError:
        return
    if answer not in {"y", "yes"}:
        cache["last_prompt"] = now
        cache["last_prompt_tag"] = latest_tag
        _save_ota_cache(cache)
        return
    if not _perform_update(latest_tag, force=False):
        print("epy: update skipped.", file=sys.stderr)


def _perform_update(tag: str, force: bool) -> bool:
    if _is_homebrew_install():
        _print_homebrew_update_notice()
        return False
    marker = _externally_managed_marker()
    allow_system = force or OTA_ALLOW_SYSTEM or _pip_config_allows_break_system_packages()
    if marker and not allow_system:
        _print_external_managed_notice()
        return False
    url = f"{OTA_PIP_URL}@{tag}"
    cmd = [sys.executable, "-m", "pip", "install", "--upgrade"]
    if marker and allow_system:
        cmd.append("--break-system-packages")
    if OTA_PIP_ARGS:
        cmd.extend(OTA_PIP_ARGS.split())
    cmd.append(url)
    result = subprocess.run(cmd)
    if result.returncode != 0:
        print("epy: update failed.", file=sys.stderr)
        return False
    return True


def _update_if_newer(force: bool = False) -> None:
    if _is_homebrew_install():
        _print_homebrew_update_notice()
        return
    latest = _fetch_latest_tag()
    if latest is None:
        print("epy: unable to check for updates.")
        return
    latest_tag, latest_version = latest
    current = _parse_version(_current_version())
    if current is None:
        print("epy: unable to determine current version.")
        return
    if _compare_versions(current, latest_version) >= 0:
        print("epy: already up to date.")
        return
    print(f"epy: updating to {latest_tag}...")
    if not _perform_update(latest_tag, force=force):
        sys.exit(1)


def _usage(script_name: str) -> str:
    return "\n".join(
        [
            "Usage:",
            f"  {script_name} server [pymulator args...]",
            f"  {script_name} import [specimport args...]",
            f"  {script_name} spec compile <vehicle|project-dir> [-o out.vehicle] [--overwrite]",
            f"  {script_name} spec explode <vehicle|project-dir> [-o out-dir] [--overwrite]",
            f"  {script_name} client --doip [doipclient args...]",
            f"  {script_name} client --hsfz [hsfzclient args...]",
            f"  {script_name} elm [elmpythonator args...]",
            f"  {script_name} update [--force]",
            f"  {script_name} version",
            "",
            "Examples:",
            f"  {script_name} server --ecuconnect ecu:./Resources/ptc-obd2.json",
            f"  {script_name} import ./capture.log -o ./specs",
            f"  {script_name} spec compile ./project/ -o ./vehicle.vehicle",
            f"  {script_name} spec explode ./vehicle.vehicle -o ./vehicle.vehicleproj",
            f"  {script_name} client --doip",
            f"  {script_name} client --hsfz enet://0.0.0.0:6801",
            f"  {script_name} elm ecu:./Resources/ptc-obd2.json",
            f"  {script_name} update",
            f"  {script_name} update --force",
            f"  {script_name} version",
        ]
    )


def _command_runs_epy_server(command: str) -> bool:
    try:
        parts = shlex.split(command)
    except ValueError:
        parts = command.split()
    if not parts:
        return False

    first = Path(parts[0]).name
    if first in {"epy", "epy.exe"}:
        return len(parts) > 1 and parts[1] == "server"

    for idx, value in enumerate(parts[:-1]):
        if value == "-m" and parts[idx + 1] == "pymuclient.epy":
            return len(parts) > idx + 2 and parts[idx + 2] == "server"

    if first.startswith("python"):
        return len(parts) > 2 and Path(parts[1]).name in {"epy", "epy.py"} and parts[2] == "server"
    return False


def _find_running_epy_server_process(current_pid: int | None = None) -> Optional[tuple[int, str]]:
    current_pid = os.getpid() if current_pid is None else current_pid
    try:
        result = subprocess.run(
            ["ps", "-ax", "-o", "pid=", "-o", "command="],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return None
    if result.returncode != 0:
        return None

    for line in result.stdout.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        fields = stripped.split(maxsplit=1)
        if not fields:
            continue
        try:
            pid = int(fields[0])
        except ValueError:
            continue
        if pid == current_pid:
            continue
        command = fields[1] if len(fields) > 1 else ""
        if _command_runs_epy_server(command):
            return pid, command
    return None


def _open_server_lock_fd(path: Path) -> int:
    path.parent.mkdir(parents=True, exist_ok=True)
    return os.open(path, os.O_RDWR | os.O_CREAT, 0o666)


def _acquire_server_lock_fd(path: Path) -> Optional[int]:
    if fcntl is None:
        return None
    fd = _open_server_lock_fd(path)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        os.close(fd)
        return None
    except Exception:
        os.close(fd)
        raise
    return fd


def _write_server_lock_metadata(fd: int, path: Path) -> None:
    payload = json.dumps(
        {
            "pid": os.getpid(),
            "started_at": int(time.time()),
            "command": " ".join(sys.argv),
        }
    )
    os.lseek(fd, 0, os.SEEK_SET)
    os.ftruncate(fd, 0)
    os.write(fd, payload.encode("utf-8"))
    os.fsync(fd)
    try:
        os.chmod(path, 0o666)
    except Exception:
        pass


def _release_server_lock_fd(fd: int) -> None:
    try:
        if fcntl is not None:
            fcntl.flock(fd, fcntl.LOCK_UN)
    finally:
        os.close(fd)


def _format_server_running_error(pid: int, command: str) -> str:
    return (
        f"Error: another epy server instance is already running (pid {pid}).\n"
        f"Command: {command}"
    )


@contextmanager
def _epy_server_singleton_guard() -> Iterator[None]:
    running = _find_running_epy_server_process()
    if running is not None:
        pid, command = running
        raise SystemExit(_format_server_running_error(pid, command))

    if fcntl is None:
        yield
        return

    fd = _acquire_server_lock_fd(SERVER_LOCK_PATH)
    if fd is None:
        running = _find_running_epy_server_process()
        if running is not None:
            pid, command = running
            raise SystemExit(_format_server_running_error(pid, command))
        raise SystemExit("Error: unable to acquire server singleton lock.")

    _write_server_lock_metadata(fd, SERVER_LOCK_PATH)
    try:
        yield
    finally:
        _release_server_lock_fd(fd)


def _dispatch_server(argv: list[str]) -> None:
    from pymulator.cli import main

    with _epy_server_singleton_guard():
        main(argv)


def _dispatch_import(argv: list[str]) -> None:
    from pymulator.specimport.cli import main

    main(argv)


def _dispatch_spec(argv: list[str]) -> None:
    from pymulator.specs.format_cli import main

    main(argv)


def _dispatch_elmpythonator(argv: list[str]) -> None:
    from elmpythonator.cli import main

    main(argv)


def _dispatch_doip(argv: list[str]) -> None:
    from doipclient.cli import main

    main(argv)


def _dispatch_hsfz(argv: list[str]) -> None:
    from hsfzclient.cli import main

    main(argv)


def main(argv: list[str] | None = None) -> None:
    args = list(sys.argv[1:] if argv is None else argv)
    script_name = Path(sys.argv[0]).name

    if not args:
        print(_summary(script_name))
        print(_usage(script_name))
        return
    if args[0] in {"-h", "--help", "help"}:
        print(_usage(script_name))
        return

    command = args[0]
    if command in {"version", "--version", "-V"}:
        print(_summary(script_name))
        return
    if command == "update":
        extra = args[1:]
        if any(flag in extra for flag in {"-h", "--help", "help"}):
            print(_usage(script_name))
            return
        allowed = {"-f", "--force"}
        unknown = [flag for flag in extra if flag not in allowed]
        if unknown:
            print(f"Error: unknown update option(s): {' '.join(unknown)}", file=sys.stderr)
            print(_usage(script_name))
            sys.exit(1)
        force = any(flag in extra for flag in ("-f", "--force"))
        _update_if_newer(force=force)
        return

    _maybe_check_update(interactive=sys.stdin.isatty())

    missing = _missing_dependencies()
    if missing:
        _print_missing_deps(missing)
        sys.exit(1)
    if command == "server":
        can_missing = _missing_server_can_dependencies(args[1:])
        if can_missing:
            _print_missing_deps(can_missing)
            sys.exit(1)

    command = args.pop(0)
    if command == "server":
        _dispatch_server(args)
        return
    if command == "import":
        _dispatch_import(args)
        return
    if command == "spec":
        _dispatch_spec(args)
        return
    if command in {"elm", "elmpythonator"}:
        _dispatch_elmpythonator(args)
        return
    if command == "client":
        if not args:
            print(_usage(script_name))
            sys.exit(1)
        sub = args.pop(0)
        if sub in {"--doip", "doip"}:
            _dispatch_doip(args)
            return
        if sub in {"--hsfz", "hsfz"}:
            _dispatch_hsfz(args)
            return
        if sub in {"-h", "--help", "help"}:
            print(_usage(script_name))
            return
        print(f"Error: unknown client type '{sub}'", file=sys.stderr)
        print(_usage(script_name))
        sys.exit(1)

    print(f"Error: unknown command '{command}'", file=sys.stderr)
    print(_usage(script_name))
    sys.exit(1)


if __name__ == "__main__":
    main()
