from __future__ import annotations

import json
import os
import shlex
import site
import shutil
import subprocess
import sys
import sysconfig
import time
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from importlib import util as importlib_util
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional

MANAGED_INSTALL_FILE = Path.home() / ".oasyce" / "managed_install.json"
AUTO_UPDATE_INTERVAL_SECONDS = 86400
MANAGED_PACKAGES = ("oasyce", "odv")
MANAGED_RUNTIME_TARGETS = {
    "oasyce": {"import_name": "oasyce", "cli_name": "oas"},
    "odv": {"import_name": "datavault", "cli_name": "datavault"},
}


@dataclass(frozen=True)
class ManagedPackageSpec:
    name: str
    import_name: str
    cli_name: str


@dataclass(frozen=True)
class RuntimeEnvironment:
    python_executable: str
    python_version: str
    prefix: str
    user_site: Optional[str]
    externally_managed: bool
    virtualenv: bool
    pipx: bool


@dataclass(frozen=True)
class PackageContext:
    name: str
    installed: bool
    version: Optional[str]
    install_kind: str
    editable: bool
    project_root: Optional[str]
    dist_path: Optional[str]


@dataclass(frozen=True)
class PackageRuntimeState:
    name: str
    distribution_version: Optional[str]
    install_kind: str
    project_root: Optional[str]
    import_name: str
    importable: bool
    import_path: Optional[str]
    cli_name: str
    cli_path: Optional[str]


@dataclass(frozen=True)
class PackageUpdateState:
    name: str
    current: Optional[str]
    latest: Optional[str]
    installed: bool
    up_to_date: bool


@dataclass(frozen=True)
class UpgradePlan:
    command: List[str]
    command_display: str
    pip_targets: List[str]
    manual_steps: List[str]
    auto_apply_supported: bool
    reason: str


@dataclass(frozen=True)
class ManagedUpdateSnapshot:
    environment: RuntimeEnvironment
    package_specs: List[ManagedPackageSpec]
    package_contexts: List[PackageContext]
    runtime_packages: List[PackageRuntimeState]
    package_updates: List[PackageUpdateState]
    plan: UpgradePlan


def _managed_install_file() -> Path:
    return Path.home() / ".oasyce" / "managed_install.json"


def read_managed_install_state() -> dict:
    path = _managed_install_file()
    if not path.exists():
        return {"auto_update": False}
    try:
        return json.loads(path.read_text())
    except (OSError, json.JSONDecodeError):
        return {"auto_update": False}


def write_managed_install_state(**updates) -> dict:
    state = read_managed_install_state()
    state.update(updates)
    path = _managed_install_file()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, indent=2, sort_keys=True))
    return state


def enable_managed_install(auto_update: bool = True) -> dict:
    now = time.time()
    return write_managed_install_state(
        auto_update=auto_update,
        installed_via_bootstrap=True,
        last_bootstrap=now,
        last_auto_update_check=0,
    )


def is_auto_update_enabled() -> bool:
    return bool(read_managed_install_state().get("auto_update"))


def fetch_latest_pypi_version(package_name: str) -> Optional[str]:
    try:
        req = urllib.request.Request(
            f"https://pypi.org/pypi/{package_name}/json",
            headers={"Accept": "application/json", "User-Agent": "oasyce-cli"},
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode())
            return data.get("info", {}).get("version")
    except Exception:
        return None


def installed_package_version(package_name: str) -> Optional[str]:
    context = package_install_context(package_name)
    if not context["installed"]:
        return None
    return context["version"]


def parse_version_tuple(value: Optional[str]):
    if not value:
        return tuple()
    parts: List[object] = []
    for part in value.split("."):
        try:
            parts.append(int(part))
        except ValueError:
            parts.append(part)
    return tuple(parts)


def check_package_updates(package_names: Iterable[str] = ("oasyce", "odv")) -> List[dict]:
    snapshot = managed_update_snapshot(package_names=package_names)
    return [asdict(package) for package in snapshot.package_updates]


def _distribution_for(package_name: str):
    try:
        return distribution(package_name)
    except PackageNotFoundError:
        return None


