from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Protocol
from xml.sax.saxutils import escape

from rimpack.config import (
    RimpackConfig,
    get_pack_runtime_root,
    get_pack_save_data_root,
)
from rimpack.core.modpack import (
    EntryLine,
    Modpack,
    ModpackError,
    ModpackLoadOrderEntry,
    ModpackResolutionRoots,
    generate_modpack_load_order,
)
from rimpack.steamworks import (
    create_steamworks_client,
    resolve_workshop_by_id,
    unsubscribe_from_workshop_item,
)


class _WaitableProcess(Protocol):
    def wait(self) -> int | None: ...


@dataclass(frozen=True)
class LaunchResult:
    launch_root: Path
    executable_path: Path
    save_data_root: Path
    load_order: tuple[ModpackLoadOrderEntry, ...]
    subscribed_workshop_ids: tuple[int, ...]


def launch_modpack(
    modpack: Modpack,
    *,
    root_directory: Path,
    config: RimpackConfig | None,
    clean: bool,
) -> LaunchResult:
    rimworld_folder = _require_rimworld_folder(config)
    steam_workshop_folder = _configured_steam_workshop_folder(config)
    required_workshop_ids = _required_workshop_ids(modpack)
    cleanup_plan = _prepare_workshop_subscriptions(
        required_workshop_ids,
        steam_workshop_folder=steam_workshop_folder,
        clean=clean,
    )

    load_order = generate_modpack_load_order(
        modpack,
        root_directory=root_directory,
        resolution_roots=_resolution_roots(
            modpack,
            root_directory=root_directory,
            rimworld_folder=rimworld_folder,
            steam_workshop_folder=steam_workshop_folder,
        ),
    )

    _write_resolved_load_order(
        modpack,
        root_directory=root_directory,
        load_order=load_order,
    )

    launch_root = _resolve_launch_root(root_directory)
    _rebuild_launch_root(launch_root, rimworld_folder=rimworld_folder, load_order=load_order)
    save_data_root = _rebuild_save_data_root(
        root_directory,
        modpack=modpack,
        root_directory=root_directory,
        load_order=load_order,
        rimworld_folder=rimworld_folder,
    )
    executable_path = _resolve_launch_executable(launch_root, rimworld_folder=rimworld_folder)
    process = subprocess.Popen(
        [str(executable_path), f"-savedatafolder={save_data_root}"],
        cwd=str(launch_root),
    )
    _ = _wait_for_rimworld_exit(process)
    _run_cleanup_plan(cleanup_plan)

    return LaunchResult(
        launch_root=launch_root,
        executable_path=executable_path,
        save_data_root=save_data_root,
        load_order=load_order,
        subscribed_workshop_ids=required_workshop_ids,
    )


def _required_workshop_ids(modpack: Modpack) -> tuple[int, ...]:
    workshop_ids: list[int] = []
    seen: set[int] = set()
    for modpack_file in modpack.mod_files:
        for line in modpack_file.document.lines:
            if not isinstance(line, EntryLine) or line.disabled:
                continue
            if line.reference.kind != "steam":
                continue
            workshop_id = int(line.reference.value)
            if workshop_id in seen:
                continue
            seen.add(workshop_id)
            workshop_ids.append(workshop_id)
    return tuple(workshop_ids)


@dataclass(frozen=True)
class _WorkshopCleanupPlan:
    steamworks_client: object | None
    cleanup_workshop_ids: tuple[int, ...]


