from __future__ import annotations

import os
import platform as platform_lib
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.request import urlopen

from autosana_cli.models import (
    DEFAULT_APPIUM_VERSION,
    DEFAULT_NODE_VERSION,
    DEFAULT_UIAUTOMATOR2_VERSION,
    DEFAULT_XCUITEST_VERSION,
    Platform,
    appium_home,
    node_home,
)

# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------

# Node.js uses "x64" not "x86_64", and "arm64" matches directly.
# Values: (node_os, node_arch, archive_format)
_NODE_PLATFORM_MAP: dict[tuple[str, str], tuple[str, str, str]] = {
    ("darwin", "arm64"): ("darwin", "arm64", "tar.gz"),
    ("darwin", "x86_64"): ("darwin", "x64", "tar.gz"),
    ("linux", "x86_64"): ("linux", "x64", "tar.gz"),
    ("linux", "amd64"): ("linux", "x64", "tar.gz"),
    ("linux", "aarch64"): ("linux", "arm64", "tar.gz"),
    ("linux", "arm64"): ("linux", "arm64", "tar.gz"),
    ("windows", "amd64"): ("win", "x64", "zip"),
    ("windows", "x86_64"): ("win", "x64", "zip"),
    ("windows", "arm64"): ("win", "arm64", "zip"),
}

_IS_WINDOWS = sys.platform == "win32"


@dataclass(frozen=True)
class NodeAsset:
    url: str
    filename: str
    archive_format: str  # "tar.gz" or "zip"


def node_download_info(version: str = DEFAULT_NODE_VERSION) -> NodeAsset:
    system = platform_lib.system().lower()
    machine = platform_lib.machine().lower()
    mapped = _NODE_PLATFORM_MAP.get((system, machine))
    if mapped is None:
        raise RuntimeError(f"Unsupported platform for Node.js: {system}/{machine}")
    node_os, node_arch, fmt = mapped
    filename = f"node-v{version}-{node_os}-{node_arch}.{fmt}"
    url = f"https://nodejs.org/dist/v{version}/{filename}"
    return NodeAsset(url=url, filename=filename, archive_format=fmt)


# ---------------------------------------------------------------------------
# Binary paths
# ---------------------------------------------------------------------------


def _node_bin() -> Path:
    if _IS_WINDOWS:
        return node_home() / "node.exe"
    return node_home() / "bin" / "node"


def _npm_bin() -> Path:
    if _IS_WINDOWS:
        return node_home() / "npm.cmd"
    return node_home() / "bin" / "npm"


def _appium_bin() -> Path:
    if _IS_WINDOWS:
        return appium_home() / "node_modules" / ".bin" / "appium.cmd"
    return appium_home() / "node_modules" / ".bin" / "appium"


# ---------------------------------------------------------------------------
# Version checking
# ---------------------------------------------------------------------------

_VERSION_RE = re.compile(r"v?(\d+\.\d+\.\d+)")
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")