def managed_package_spec(package_name: str) -> ManagedPackageSpec:
    target = MANAGED_RUNTIME_TARGETS.get(package_name) or {}
    return ManagedPackageSpec(
        name=package_name,
        import_name=str(target.get("import_name") or package_name),
        cli_name=str(target.get("cli_name") or package_name),
    )


def _read_local_pyproject_version(project_root: Path) -> Optional[str]:
    pyproject = project_root / "pyproject.toml"
    if not pyproject.exists():
        return None
    try:
        text = pyproject.read_text()
    except OSError:
        return None

    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith("version ="):
            _, _, raw = stripped.partition("=")
            return raw.strip().strip('"').strip("'")
    return None


def _distribution_path(dist) -> Optional[Path]:
    raw_path = getattr(dist, "_path", None)
    if raw_path is None:
        return None
    try:
        return Path(raw_path).resolve()
    except OSError:
        return Path(raw_path)


def _direct_url_payload(dist) -> Optional[Dict[str, Any]]:
    raw = dist.read_text("direct_url.json")
    if not raw:
        return None
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return None
    if isinstance(payload, dict):
        return payload
    return None


def _path_is_within(path: Optional[Path], root: Optional[Path]) -> bool:
    if path is None or root is None:
        return False
    try:
        path.resolve().relative_to(root.resolve())
        return True
    except (OSError, ValueError):
        return False


def _running_in_virtualenv() -> bool:
    base_prefix = getattr(sys, "base_prefix", sys.prefix)
    return sys.prefix != base_prefix or hasattr(sys, "real_prefix")


def _running_in_pipx() -> bool:
    prefix = Path(sys.prefix)
    return any(part == "pipx" for part in prefix.parts)


def is_externally_managed_environment() -> bool:
    if _running_in_virtualenv() or _running_in_pipx():
        return False
    seen: set[Path] = set()
    for key in ("stdlib", "platstdlib"):
        stdlib = sysconfig.get_path(key)
        if not stdlib:
            continue
        marker = Path(stdlib) / "EXTERNALLY-MANAGED"
        if marker in seen:
            continue
        seen.add(marker)
        if marker.exists():
            return True
    return False


def package_install_context(package_name: str) -> Dict[str, Any]:
    dist = _distribution_for(package_name)
    if dist is None:
        install_kind = "pipx" if _running_in_pipx() else "missing"
        if _running_in_virtualenv():
            install_kind = "venv"
        elif is_externally_managed_environment():
            install_kind = "system-site"
        return {
            "name": package_name,
            "installed": False,
            "version": None,
            "install_kind": install_kind,
            "editable": False,
            "project_root": None,
            "dist_path": None,
        }

    dist_path = _distribution_path(dist)
    payload = _direct_url_payload(dist)
    editable = bool(payload and payload.get("dir_info", {}).get("editable"))
    project_root: Optional[Path] = None
    resolved_version = dist.version

    if payload and str(payload.get("url", "")).startswith("file://"):
        parsed = urllib.parse.urlparse(payload["url"])
        root = urllib.request.url2pathname(parsed.path)
        if root:
            project_root = Path(root).expanduser()
            if editable:
                local_version = _read_local_pyproject_version(project_root)
                if local_version:
                    resolved_version = local_version

    if project_root is None and dist_path is not None and dist_path.suffix == ".egg-info":
        candidate_root = dist_path.parent
        if (candidate_root / "pyproject.toml").exists():
            project_root = candidate_root
            editable = True
            local_version = _read_local_pyproject_version(project_root)
            if local_version:
                resolved_version = local_version

    user_site = getattr(site, "USER_SITE", None)
    user_site_path = Path(user_site).resolve() if user_site else None

    if editable and project_root is not None:
        install_kind = "editable-local"
    elif _running_in_pipx():
        install_kind = "pipx"
    elif _path_is_within(dist_path, user_site_path):
        install_kind = "user-site"
    elif _running_in_virtualenv():
        install_kind = "venv"
    elif is_externally_managed_environment():
        install_kind = "system-site"
    else:
        install_kind = "global-site"

    return {
        "name": package_name,
        "installed": True,
        "version": resolved_version,
        "install_kind": install_kind,
        "editable": editable,
        "project_root": project_root,
        "dist_path": dist_path,
    }