def _prepare_workshop_subscriptions(
    required_workshop_ids: tuple[int, ...],
    *,
    steam_workshop_folder: Path | None,
    clean: bool,
) -> _WorkshopCleanupPlan:
    if not required_workshop_ids and not clean:
        return _WorkshopCleanupPlan(steamworks_client=None, cleanup_workshop_ids=())
    if steam_workshop_folder is None:
        if required_workshop_ids:
            raise ModpackError("could not locate the RimWorld Steam Workshop folder")
        return _WorkshopCleanupPlan(steamworks_client=None, cleanup_workshop_ids=())
    if not steam_workshop_folder.is_dir():
        raise ModpackError(f"Steam Workshop folder does not exist: {steam_workshop_folder}")

    client = create_steamworks_client()
    for workshop_id in required_workshop_ids:
        _ = resolve_workshop_by_id(
            workshop_id,
            cleanup=False,
            steam_workshop_folder=steam_workshop_folder,
            steamworks_client=client,
        )

    if not clean:
        return _WorkshopCleanupPlan(steamworks_client=client, cleanup_workshop_ids=())

    required_id_set = set(required_workshop_ids)
    cleanup_workshop_ids = tuple(
        workshop_id
        for workshop_id in _installed_workshop_ids(steam_workshop_folder)
        if workshop_id not in required_id_set
    )
    return _WorkshopCleanupPlan(
        steamworks_client=client,
        cleanup_workshop_ids=cleanup_workshop_ids,
    )


def _wait_for_rimworld_exit(process: _WaitableProcess) -> int | None:
    return process.wait()


def _run_cleanup_plan(cleanup_plan: _WorkshopCleanupPlan) -> None:
    if cleanup_plan.steamworks_client is None:
        return
    for workshop_id in cleanup_plan.cleanup_workshop_ids:
        _ = unsubscribe_from_workshop_item(
            workshop_id,
            steamworks_client=cleanup_plan.steamworks_client,
        )


def _installed_workshop_ids(steam_workshop_folder: Path) -> tuple[int, ...]:
    workshop_ids: list[int] = []
    for child in sorted(steam_workshop_folder.iterdir(), key=lambda path: path.name.casefold()):
        if child.is_dir() and child.name.isdigit():
            workshop_ids.append(int(child.name))
    return tuple(workshop_ids)


def _resolution_roots(
    modpack: Modpack,
    *,
    root_directory: Path,
    rimworld_folder: Path,
    steam_workshop_folder: Path | None,
) -> ModpackResolutionRoots:
    local_mods_dir = modpack.manifest.paths.local_mods_dir
    return ModpackResolutionRoots(
        local_mods_dir=(
            (root_directory / Path(local_mods_dir)).resolve()
            if local_mods_dir is not None
            else None
        ),
        steam_workshop_folder=steam_workshop_folder,
        rimworld_mods_folder=(rimworld_folder / "Mods").resolve(),
        rimworld_data_folder=(rimworld_folder / "Data").resolve(),
    )


def _write_resolved_load_order(
    modpack: Modpack,
    *,
    root_directory: Path,
    load_order: tuple[ModpackLoadOrderEntry, ...],
) -> None:
    relative_path = modpack.manifest.paths.resolved_load_order_file
    if relative_path is None:
        return
    destination = (root_directory / Path(relative_path)).resolve()
    destination.parent.mkdir(parents=True, exist_ok=True)
    _ = destination.write_text(_render_resolved_load_order(load_order), encoding="utf-8", newline="\n")


def _render_resolved_load_order(load_order: tuple[ModpackLoadOrderEntry, ...]) -> str:
    lines = [
        (
            f"{index:04d} | {entry.package_id} | {entry.name} | "
            f"{entry.requested} | {entry.source_file}:{entry.source_line}"
        )
        for index, entry in enumerate(load_order, start=1)
    ]
    if not lines:
        return ""
    return "\n".join(lines) + "\n"


def _rebuild_save_data_root(
    pack_root: Path,
    *,
    modpack: Modpack,
    root_directory: Path,
    load_order: tuple[ModpackLoadOrderEntry, ...],
    rimworld_folder: Path,
) -> Path:
    save_data_root = _resolve_save_data_root(pack_root)
    if save_data_root.exists() and not save_data_root.is_dir():
        raise ModpackError(f"save data root exists but is not a directory: {save_data_root}")
    save_data_root.mkdir(parents=True, exist_ok=True)

    config_root = save_data_root / "Config"
    _seed_persistent_config_directory(
        config_root,
        modpack=modpack,
        root_directory=root_directory,
    )
    mods_config_text = _render_mods_config(
        load_order=load_order,
        rimworld_version=_read_rimworld_version(rimworld_folder),
        rimworld_folder=rimworld_folder,
    )
    _ = (config_root / "ModsConfig.xml").write_text(
        mods_config_text,
        encoding="utf-8",
        newline="\n",
    )
    return save_data_root