def check_node_version(node_bin: Path) -> str | None:
    try:
        result = subprocess.run(
            [str(node_bin), "--version"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        match = _VERSION_RE.search(result.stdout.strip())
        return match.group(1) if match else None
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return None


def check_appium_version(appium_bin: Path, env: dict[str, str] | None = None) -> str | None:
    try:
        result = subprocess.run(
            [str(appium_bin), "--version"],
            capture_output=True,
            text=True,
            timeout=15,
            env=env,
        )
        cleaned = _ANSI_RE.sub("", result.stdout.strip())
        match = _VERSION_RE.search(cleaned)
        return match.group(1) if match else None
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return None


def _major_version(version: str) -> int:
    return int(version.split(".")[0])


# ---------------------------------------------------------------------------
# Find functions (managed first, then system PATH, with version validation)
# ---------------------------------------------------------------------------


def find_node(version: str = DEFAULT_NODE_VERSION) -> Path | None:
    managed = _node_bin()
    if managed.exists() and os.access(managed, os.X_OK):
        v = check_node_version(managed)
        if v and _major_version(v) == _major_version(version):
            return managed

    system = shutil.which("node")
    if system:
        v = check_node_version(Path(system))
        if v and _major_version(v) == _major_version(version):
            return Path(system)

    return None


def find_npm() -> Path | None:
    managed = _npm_bin()
    if managed.exists() and os.access(managed, os.X_OK):
        return managed

    system = shutil.which("npm")
    if system:
        return Path(system)

    return None


def find_appium(version: str = DEFAULT_APPIUM_VERSION) -> Path | None:
    managed = _appium_bin()
    if managed.exists() and os.access(managed, os.X_OK):
        env = runtime_env()
        v = check_appium_version(managed, env=env)
        if v == version:
            return managed

    system = shutil.which("appium")
    if system:
        v = check_appium_version(Path(system))
        if v == version:
            return Path(system)

    return None


def check_driver_installed(driver_name: str) -> bool:
    appium_bin = find_appium() or _appium_bin()
    if not appium_bin.exists():
        return False
    try:
        result = subprocess.run(
            [str(appium_bin), "driver", "list", "--installed", "--json"],
            capture_output=True,
            text=True,
            timeout=15,
            env=runtime_env(),
        )
        return driver_name in result.stdout
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return False


# ---------------------------------------------------------------------------
# Environment builder
# ---------------------------------------------------------------------------


def runtime_env() -> dict[str, str]:
    env = os.environ.copy()
    path_prepend = []

    node_bin_dir = node_home() / "bin"
    if node_bin_dir.exists():
        path_prepend.append(str(node_bin_dir))

    appium_bin_dir = appium_home() / "node_modules" / ".bin"
    if appium_bin_dir.exists():
        path_prepend.append(str(appium_bin_dir))

    if path_prepend:
        existing = env.get("PATH", "")
        env["PATH"] = os.pathsep.join(path_prepend + [existing])

    env["APPIUM_HOME"] = str(appium_home())
    return env


# ---------------------------------------------------------------------------
# Ensure functions (idempotent, atomic installs)
# ---------------------------------------------------------------------------


def ensure_node(version: str = DEFAULT_NODE_VERSION) -> Path:
    existing = find_node(version)
    if existing is not None:
        return existing

    asset = node_download_info(version)
    dest = node_home()

    with tempfile.TemporaryDirectory(prefix="autosana-node-") as tmpdir:
        tmpdir_path = Path(tmpdir)
        archive_path = tmpdir_path / asset.filename

        with urlopen(asset.url, timeout=60) as response:
            archive_path.write_bytes(response.read())

        if asset.archive_format == "zip":
            with zipfile.ZipFile(archive_path) as zf:
                zf.extractall(tmpdir_path)
        else:
            with tarfile.open(archive_path) as archive:
                archive.extractall(tmpdir_path, filter="data")

        # Archive extracts to node-v{VERSION}-{os}-{arch}/ — find it
        extracted_dirs = [d for d in tmpdir_path.iterdir() if d.is_dir() and d.name.startswith("node-")]
        if not extracted_dirs:
            raise RuntimeError(f"Node.js archive did not contain expected directory: {asset.filename}")
        extracted = extracted_dirs[0]

        # Atomic rename: ensure parent exists, remove stale dest, move
        dest.parent.mkdir(parents=True, exist_ok=True)
        if dest.exists():
            shutil.rmtree(dest)
        shutil.move(str(extracted), str(dest))

    node_bin = _node_bin()
    if not node_bin.exists():
        raise RuntimeError(f"Node.js binary not found after extraction: {node_bin}")

    v = check_node_version(node_bin)
    if not v:
        raise RuntimeError("Failed to verify Node.js installation")

    return node_bin


def ensure_appium(version: str = DEFAULT_APPIUM_VERSION) -> Path:
    existing = find_appium(version)
    if existing is not None:
        return existing

    npm_bin = find_npm()
    if npm_bin is None:
        raise RuntimeError("npm not found — ensure Node.js is installed first")

    appium_dir = appium_home()
    appium_dir.mkdir(parents=True, exist_ok=True)

    env = runtime_env()
    # Use --cache to keep npm cache under our managed dir
    cache_dir = appium_dir / ".npm-cache"
    result = subprocess.run(
        [str(npm_bin), "install", f"appium@{version}", "--prefix", str(appium_dir), "--cache", str(cache_dir)],
        capture_output=True,
        text=True,
        timeout=300,
        env=env,
    )
    if result.returncode != 0:
        raise RuntimeError(f"Failed to install Appium {version}:\n{result.stderr}")

    appium_bin = _appium_bin()
    if not appium_bin.exists():
        raise RuntimeError(f"Appium binary not found after install: {appium_bin}")

    v = check_appium_version(appium_bin, env=env)
    if v != version:
        raise RuntimeError(f"Appium version mismatch: expected {version}, got {v}")

    return appium_bin


def ensure_driver(driver_name: str, version: str) -> None:
    if check_driver_installed(driver_name):
        return

    appium_bin = find_appium() or _appium_bin()
    if not appium_bin.exists():
        raise RuntimeError("Appium not found — ensure Appium is installed first")

    env = runtime_env()
    result = subprocess.run(
        [str(appium_bin), "driver", "install", f"{driver_name}@{version}"],
        capture_output=True,
        text=True,
        timeout=300,
        env=env,
    )
    if result.returncode != 0:
        raise RuntimeError(f"Failed to install {driver_name} driver:\n{result.stderr}")


def ensure_drivers(platform: Platform | None) -> None:
    if platform is Platform.IOS or platform is None:
        ensure_driver("xcuitest", DEFAULT_XCUITEST_VERSION)
    if platform is Platform.ANDROID or platform is None:
        ensure_driver("uiautomator2", DEFAULT_UIAUTOMATOR2_VERSION)