def managed_runtime_target(package_name: str) -> Dict[str, str]:
    target = MANAGED_RUNTIME_TARGETS.get(package_name)
    if target is not None:
        return dict(target)
    return {"import_name": package_name, "cli_name": package_name}


def runtime_environment_report() -> Dict[str, Any]:
    return {
        "python_executable": sys.executable,
        "python_version": sys.version.split()[0],
        "prefix": sys.prefix,
        "user_site": getattr(site, "USER_SITE", None),
        "externally_managed": is_externally_managed_environment(),
        "virtualenv": _running_in_virtualenv(),
        "pipx": _running_in_pipx(),
    }


def package_runtime_state(package_name: str) -> Dict[str, Any]:
    context = package_install_context(package_name)
    target = managed_runtime_target(package_name)
    import_name = target["import_name"]
    cli_name = target["cli_name"]
    spec = importlib_util.find_spec(import_name)
    return {
        "name": package_name,
        "distribution_version": context["version"],
        "install_kind": context["install_kind"],
        "project_root": str(context["project_root"]) if context["project_root"] else None,
        "import_name": import_name,
        "importable": spec is not None,
        "import_path": getattr(spec, "origin", None) if spec is not None else None,
        "cli_name": cli_name,
        "cli_path": shutil.which(cli_name) if cli_name else None,
    }