def _seed_persistent_config_directory(
    config_root: Path,
    *,
    modpack: Modpack,
    root_directory: Path,
) -> None:
    config_dir = modpack.manifest.paths.config_dir
    if config_dir is None:
        config_root.mkdir(parents=True, exist_ok=True)
        return
    source_root = (root_directory / Path(config_dir)).resolve()
    if not source_root.is_dir():
        raise ModpackError(f"modpack config directory does not exist: {source_root}")
    if not config_root.exists():
        _ = shutil.copytree(source_root, config_root)
        return
    if not config_root.is_dir():
        raise ModpackError(f"persistent config root exists but is not a directory: {config_root}")
    for child in sorted(source_root.iterdir(), key=lambda path: path.name.casefold()):
        destination = config_root / child.name
        _copy_missing_config_entries(child, destination)


def _copy_missing_config_entries(source: Path, destination: Path) -> None:
    if destination.exists():
        if source.is_dir():
            if not destination.is_dir():
                raise ModpackError(
                    f"persistent config path conflicts with authored config directory: {destination}"
                )
            for child in sorted(source.iterdir(), key=lambda path: path.name.casefold()):
                _copy_missing_config_entries(child, destination / child.name)
        return
    if source.is_dir():
        _ = shutil.copytree(source, destination)
        return
    destination.parent.mkdir(parents=True, exist_ok=True)
    _ = shutil.copy2(source, destination)


def _render_mods_config(
    *,
    load_order: tuple[ModpackLoadOrderEntry, ...],
    rimworld_version: str,
    rimworld_folder: Path,
) -> str:
    active_package_ids = _mods_config_active_package_ids(
        load_order,
    )
    active_mods = "\n".join(
        f"    <li>{escape(package_id)}</li>" for package_id in active_package_ids
    )
    known_expansion_package_ids = _known_expansion_package_ids(
        load_order,
        rimworld_folder=rimworld_folder,
    )
    known_expansions = "\n".join(
        f"    <li>{escape(package_id)}</li>" for package_id in known_expansion_package_ids
    )
    active_mods_block = f"{active_mods}\n" if active_mods else ""
    known_expansions_block = f"{known_expansions}\n" if known_expansions else ""
    return (
        '<?xml version="1.0" encoding="utf-8"?>\n'
        "<ModsConfigData>\n"
        f"  <version>{escape(rimworld_version)}</version>\n"
        "  <activeMods>\n"
        f"{active_mods_block}"
        "  </activeMods>\n"
        "  <knownExpansions>\n"
        f"{known_expansions_block}"
        "  </knownExpansions>\n"
        "</ModsConfigData>\n"
    )


def _mods_config_active_package_ids(
    load_order: tuple[ModpackLoadOrderEntry, ...],
) -> tuple[str, ...]:
    package_ids: list[str] = []
    seen: set[str] = set()
    for entry in load_order:
        normalized_package_id = entry.package_id.casefold()
        if normalized_package_id in seen:
            continue
        seen.add(normalized_package_id)
        package_ids.append(normalized_package_id)
    return tuple(package_ids)


def _known_expansion_package_ids(
    load_order: tuple[ModpackLoadOrderEntry, ...],
    *,
    rimworld_folder: Path,
) -> tuple[str, ...]:
    package_ids: list[str] = []
    seen: set[str] = set()
    for entry in load_order:
        if not _is_game_data_entry(entry, rimworld_folder=rimworld_folder):
            continue
        normalized_package_id = entry.package_id.casefold()
        if normalized_package_id == "ludeon.rimworld":
            continue
        if normalized_package_id in seen:
            continue
        seen.add(normalized_package_id)
        package_ids.append(normalized_package_id)
    return tuple(package_ids)


