#!/usr/bin/env python3
"""Uninstall Codex MMO without deleting user state unless explicitly requested."""

from __future__ import annotations

import argparse
import contextlib
import json
import math
import os
import shutil
import stat
import sys
import tempfile
import traceback
from pathlib import Path
from typing import Any

PACKAGE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PACKAGE_ROOT / "libexec"))

from mmo_version import MMO_SCHEMA_VERSION, PACKAGE_VERSION  # noqa: E402

OWNER_MANIFEST = ".codex-mmo-install-owner.json"


def _reject_json_constant(value: str) -> Any:
    raise ValueError(f"non-standard JSON constant: {value}")


def _finite_json_float(value: str) -> float:
    parsed = float(value)
    if not math.isfinite(parsed):
        raise ValueError(f"JSON number is outside the supported finite range: {value}")
    return parsed


def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
    result: dict[str, Any] = {}
    for key, item in pairs:
        if key in result:
            raise ValueError(f"duplicate JSON object member: {key!r}")
        result[key] = item
    return result


def _validate_json_unicode(value: Any) -> None:
    if isinstance(value, str):
        try:
            value.encode("utf-8")
        except UnicodeEncodeError as exc:
            raise ValueError("JSON strings must contain valid Unicode scalar values") from exc
    elif isinstance(value, list):
        for item in value:
            _validate_json_unicode(item)
    elif isinstance(value, dict):
        for key, item in value.items():
            _validate_json_unicode(key)
            _validate_json_unicode(item)


def _strict_json_loads(value: str) -> Any:
    parsed = json.loads(
        value,
        parse_constant=_reject_json_constant,
        parse_float=_finite_json_float,
        object_pairs_hook=_unique_json_object,
    )
    _validate_json_unicode(parsed)
    return parsed


def xdg_path(variable: str, fallback: Path) -> Path:
    """Resolve an XDG base directory, ignoring empty or relative values."""

    raw = os.environ.get(variable)
    if raw:
        candidate = Path(raw)
        if candidate.is_absolute():
            return candidate
    return fallback


def defaults() -> tuple[Path, Path, Path, Path]:
    install = xdg_path("XDG_DATA_HOME", Path.home() / ".local" / "share") / "codex-mmo"
    config = xdg_path("XDG_CONFIG_HOME", Path.home() / ".config") / "codex-mmo"
    state = xdg_path("XDG_STATE_HOME", Path.home() / ".local" / "state") / "codex-mmo"
    bin_dir = xdg_path("XDG_BIN_HOME", Path.home() / ".local" / "bin")
    return install, config, state, bin_dir


def owned_wrapper(path: Path) -> bool:
    if not path.is_file():
        return False
    try:
        return (
            "Installed by codex-multimodel-orchestrator"
            in path.read_text(encoding="utf-8", errors="replace")[:500]
        )
    except OSError:
        return False


def absolute_path(path: Path) -> Path:
    """Return a lexical absolute path without following the deletion target."""

    return Path(os.path.abspath(path.expanduser()))


def remove_tree(path: Path) -> None:
    """Remove an owned tree even when compiled snapshots are read-only."""

    try:
        mode = path.lstat().st_mode
    except FileNotFoundError:
        return
    if stat.S_ISLNK(mode):
        path.unlink()
        return
    if not stat.S_ISDIR(mode):
        raise RuntimeError(f"refusing to recursively remove non-directory: {path}")
    if path.resolve(strict=False) != path:
        raise RuntimeError(f"refusing to remove a path reached through a symlink: {path}")
    for item in path.rglob("*"):
        with contextlib.suppress(OSError):
            item_mode = item.lstat().st_mode
            if not stat.S_ISLNK(item_mode):
                os.chmod(item, 0o700 if stat.S_ISDIR(item_mode) else 0o600)
    with contextlib.suppress(OSError):
        os.chmod(path, 0o700)
    shutil.rmtree(path)


def validate_removal_target(path: Path, label: str) -> None:
    protected = {Path("/"), Path.home().resolve()}
    if path in protected or len(path.parts) <= 2:
        raise ValueError(f"refusing to remove broad protected {label}: {path}")
    if path.resolve(strict=False) != path:
        raise ValueError(f"refusing {label} reached through a symlink: {path}")


def _option_was_supplied(name: str) -> bool:
    return any(item == name or item.startswith(f"{name}=") for item in sys.argv[1:])


def _trusted_manifest(path: Path, install_root: Path) -> dict[str, Any] | None:
    if path.is_symlink() or not path.is_file():
        return None
    try:
        data = _strict_json_loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError, ValueError):
        return None
    schema_version = data.get("schema_version") if isinstance(data, dict) else None
    if (
        not isinstance(data, dict)
        or not isinstance(schema_version, int)
        or isinstance(schema_version, bool)
        or schema_version != MMO_SCHEMA_VERSION
    ):
        return None
    recorded = data.get("install_root")
    if (
        not isinstance(recorded, str)
        or not Path(recorded).expanduser().is_absolute()
        or absolute_path(Path(recorded)) != install_root
    ):
        return None
    if data.get("package") != "codex-multimodel-orchestrator":
        return None
    return data