def managed_runtime_report(
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> Dict[str, Any]:
    names = list(package_names)
    return {
        "environment": runtime_environment_report(),
        "packages": [package_runtime_state(package_name) for package_name in names],
    }


def _build_runtime_environment() -> RuntimeEnvironment:
    return RuntimeEnvironment(**runtime_environment_report())


def _build_package_context(package_name: str) -> PackageContext:
    raw = package_install_context(package_name)
    return PackageContext(
        **{
            **raw,
            "project_root": str(raw["project_root"]) if raw["project_root"] else None,
            "dist_path": str(raw["dist_path"]) if raw["dist_path"] else None,
        }
    )


def _build_package_runtime_state(
    spec: ManagedPackageSpec, context: PackageContext
) -> PackageRuntimeState:
    resolved_spec = importlib_util.find_spec(spec.import_name)
    return PackageRuntimeState(
        name=spec.name,
        distribution_version=context.version,
        install_kind=context.install_kind,
        project_root=context.project_root,
        import_name=spec.import_name,
        importable=resolved_spec is not None,
        import_path=getattr(resolved_spec, "origin", None) if resolved_spec is not None else None,
        cli_name=spec.cli_name,
        cli_path=shutil.which(spec.cli_name) if spec.cli_name else None,
    )


def _build_package_update_state(package_name: str, context: PackageContext) -> PackageUpdateState:
    latest = fetch_latest_pypi_version(package_name)
    up_to_date = bool(
        context.version
        and latest
        and parse_version_tuple(latest) <= parse_version_tuple(context.version)
    )
    return PackageUpdateState(
        name=package_name,
        current=context.version,
        latest=latest,
        installed=context.installed,
        up_to_date=up_to_date,
    )


def _package_needs_update(package: Dict[str, Any]) -> bool:
    return (not package.get("installed")) or (not package.get("up_to_date"))


def _pip_requires_user_site(package_contexts: Dict[str, Dict[str, Any]]) -> bool:
    if _running_in_virtualenv() or _running_in_pipx():
        return False
    if is_externally_managed_environment():
        return True
    return any(context["install_kind"] == "user-site" for context in package_contexts.values())


def _pip_requires_break_system_packages(package_contexts: Dict[str, Dict[str, Any]]) -> bool:
    if _running_in_virtualenv() or _running_in_pipx():
        return False
    return is_externally_managed_environment() and bool(package_contexts)


def _render_editable_update_step(project_root: Path) -> str:
    return " && ".join(
        [
            f"cd {shlex.quote(str(project_root))}",
            "git pull --ff-only",
            shlex.join([sys.executable, "-m", "pip", "install", "-e", "."]),
        ]
    )


def _build_upgrade_plan_from_state(
    package_updates: List[PackageUpdateState],
    package_contexts: List[PackageContext],
    environment: RuntimeEnvironment,
) -> UpgradePlan:
    context_map = {context.name: context for context in package_contexts}

    if environment.pipx:
        needs_update = any(_package_needs_update(asdict(package)) for package in package_updates)
        command = ["pipx", "upgrade", "oasyce"]
        return UpgradePlan(
            command=command if needs_update else [],
            command_display=shlex.join(command) if needs_update else "",
            pip_targets=[],
            manual_steps=[],
            auto_apply_supported=needs_update,
            reason="" if needs_update else "already-up-to-date",
        )

    pip_targets: List[str] = []
    manual_steps: List[str] = []
    for package in package_updates:
        if not _package_needs_update(asdict(package)):
            continue
        context = context_map[package.name]
        if context.install_kind == "editable-local" and context.project_root is not None:
            manual_steps.append(_render_editable_update_step(Path(context.project_root)))
            continue
        pip_targets.append(package.name)

    command: List[str] = []
    if pip_targets:
        command = [
            sys.executable,
            "-m",
            "pip",
            "install",
            "--upgrade",
            "--upgrade-strategy",
            "eager",
        ]
        package_context_dicts = {context.name: asdict(context) for context in package_contexts}
        if _pip_requires_user_site(package_context_dicts):
            command.append("--user")
        if _pip_requires_break_system_packages(package_context_dicts):
            command.append("--break-system-packages")
        command.extend(pip_targets)

    display_parts: List[str] = []
    if command:
        display_parts.append(shlex.join(command))
    display_parts.extend(manual_steps)

    return UpgradePlan(
        command=command,
        command_display=" && ".join(display_parts),
        pip_targets=pip_targets,
        manual_steps=manual_steps,
        auto_apply_supported=bool(command) and not manual_steps,
        reason="editable-local" if manual_steps else "",
    )


def managed_update_snapshot(
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> ManagedUpdateSnapshot:
    specs = [managed_package_spec(package_name) for package_name in package_names]
    environment = _build_runtime_environment()
    package_contexts = [_build_package_context(spec.name) for spec in specs]
    package_updates = [
        _build_package_update_state(spec.name, context)
        for spec, context in zip(specs, package_contexts)
    ]
    runtime_packages = [
        _build_package_runtime_state(spec, context)
        for spec, context in zip(specs, package_contexts)
    ]
    plan = _build_upgrade_plan_from_state(package_updates, package_contexts, environment)
    return ManagedUpdateSnapshot(
        environment=environment,
        package_specs=specs,
        package_contexts=package_contexts,
        runtime_packages=runtime_packages,
        package_updates=package_updates,
        plan=plan,
    )


def build_upgrade_plan(
    package_updates: Optional[Iterable[Dict[str, Any]]] = None,
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> Dict[str, Any]:
    target_names = (
        [str(package["name"]) for package in package_updates]
        if package_updates is not None
        else list(package_names)
    )
    snapshot = managed_update_snapshot(package_names=target_names)
    if package_updates is not None:
        package_update_objects = [PackageUpdateState(**package) for package in package_updates]
        plan = _build_upgrade_plan_from_state(
            package_update_objects,
            snapshot.package_contexts,
            snapshot.environment,
        )
    else:
        plan = snapshot.plan
        package_update_objects = snapshot.package_updates
    return {
        "packages": [asdict(package) for package in package_update_objects],
        "package_contexts": {
            context.name: asdict(context) for context in snapshot.package_contexts
        },
        "command": list(plan.command),
        "command_display": plan.command_display,
        "pip_targets": list(plan.pip_targets),
        "manual_steps": list(plan.manual_steps),
        "auto_apply_supported": plan.auto_apply_supported,
        "reason": plan.reason,
    }


def update_command_for_agents(
    package_updates: Optional[Iterable[Dict[str, Any]]] = None,
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> str:
    return build_upgrade_plan(package_updates, package_names)["command_display"]


def _pep668_failure_message(plan: Dict[str, Any], stderr: str = "") -> str:
    command_display = plan.get("command_display") or update_command_for_agents(plan.get("packages"))
    parts = [
        "Automatic upgrade was blocked by PEP 668 (externally managed environment).",
    ]
    if command_display:
        parts.append(f"Run this instead: {command_display}")
    parts.append("If you want isolated upgrades, use a virtual environment or pipx.")
    if stderr.strip():
        parts.append(f"pip said: {stderr.strip()}")
    return "\n".join(parts)


def _manual_update_failure_message(plan: Dict[str, Any]) -> str:
    command_display = plan.get("command_display") or update_command_for_agents(plan.get("packages"))
    parts = [
        "Automatic upgrade is disabled for editable local installs.",
    ]
    if command_display:
        parts.append(f"Run this instead: {command_display}")
    return "\n".join(parts)


def _completed_process(args: List[str], returncode: int, stdout: str = "", stderr: str = ""):
    return subprocess.CompletedProcess(
        args=args, returncode=returncode, stdout=stdout, stderr=stderr
    )


def _looks_like_pep668_error(stderr: str) -> bool:
    lowered = stderr.lower()
    return (
        "externally-managed-environment" in lowered or "externally managed environment" in lowered
    )


def upgrade_managed_packages(
    package_updates: Optional[Iterable[Dict[str, Any]]] = None,
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> subprocess.CompletedProcess[str]:
    plan = build_upgrade_plan(package_updates=package_updates, package_names=package_names)

    if not any(_package_needs_update(package) for package in plan["packages"]):
        return _completed_process([], 0, stdout="Already up to date.")

    if plan["manual_steps"]:
        return _completed_process([], 1, stderr=_manual_update_failure_message(plan))

    command = list(plan["command"])
    if not command:
        return _completed_process(
            [], 1, stderr="No managed packages could be upgraded automatically."
        )

    result = subprocess.run(command, capture_output=True, text=True)
    if result.returncode != 0 and _looks_like_pep668_error(result.stderr or ""):
        return _completed_process(
            command,
            result.returncode,
            stdout=result.stdout,
            stderr=_pep668_failure_message(plan, result.stderr or ""),
        )
    return result


def maybe_auto_update_for_cli(
    *,
    module_name: str,
    argv: List[str],
    command_name: Optional[str],
    package_names: Iterable[str] = MANAGED_PACKAGES,
) -> bool:
    if not command_name:
        return False
    if command_name in {"bootstrap", "update"}:
        return False
    if os.environ.get("OASYCE_SKIP_AUTO_UPDATE") == "1":
        return False

    state = read_managed_install_state()
    if not state.get("auto_update"):
        return False

    now = time.time()
    last_check = float(state.get("last_auto_update_check", 0) or 0)
    if now - last_check < AUTO_UPDATE_INTERVAL_SECONDS:
        return False

    packages = check_package_updates(package_names)
    state = write_managed_install_state(last_auto_update_check=now, packages=packages)
    if any(pkg["latest"] is None for pkg in packages):
        return False

    needs_update = any((not pkg["installed"]) or (not pkg["up_to_date"]) for pkg in packages)
    if not needs_update:
        return False

    result = upgrade_managed_packages(package_updates=packages, package_names=package_names)
    if result.returncode != 0:
        write_managed_install_state(
            last_auto_update_error=result.stderr or "upgrade failed",
            last_auto_update_check=now,
        )
        return False

    write_managed_install_state(
        last_auto_update_error="",
        last_auto_update_check=now,
        last_auto_update_success=now,
        packages=check_package_updates(package_names),
    )

    env = os.environ.copy()
    env["OASYCE_SKIP_AUTO_UPDATE"] = "1"
    os.execvpe(sys.executable, [sys.executable, "-m", module_name, *argv], env)
    return True