def _read_rimworld_version(rimworld_folder: Path) -> str:
    version_path = rimworld_folder / "Version.txt"
    if not version_path.is_file():
        return "unknown"
    return version_path.read_text(encoding="utf-8").strip() or "unknown"


def _resolve_launch_root(root_directory: Path) -> Path:
    return get_pack_runtime_root(root_directory).resolve()


def _resolve_save_data_root(root_directory: Path) -> Path:
    return get_pack_save_data_root(root_directory).resolve()


def _require_rimworld_folder(config: RimpackConfig | None) -> Path:
    rimworld_folder = None if config is None else config.rimworld_folder
    if rimworld_folder is None:
        raise ModpackError("could not locate the RimWorld install directory")
    resolved_rimworld_folder = rimworld_folder.resolve()
    if not resolved_rimworld_folder.is_dir():
        raise ModpackError(f"RimWorld install directory does not exist: {resolved_rimworld_folder}")
    return resolved_rimworld_folder


def _configured_steam_workshop_folder(config: RimpackConfig | None) -> Path | None:
    if config is None or config.steam_workshop_folder is None:
        return None
    return config.steam_workshop_folder.resolve()


def _rebuild_launch_root(
    launch_root: Path,
    *,
    rimworld_folder: Path,
    load_order: tuple[ModpackLoadOrderEntry, ...],
) -> None:
    if launch_root.exists():
        if not launch_root.is_dir():
            raise ModpackError(f"launch root exists but is not a directory: {launch_root}")
        _clear_directory(launch_root)
    else:
        launch_root.mkdir(parents=True, exist_ok=True)

    for child in sorted(rimworld_folder.iterdir(), key=lambda path: path.name.casefold()):
        if child.name.casefold() == "mods":
            continue
        _create_symlink(
            target=child.resolve(),
            link_path=launch_root / child.name,
            target_is_directory=child.is_dir(),
        )

    mods_root = launch_root / "Mods"
    mods_root.mkdir()
    for index, entry in enumerate(load_order, start=1):
        if _is_game_data_entry(entry, rimworld_folder=rimworld_folder):
            continue
        if not entry.path.is_dir():
            raise ModpackError(f"resolved mod path does not exist: {entry.path}")
        _create_symlink(
            target=entry.path.resolve(),
            link_path=mods_root / _launch_mod_symlink_name(index, entry.path.name),
            target_is_directory=True,
        )


def _clear_directory(path: Path) -> None:
    for child in path.iterdir():
        if child.is_symlink() or child.is_file():
            child.unlink()
            continue
        shutil.rmtree(child)


def _create_symlink(*, target: Path, link_path: Path, target_is_directory: bool) -> None:
    link_path.symlink_to(target, target_is_directory=target_is_directory)


def _launch_mod_symlink_name(index: int, folder_name: str) -> str:
    return f"{index:04d}_{folder_name}"


def _is_game_data_entry(
    entry: ModpackLoadOrderEntry,
    *,
    rimworld_folder: Path,
) -> bool:
    data_root = (rimworld_folder / "Data").resolve()
    try:
        _ = entry.path.resolve().relative_to(data_root)
    except ValueError:
        return False
    return True


def _resolve_launch_executable(launch_root: Path, *, rimworld_folder: Path) -> Path:
    for relative_path in _launch_executable_candidates():
        real_executable = rimworld_folder / relative_path
        if not real_executable.exists():
            continue
        launch_executable = launch_root / relative_path
        if launch_executable.exists():
            return launch_executable
    raise ModpackError(f"could not find a RimWorld executable in {rimworld_folder}")


def _launch_executable_candidates() -> tuple[Path, ...]:
    if sys.platform == "win32":
        return (
            Path("RimWorldWin64.exe"),
            Path("RimWorldWin.exe"),
        )
    if sys.platform == "darwin":
        return (Path("RimWorldMac.app") / "Contents" / "MacOS" / "RimWorldMac",)
    return (
        Path("RimWorldLinux"),
        Path("RimWorldLinux.x86_64"),
    )