def _write_owner_manifest(root: Path, data: dict[str, Any]) -> None:
    """Persist deletion ownership when config/state survives package removal."""

    if not root.is_dir() or root.is_symlink():
        return
    destination = root / OWNER_MANIFEST
    fd, raw_temporary = tempfile.mkstemp(prefix=f".{OWNER_MANIFEST}.", dir=root)
    temporary = Path(raw_temporary)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
            json.dump(data, handle, indent=2, sort_keys=True, allow_nan=False)
            handle.write("\n")
            handle.flush()
            os.fsync(handle.fileno())
        os.chmod(temporary, 0o600)
        os.replace(temporary, destination)
    finally:
        with contextlib.suppress(FileNotFoundError):
            temporary.unlink()


def main() -> int:
    install, config, state, bin_dir = defaults()
    parser_options: dict[str, Any] = {
        "description": "Remove the owned Codex MMO runtime while retaining user data by default.",
        "epilog": (
            "Configuration, credentials, sessions, jobs, evaluations, and logs remain unless "
            "their explicit purge flags are supplied. Ownership manifests guard every removal."
        ),
        "allow_abbrev": False,
    }
    if sys.version_info >= (3, 14):
        parser_options["color"] = False
    parser = argparse.ArgumentParser(**parser_options)
    parser.add_argument(
        "--version", action="version", version=PACKAGE_VERSION, help="print the package version"
    )
    parser.add_argument(
        "--install-root", type=Path, default=install, help="owned runtime payload to remove"
    )
    parser.add_argument(
        "--config-root",
        type=Path,
        default=config,
        help="configuration root recorded by the installer",
    )
    parser.add_argument(
        "--state-root", type=Path, default=state, help="state root recorded by the installer"
    )
    parser.add_argument(
        "--bin-dir", type=Path, default=bin_dir, help="directory containing installed wrappers"
    )
    parser.add_argument(
        "--purge-config",
        action="store_true",
        help="also permanently remove configuration and credentials",
    )
    parser.add_argument(
        "--purge-state",
        action="store_true",
        help="also permanently remove sessions, jobs, evaluations, and logs",
    )
    parser.add_argument(
        "--debug", action="store_true", help="show a Python traceback when removal fails"
    )
    args = parser.parse_args()
    for name in ("install_root", "config_root", "state_root", "bin_dir"):
        setattr(args, name, absolute_path(getattr(args, name)))

    install_manifest = args.install_root / "config" / "install-manifest.json"
    # A retained config/state marker authorizes a later purge only after the
    # payload root is gone. If anything has appeared at the install path again,
    # require that live tree's own manifest so a stale marker cannot authorize
    # deletion of unrelated replacement content.
    manifests: tuple[Path, ...]
    if args.install_root.exists() or args.install_root.is_symlink():
        manifests = (install_manifest,)
    else:
        manifests = (
            args.config_root / OWNER_MANIFEST,
            args.state_root / OWNER_MANIFEST,
        )
    data = next(
        (
            candidate
            for path in manifests
            if (candidate := _trusted_manifest(path, args.install_root)) is not None
        ),
        None,
    )
    if data is None:
        raise RuntimeError(
            "refusing to remove an install root without its valid current-generation "
            "ownership manifest: "
            f"{args.install_root}"
        )
    for option, attribute in (
        ("--config-root", "config_root"),
        ("--state-root", "state_root"),
        ("--bin-dir", "bin_dir"),
    ):
        value = data.get(attribute)
        if not isinstance(value, str) or not Path(value).expanduser().is_absolute():
            raise RuntimeError(f"ownership manifest is missing {attribute}")
        recorded = absolute_path(Path(value))
        if _option_was_supplied(option):
            if getattr(args, attribute) != recorded:
                raise RuntimeError(
                    f"refusing {option} outside the ownership manifest: "
                    f"{getattr(args, attribute)} != {recorded}"
                )
        else:
            setattr(args, attribute, recorded)

    validate_removal_target(args.install_root, "install root")
    validate_removal_target(args.config_root, "configuration root")
    validate_removal_target(args.state_root, "state root")
    if args.bin_dir.resolve(strict=False) != args.bin_dir:
        raise ValueError(f"refusing binary directory reached through a symlink: {args.bin_dir}")

    # Preserve a location-bound ownership record before removing the only
    # installed copy. This supports a later explicit purge without relaxing
    # deletion checks for arbitrary directories.
    if not args.purge_config:
        _write_owner_manifest(args.config_root, data)
    if not args.purge_state:
        _write_owner_manifest(args.state_root, data)

    for name in ("codex-mmo", "codex-mmoctl", "codex-mmo-uninstall"):
        path = args.bin_dir / name
        if owned_wrapper(path):
            path.unlink()
    remove_tree(args.install_root)
    if args.purge_config:
        remove_tree(args.config_root)
    if args.purge_state:
        remove_tree(args.state_root)

    print("Codex Multi-Model Orchestrator removed.")
    if not args.purge_config and args.config_root.exists():
        print(f"Configuration retained at {args.config_root}; use --purge-config to remove it.")
    if not args.purge_state and args.state_root.exists():
        print(f"State retained at {args.state_root}; use --purge-state to remove it.")
    return 0


def cli_main() -> int:
    try:
        return main()
    except KeyboardInterrupt:
        return 130
    except Exception as exc:
        print(f"error: {exc}", file=sys.stderr)
        if "--debug" in sys.argv[1:]:
            print(f"exception: {type(exc).__name__}", file=sys.stderr)
            traceback.print_exc(file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(cli_main())
