import importlib
from dataclasses import dataclass
from pathlib import Path
import tomllib
from types import ModuleType

import pytest
from typer.testing import CliRunner

from rimpack.cli.main import app
from rimpack.config import RimpackConfig
from rimpack.core.modpack import (
    AliasLine,
    BlankLine,
    EntryLine,
    ModpackError,
    load_pack_toml,
    load_modpack_list,
    render_pack_toml,
    with_added_ordering_rule_file,
)
from rimpack.core.mods.about_xml import AboutXmlError
from rimpack.core.mods.mod_folder import ModFolder, load_mod_folder
from rimpack.steamworks import SteamworksError
from tests._helpers import write_about_xml

runner = CliRunner()
cli_main = importlib.import_module("rimpack.cli.main")
assert isinstance(cli_main, ModuleType)


def test_cli_with_no_args_creates_config_when_confirmed(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_directory = tmp_path / "rimpack-config"
    config_path = config_directory / "config.toml"
    rimworld_folder = tmp_path / "RimWorld"
    steam_workshop_folder = tmp_path / "Workshop"
    rimworld_folder.mkdir()
    steam_workshop_folder.mkdir()
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.setattr(
        cli_main,
        "_discover_default_config",
        lambda: RimpackConfig(
            rimworld_folder=rimworld_folder,
            steam_workshop_folder=steam_workshop_folder,
        ),
    )

    result = runner.invoke(app, input="y\ny\ny\n")

    assert result.exit_code == 0
    assert "Create configuration file" in result.stdout
    assert "Use RimWorld folder" in result.stdout
    assert "Use Steam Workshop folder" in result.stdout
    assert config_path.is_file()
    document = tomllib.loads(config_path.read_text(encoding="utf-8"))
    assert document == {
        "rimworld_folder": rimworld_folder.as_posix(),
        "steam_workshop_folder": steam_workshop_folder.as_posix(),
    }


def test_cli_with_no_args_leaves_config_missing_when_declined(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_directory = tmp_path / "rimpack-config"
    config_path = config_directory / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    result = runner.invoke(app, input="n\n")

    assert result.exit_code == 0
    assert "Create configuration file" in result.stdout
    assert not config_path.exists()


def test_cli_with_no_args_skips_prompt_when_config_exists(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_directory = tmp_path / "rimpack-config"
    config_path = config_directory / "config.toml"
    config_directory.mkdir(parents=True)
    _ = config_path.write_text("", encoding="utf-8")
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    result = runner.invoke(app)

    assert result.exit_code == 0
    assert result.stdout == ""


def test_cli_help_includes_command_summary() -> None:
    result = runner.invoke(app, ["--help"])

    assert result.exit_code == 0
    assert "Inspect RimWorld mods and initialize RimWorld modpack projects." in result.stdout
    assert "init" in result.stdout
    assert "launch" in result.stdout
    assert "load-mods" in result.stdout
    assert "resolve" in result.stdout
    assert "subscribe" in result.stdout
    assert "unsubscribe" in result.stdout
    assert "validate" in result.stdout


def test_launch_help_describes_launch_workflow_and_clean_option() -> None:
    result = runner.invoke(app, ["launch", "--help"])

    assert result.exit_code == 0
    assert "Subscribe workshop mods from the current modpack" in result.stdout
    assert "symlinked runtime tree" in result.stdout
    assert "--clean" in result.stdout
    assert "--no-clean" in result.stdout
    assert "After RimWorld exits" in result.stdout


def test_validate_help_describes_validation_scope() -> None:
    result = runner.invoke(app, ["validate", "--help"])

    assert result.exit_code == 0
    assert "Validate the current modpack" in result.stdout
    assert "missing" in result.stdout
    assert "dependencies" in result.stdout
    assert "load-order" in result.stdout
    assert "cycles" in result.stdout


def test_load_mods_help_describes_default_scan_behavior() -> None:
    result = runner.invoke(app, ["load-mods", "--help"])

    assert result.exit_code == 0
    assert "configured or detected" in result.stdout
    assert "Optional mod folder or directory containing mod" in result.stdout
    assert "rimpack scans" in result.stdout
    assert "RimWorld Mods, Data, and Steam Workshop" in result.stdout


def test_init_help_describes_default_behavior() -> None:
    result = runner.invoke(app, ["init", "--help"])

    assert result.exit_code == 0
    assert "Create a new modpack project folder" in result.stdout
    assert "Target modpack directory" in result.stdout


def test_add_help_describes_target_and_supported_references() -> None:
    result = runner.invoke(app, ["add", "--help"])

    assert result.exit_code == 0
    assert "Resolve a mod reference and append it" in result.stdout
    assert "Missing list targets are created" in result.stdout
    assert "@alias" in result.stdout
    assert "local:<name>" in result.stdout
    assert "steam:<id>" in result.stdout
    assert "sharedfiles" in result.stdout
    assert "package" in result.stdout


def test_add_ordering_rule_help_describes_arguments_and_file_option() -> None:
    result = runner.invoke(app, ["add-ordering-rule", "--help"])

    assert result.exit_code == 0
    assert "Append an ordering rule" in result.stdout
    assert "Missing rule targets are created" in result.stdout
    assert "before or after" in result.stdout
    assert "steam:<id>" in result.stdout
    assert "--reason" in result.stdout
    assert "--file" in result.stdout


def test_subscribe_help_describes_workshop_id_argument() -> None:
    result = runner.invoke(app, ["subscribe", "--help"])

    assert result.exit_code == 0
    assert "Subscribe to a Steam Workshop mod" in result.stdout
    assert "sharedfiles URL" in result.stdout


def test_unsubscribe_help_describes_workshop_id_argument() -> None:
    result = runner.invoke(app, ["unsubscribe", "--help"])

    assert result.exit_code == 0
    assert "Unsubscribe from a Steam Workshop mod" in result.stdout
    assert "sharedfiles URL" in result.stdout


def test_resolve_help_includes_steam_subcommand() -> None:
    result = runner.invoke(app, ["resolve", "--help"])

    assert result.exit_code == 0
    assert "Resolve mod references through supported backends." in result.stdout
    assert "steam" in result.stdout


def test_resolve_steam_help_describes_workshop_id_and_cleanup_option() -> None:
    result = runner.invoke(app, ["resolve", "steam", "--help"])

    assert result.exit_code == 0
    assert "Resolve a Steam Workshop mod" in result.stdout
    assert "sharedfiles URL" in result.stdout
    assert "--no-cleanup" in result.stdout


def test_subscribe_calls_steamworks_wrapper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    calls: list[int] = []

    def fake_subscribe(workshop_id: int) -> bool:
        calls.append(workshop_id)
        return True

    monkeypatch.setattr(cli_main, "subscribe_to_workshop_item", fake_subscribe)

    result = runner.invoke(app, ["subscribe", "2009463077"])

    assert result.exit_code == 0
    assert calls == [2009463077]
    assert "Subscribed to workshop item 2009463077" in result.stdout


def test_unsubscribe_calls_steamworks_wrapper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    calls: list[int] = []

    def fake_unsubscribe(workshop_id: int) -> bool:
        calls.append(workshop_id)
        return True

    monkeypatch.setattr(cli_main, "unsubscribe_from_workshop_item", fake_unsubscribe)

    result = runner.invoke(app, ["unsubscribe", "818773962"])

    assert result.exit_code == 0
    assert calls == [818773962]
    assert "Unsubscribed from workshop item 818773962" in result.stdout


def test_subscribe_accepts_sharedfiles_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    calls: list[int] = []

    def fake_subscribe(workshop_id: int) -> bool:
        calls.append(workshop_id)
        return True

    monkeypatch.setattr(cli_main, "subscribe_to_workshop_item", fake_subscribe)

    result = runner.invoke(
        app,
        ["subscribe", "https://steamcommunity.com/sharedfiles/filedetails/?id=3685058533"],
    )

    assert result.exit_code == 0
    assert calls == [3685058533]
    assert "Subscribed to workshop item 3685058533" in result.stdout


def test_unsubscribe_accepts_sharedfiles_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    calls: list[int] = []

    def fake_unsubscribe(workshop_id: int) -> bool:
        calls.append(workshop_id)
        return True

    monkeypatch.setattr(cli_main, "unsubscribe_from_workshop_item", fake_unsubscribe)

    result = runner.invoke(
        app,
        ["unsubscribe", "https://steamcommunity.com/sharedfiles/filedetails/?id=3685058533"],
    )

    assert result.exit_code == 0
    assert calls == [3685058533]
    assert "Unsubscribed from workshop item 3685058533" in result.stdout


def test_subscribe_returns_error_when_steamworks_fails(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    def fail(_workshop_id: str) -> bool:
        raise SteamworksError("steam offline")

    monkeypatch.setattr(cli_main, "subscribe_to_workshop_item", fail)

    result = runner.invoke(app, ["subscribe", "2009463077"])

    assert result.exit_code == 1
    assert "Error: steam offline" in result.stderr


def test_unsubscribe_returns_error_for_invalid_workshop_id(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    result = runner.invoke(app, ["unsubscribe", "steam:2009463077"])

    assert result.exit_code == 1
    assert "Error: workshop_id must be a positive integer" in result.stderr


def test_resolve_steam_displays_mod_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    workshop_root = tmp_path / "Workshop"
    write_about_xml(workshop_root / "2009463077", "author.workshop", "Workshop Mod")
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    calls: list[tuple[str, bool]] = []

    def fake_resolve(workshop_id: str, *, cleanup: bool = True) -> ModFolder:
        calls.append((workshop_id, cleanup))
        return load_mod_folder(workshop_root / "2009463077")

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(app, ["resolve", "steam", "2009463077"])

    assert result.exit_code == 0
    assert calls == [("2009463077", True)]
    assert "Mod Metadata" in result.stdout
    assert "Workshop Mod" in result.stdout
    assert "author.workshop" in result.stdout


def test_resolve_steam_supports_no_cleanup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    workshop_root = tmp_path / "Workshop"
    write_about_xml(workshop_root / "3685058533", "author.persistent", "Persistent Mod")
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    calls: list[tuple[str, bool]] = []

    def fake_resolve(workshop_id: str, *, cleanup: bool = True) -> ModFolder:
        calls.append((workshop_id, cleanup))
        return load_mod_folder(workshop_root / "3685058533")

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        [
            "resolve",
            "steam",
            "https://steamcommunity.com/sharedfiles/filedetails/?id=3685058533",
            "--no-cleanup",
        ],
    )

    assert result.exit_code == 0
    assert calls == [
        ("https://steamcommunity.com/sharedfiles/filedetails/?id=3685058533", False)
    ]
    assert "Persistent Mod" in result.stdout


def test_resolve_steam_returns_error_when_resolution_fails(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    def fail(_workshop_id: str, *, cleanup: bool = True) -> ModFolder:
        assert cleanup is True
        raise SteamworksError("resolution failed")

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fail)

    result = runner.invoke(app, ["resolve", "steam", "2009463077"])

    assert result.exit_code == 1
    assert "Error: resolution failed" in result.stderr


def test_launch_calls_launch_modpack_with_clean_flag(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "LaunchPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    calls: list[tuple[Path, bool, RimpackConfig | None]] = []

    @dataclass(frozen=True)
    class _Result:
        launch_root: Path
        load_order: tuple[object, ...] = ()

    def fake_launch_modpack(
        modpack: object,
        *,
        root_directory: Path,
        config: RimpackConfig | None,
        clean: bool,
    ) -> _Result:
        calls.append((root_directory, clean, config))
        assert modpack is not None
        return _Result(launch_root=tmp_path / "Runtime")

    monkeypatch.setattr(cli_main, "launch_modpack", fake_launch_modpack)

    def passthrough_config(config: RimpackConfig | None) -> RimpackConfig | None:
        return config

    monkeypatch.setattr(cli_main, "_merged_runtime_config", passthrough_config)

    result = runner.invoke(app, ["launch"])

    assert result.exit_code == 0
    assert len(calls) == 1
    assert calls[0][0] == pack_root
    assert calls[0][1] is True
    assert "RimWorld exited from" in result.stdout


def test_launch_supports_no_clean_flag(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "LaunchNoCleanPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    calls: list[bool] = []

    @dataclass(frozen=True)
    class _Result:
        launch_root: Path
        load_order: tuple[object, ...] = ()

    def fake_launch_modpack(
        modpack: object,
        *,
        root_directory: Path,
        config: RimpackConfig | None,
        clean: bool,
    ) -> _Result:
        _ = (modpack, root_directory, config)
        calls.append(clean)
        return _Result(launch_root=tmp_path / "Runtime")

    monkeypatch.setattr(cli_main, "launch_modpack", fake_launch_modpack)

    result = runner.invoke(app, ["launch", "--no-clean"])

    assert result.exit_code == 0
    assert calls == [False]


def test_launch_returns_error_when_launching_fails(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "BrokenLaunchPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fail(*_args: object, **_kwargs: object) -> object:
        raise ModpackError("missing runtime")

    monkeypatch.setattr(cli_main, "launch_modpack", fail)

    result = runner.invoke(app, ["launch"])

    assert result.exit_code == 1
    assert "Error: missing runtime" in result.stderr


def test_validate_reports_errors_and_returns_nonzero_exit_code(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    pack_root = tmp_path / "ValidateCliPack"
    _initialize_pack_root(pack_root)
    write_about_xml(
        pack_root / "local" / "mods" / "NeedsDependency",
        "author.needsdependency",
        "Needs Dependency",
        extra_xml="""\
  <modDependencies>
    <li>
      <packageId>author.missingdependency</packageId>
      <displayName>Missing Dependency</displayName>
    </li>
  </modDependencies>""",
    )
    _ = (pack_root / "mods" / "10_libraries.list").write_text(
        "@alias libraries\n\n# Libraries\nlocal:NeedsDependency\n",
        encoding="utf-8",
        newline="\n",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(app, ["validate"])

    assert result.exit_code == 1
    assert "Validation Summary" in result.stdout
    assert "Validation Diagnostics" in result.stdout
    assert "author.needsdependency" in result.stdout
    assert "Missing Dependency" in result.stdout


def test_init_creates_modpack_with_supported_defaults(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    pack_root = tmp_path / "MyPack"

    result = runner.invoke(app, ["init", str(pack_root)])

    assert result.exit_code == 0
    assert "Initialized modpack at" in result.stdout
    assert (pack_root / "pack.toml").is_file()
    assert (pack_root / "README.md").is_file()
    assert (pack_root / "mods" / "00_core.list").is_file()
    assert (pack_root / "mods" / "10_libraries.list").is_file()
    assert (pack_root / "mods" / "20_ui.list").is_file()
    assert (pack_root / "mods" / "30_gameplay.list").is_file()
    assert (pack_root / "mods" / "40_qol.list").is_file()
    assert (pack_root / "mods" / "50_overhaul.list").is_file()
    assert (pack_root / "mods" / "60_factions.list").is_file()
    assert (pack_root / "mods" / "70_combat.list").is_file()
    assert (pack_root / "mods" / "80_graphics.list").is_file()
    assert (pack_root / "mods" / "90_textures.list").is_file()
    assert (pack_root / "mods" / "99_patch.list").is_file()
    assert (pack_root / "config" / "Mod_Configs").is_dir()
    assert (pack_root / "generated").is_dir()
    assert (pack_root / "local" / "mods").is_dir()

    manifest = load_pack_toml(pack_root / "pack.toml")
    assert manifest.pack.name == "MyPack"
    assert manifest.layout.mod_files == (
        "mods/00_core.list",
        "mods/10_libraries.list",
        "mods/20_ui.list",
        "mods/30_gameplay.list",
        "mods/40_qol.list",
        "mods/50_overhaul.list",
        "mods/60_factions.list",
        "mods/70_combat.list",
        "mods/80_graphics.list",
        "mods/90_textures.list",
        "mods/99_patch.list",
    )
    assert manifest.paths.config_dir == "config/Mod_Configs"
    assert manifest.paths.diagnostics_file == "generated/diagnostics.json"
    assert manifest.paths.resolved_load_order_file == "generated/resolved_load_order.txt"
    assert manifest.paths.local_mods_dir == "local/mods"

    core_list = load_modpack_list(pack_root / "mods" / "00_core.list")
    assert len(core_list.lines) == 4
    assert isinstance(core_list.lines[0], AliasLine)
    assert core_list.lines[0].alias == "core"
    assert isinstance(core_list.lines[1], BlankLine)
    libraries_list = load_modpack_list(pack_root / "mods" / "10_libraries.list")
    assert len(libraries_list.lines) == 3
    assert isinstance(libraries_list.lines[0], AliasLine)
    assert libraries_list.lines[0].alias == "libraries"
    assert isinstance(libraries_list.lines[1], BlankLine)
    qol_list = load_modpack_list(pack_root / "mods" / "40_qol.list")
    assert len(qol_list.lines) == 3
    assert isinstance(qol_list.lines[0], AliasLine)
    assert qol_list.lines[0].alias == "qol"
    patch_list = load_modpack_list(pack_root / "mods" / "99_patch.list")
    assert len(patch_list.lines) == 3
    assert isinstance(patch_list.lines[0], AliasLine)
    assert patch_list.lines[0].alias == "patch"


def test_init_adds_detected_dlcs_from_rimworld_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    rimworld_folder = tmp_path / "RimWorld"
    write_about_xml(
        rimworld_folder / "Data" / "Ideology",
        "ludeon.rimworld.ideology",
        "Ideology",
    )
    write_about_xml(
        rimworld_folder / "Data" / "Biotech",
        "ludeon.rimworld.biotech",
        "Biotech",
    )
    write_about_xml(
        rimworld_folder / "Data" / "Core",
        "ludeon.rimworld",
        "Core",
    )
    _ = (rimworld_folder / "Data" / "NotAMod").mkdir(parents=True)
    monkeypatch.setenv("RIMWORLD_FOLDER", str(rimworld_folder))
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    pack_root = tmp_path / "DlcPack"
    result = runner.invoke(app, ["init", str(pack_root)])

    assert result.exit_code == 0
    core_list = load_modpack_list(pack_root / "mods" / "00_core.list")
    assert [line.raw_reference_text for line in core_list.lines if isinstance(line, EntryLine)] == [
        "packageid:ludeon.rimworld",
        "packageid:ludeon.rimworld.ideology",
        "packageid:ludeon.rimworld.biotech",
    ]


def test_init_defaults_to_current_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    monkeypatch.chdir(tmp_path)

    result = runner.invoke(app, ["init"])

    assert result.exit_code == 0
    manifest = load_pack_toml(tmp_path / "pack.toml")
    assert manifest.pack.name == tmp_path.name


def test_init_fails_when_pack_toml_already_exists(tmp_path: Path) -> None:
    pack_root = tmp_path / "ExistingPack"
    pack_root.mkdir()
    _ = (pack_root / "pack.toml").write_text("[pack]\n", encoding="utf-8")

    result = runner.invoke(app, ["init", str(pack_root)])

    assert result.exit_code == 1
    assert "pack.toml already exists" in result.stderr


def test_add_appends_resolved_workshop_mod_by_alias(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "AliasPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    calls: list[tuple[int, bool]] = []

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        calls.append((workshop_id, cleanup))
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", f"Workshop {workshop_id}")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        ["add", "@libraries", "steam:2009463077"],
    )

    assert result.exit_code == 0
    assert calls == [(2009463077, True)]
    assert "Added steam:2009463077 to mods/10_libraries.list" in result.stdout
    assert "Mod Metadata" in result.stdout
    assert "Workshop 2009463077" in result.stdout
    assert (pack_root / "mods" / "10_libraries.list").read_text(encoding="utf-8") == (
        "@alias libraries\n"
        "\n"
        "# Libraries\n"
        "steam:2009463077  # Workshop 2009463077\n"
    )


def test_add_accepts_bare_alias_target(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "BareAliasPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        assert cleanup is True
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Bare Alias Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(app, ["add", "gameplay", "steam:3685058533"])

    assert result.exit_code == 0
    assert "Added steam:3685058533 to mods/30_gameplay.list" in result.stdout
    assert "Mod Metadata" in result.stdout
    assert "Bare Alias Mod" in result.stdout
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "steam:3685058533  # Bare Alias Mod\n"
    )


def test_add_accepts_sharedfiles_url_as_workshop_reference(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "UrlRefPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        assert workshop_id == 3685058533
        assert cleanup is True
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "URL Target Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        [
            "add",
            "gameplay",
            "https://steamcommunity.com/sharedfiles/filedetails/?id=3685058533",
        ],
    )

    assert result.exit_code == 0
    assert "Added steam:3685058533 to mods/30_gameplay.list" in result.stdout
    assert "Mod Metadata" in result.stdout
    assert "URL Target Mod" in result.stdout
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "steam:3685058533  # URL Target Mod\n"
    )


def test_add_accepts_packageid_and_prefers_workshop_match(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "PackageIdWorkshopPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    rimworld_folder = tmp_path / "RimWorld"
    workshop_root = tmp_path / "Workshop"
    write_about_xml(
        workshop_root / "3685058533",
        "author.sharedmod",
        "Workshop Preferred Mod",
    )
    write_about_xml(
        rimworld_folder / "Mods" / "SharedMod",
        "author.sharedmod",
        "Installed Copy",
    )
    monkeypatch.setenv("RIMWORLD_FOLDER", str(rimworld_folder))
    monkeypatch.setenv("STEAM_WORKSHOP_FOLDER", str(workshop_root))
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["add", "gameplay", "packageid:author.sharedmod"])

    assert result.exit_code == 0
    assert "Added steam:3685058533 to mods/30_gameplay.list" in result.stdout
    assert "Workshop Preferred Mod" in result.stdout
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "steam:3685058533  # Workshop Preferred Mod\n"
    )


def test_add_accepts_packageid_from_rimworld_mods(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "PackageIdModsPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    rimworld_folder = tmp_path / "RimWorld"
    write_about_xml(
        rimworld_folder / "Mods" / "LocalLibrary",
        "author.locallibrary",
        "Local Library",
    )
    monkeypatch.setenv("RIMWORLD_FOLDER", str(rimworld_folder))
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["add", "libraries", "packageid:author.locallibrary"])

    assert result.exit_code == 0
    assert "Added packageid:author.locallibrary to mods/10_libraries.list" in result.stdout
    assert "Local Library" in result.stdout
    assert (pack_root / "mods" / "10_libraries.list").read_text(encoding="utf-8") == (
        "@alias libraries\n"
        "\n"
        "# Libraries\n"
        "packageid:author.locallibrary  # Local Library\n"
    )


def test_add_accepts_explicit_local_reference(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "LocalRefPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    _initialize_pack_root(pack_root)
    write_about_xml(
        pack_root / "local" / "mods" / "MyFork",
        "author.myfork",
        "My Fork",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(app, ["add", "gameplay", "local:MyFork"])

    assert result.exit_code == 0
    assert "Added local:MyFork to mods/30_gameplay.list" in result.stdout
    assert "My Fork" in result.stdout
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "local:MyFork  # My Fork\n"
    )


def test_add_accepts_packageid_from_current_modpack_local_mods(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "LocalPackageIdPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    _initialize_pack_root(pack_root)
    write_about_xml(
        pack_root / "local" / "mods" / "PatchFork",
        "author.patchfork",
        "Patch Fork",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(app, ["add", "gameplay", "packageid:author.patchfork"])

    assert result.exit_code == 0
    assert "Added local:PatchFork to mods/30_gameplay.list" in result.stdout
    assert "Patch Fork" in result.stdout
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "local:PatchFork  # Patch Fork\n"
    )


def test_add_accepts_packageid_from_rimworld_data(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "PackageIdDataPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    rimworld_folder = tmp_path / "RimWorld"
    write_about_xml(
        rimworld_folder / "Data" / "BiotechLike",
        "ludeon.exampleexpansion",
        "Example Expansion",
    )
    monkeypatch.setenv("RIMWORLD_FOLDER", str(rimworld_folder))
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["add", "core", "ludeon.exampleexpansion"])

    assert result.exit_code == 0
    assert "Added packageid:ludeon.exampleexpansion to mods/00_core.list" in result.stdout
    assert "Example Expansion" in result.stdout
    assert (pack_root / "mods" / "00_core.list").read_text(encoding="utf-8") == (
        "@alias core\n"
        "\n"
        "# Core game\n"
        "packageid:ludeon.rimworld\n"
        "packageid:ludeon.exampleexpansion  # Example Expansion\n"
    )


def test_add_replaces_existing_definition_after_confirmation(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "ReplacePack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    _ = (pack_root / "mods" / "10_libraries.list").write_text(
        "@alias libraries\n\n# Libraries\nsteam:3685058533  # Old Location\n",
        encoding="utf-8",
    )

    calls: list[tuple[int, bool]] = []

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        calls.append((workshop_id, cleanup))
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Replaced Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        ["add", "gameplay", "steam:3685058533"],
        input="y\n",
    )

    assert result.exit_code == 0
    assert "already exists in mods/10_libraries.list" in result.stdout
    assert "Mod Metadata" in result.stdout
    assert "Replaced Mod" in result.stdout
    assert calls == [(3685058533, True)]
    assert (pack_root / "mods" / "10_libraries.list").read_text(encoding="utf-8") == (
        "@alias libraries\n\n# Libraries\n"
    )
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n"
        "\n"
        "# Gameplay\n"
        "steam:3685058533  # Replaced Mod\n"
    )


def test_add_aborts_when_duplicate_replacement_is_declined(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "DeclineReplacePack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    original_libraries = "@alias libraries\n\n# Libraries\nsteam:3685058533  # Existing Mod\n"
    _ = (pack_root / "mods" / "10_libraries.list").write_text(
        original_libraries,
        encoding="utf-8",
    )

    called = False

    def fake_resolve(workshop_id: int, *, _cleanup: bool = True) -> ModFolder:
        nonlocal called
        called = True
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Should Not Resolve")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        ["add", "gameplay", "steam:3685058533"],
        input="n\n",
    )

    assert result.exit_code == 1
    assert "Remove the old definition and add it" in result.stdout
    assert "Aborted." in result.stdout
    assert called is False
    assert (pack_root / "mods" / "10_libraries.list").read_text(encoding="utf-8") == (
        original_libraries
    )
    assert (pack_root / "mods" / "30_gameplay.list").read_text(encoding="utf-8") == (
        "@alias gameplay\n\n# Gameplay\n"
    )


def test_add_appends_resolved_workshop_mods_by_declared_path(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "PathPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        assert cleanup is False
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Path Target Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(
        app,
        ["add", "mods/20_ui.list", "steam:3685058533", "--no-cleanup"],
    )

    assert result.exit_code == 0
    assert "Added steam:3685058533 to mods/20_ui.list" in result.stdout
    assert "Mod Metadata" in result.stdout
    assert "Path Target Mod" in result.stdout
    assert (pack_root / "mods" / "20_ui.list").read_text(encoding="utf-8") == (
        "@alias ui\n"
        "\n"
        "# UI\n"
        "steam:3685058533  # Path Target Mod\n"
    )


def test_add_rejects_multiple_workshop_references(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "TooManyRefsPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    result = runner.invoke(
        app,
        ["add", "@libraries", "steam:2009463077", "steam:818773962"],
    )

    assert result.exit_code == 2


def test_add_creates_missing_alias_target_as_last_mod_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "CreateAliasPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        assert workshop_id == 2009463077
        assert cleanup is True
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Created Alias Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(app, ["add", "@content", "steam:2009463077"])

    assert result.exit_code == 0
    assert "Added steam:2009463077 to mods/109_content.list" in result.stdout
    assert load_pack_toml(pack_root / "pack.toml").layout.mod_files == (
        "mods/00_core.list",
        "mods/10_libraries.list",
        "mods/20_ui.list",
        "mods/30_gameplay.list",
        "mods/40_qol.list",
        "mods/50_overhaul.list",
        "mods/60_factions.list",
        "mods/70_combat.list",
        "mods/80_graphics.list",
        "mods/90_textures.list",
        "mods/99_patch.list",
        "mods/109_content.list",
    )
    assert (pack_root / "mods" / "109_content.list").read_text(encoding="utf-8") == (
        "@alias content\n"
        "\n"
        "# Content\n"
        "steam:2009463077  # Created Alias Mod\n"
    )


def test_add_creates_missing_declared_path_target(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "CreatePathPack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    def fake_resolve(workshop_id: int, *, cleanup: bool = True) -> ModFolder:
        assert workshop_id == 3685058533
        assert cleanup is True
        mod_root = tmp_path / "ResolvedMods" / str(workshop_id)
        write_about_xml(mod_root, f"author.{workshop_id}", "Created Path Mod")
        return load_mod_folder(mod_root)

    monkeypatch.setattr(cli_main, "resolve_workshop_by_id", fake_resolve)

    result = runner.invoke(app, ["add", "mods/40_content.list", "steam:3685058533"])

    assert result.exit_code == 0
    assert "Added steam:3685058533 to mods/40_content.list" in result.stdout
    assert load_pack_toml(pack_root / "pack.toml").layout.mod_files == (
        "mods/00_core.list",
        "mods/10_libraries.list",
        "mods/20_ui.list",
        "mods/30_gameplay.list",
        "mods/40_qol.list",
        "mods/50_overhaul.list",
        "mods/60_factions.list",
        "mods/70_combat.list",
        "mods/80_graphics.list",
        "mods/90_textures.list",
        "mods/99_patch.list",
        "mods/40_content.list",
    )
    assert (pack_root / "mods" / "40_content.list").read_text(encoding="utf-8") == (
        "@alias content\n"
        "\n"
        "# Content\n"
        "steam:3685058533  # Created Path Mod\n"
    )


def test_add_returns_error_for_non_workshop_reference(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "InvalidRefPack"
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["add", "@libraries", "packageid:author.missingmod"])

    assert result.exit_code == 1
    assert "could not find a mod for package ID" in result.stderr


def test_add_ordering_rule_appends_to_declared_ordering_rule_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "OrderingRulePack"
    _initialize_pack_root(pack_root)
    manifest = load_pack_toml(pack_root / "pack.toml")
    updated_manifest = with_added_ordering_rule_file(
        manifest,
        relative_path="rules/ordering/30_combat.toml",
    )
    _ = (pack_root / "pack.toml").write_text(
        render_pack_toml(updated_manifest),
        encoding="utf-8",
        newline="\n",
    )
    _ = (pack_root / "rules" / "ordering" / "30_combat.toml").parent.mkdir(
        parents=True,
        exist_ok=True,
    )
    _ = (pack_root / "rules" / "ordering" / "30_combat.toml").write_text(
        "# Combat ordering\n",
        encoding="utf-8",
        newline="\n",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(
        app,
        [
            "add-ordering-rule",
            "after",
            "packageid:my.ce.patch",
            "packageid:ceteam.combatextended",
            "--reason",
            "Compatibility patch loads after CE",
        ],
    )

    assert result.exit_code == 0
    assert "Added ordering rule to rules/ordering/30_combat.toml" in result.stdout
    assert (pack_root / "rules" / "ordering" / "30_combat.toml").read_text(
        encoding="utf-8"
    ) == (
        "# Combat ordering\n"
        "\n"
        "[[rules]]\n"
        'type = "after"\n'
        'mod = "packageid:my.ce.patch"\n'
        'target = "packageid:ceteam.combatextended"\n'
        'reason = "Compatibility patch loads after CE"\n'
    )
    assert load_pack_toml(pack_root / "pack.toml").paths.ordering_rule_files == (
        "rules/ordering/30_combat.toml",
    )


def test_add_ordering_rule_creates_default_ordering_rule_file_when_missing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "DefaultOrderingRulePack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    result = runner.invoke(
        app,
        [
            "add-ordering-rule",
            "before",
            "local:MyFork",
            "steam:2009463077",
        ],
    )

    assert result.exit_code == 0
    assert "Added ordering rule to rules/ordering/90_manual.toml" in result.stdout
    assert load_pack_toml(pack_root / "pack.toml").paths.ordering_rule_files == (
        "rules/ordering/90_manual.toml",
    )
    assert (pack_root / "rules" / "ordering" / "90_manual.toml").read_text(
        encoding="utf-8"
    ) == (
        "[[rules]]\n"
        'type = "before"\n'
        'mod = "local:MyFork"\n'
        'target = "steam:2009463077"\n'
    )


def test_add_ordering_rule_creates_missing_alias_named_rule_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "AliasOrderingRulePack"
    _initialize_pack_root(pack_root)
    manifest = load_pack_toml(pack_root / "pack.toml")
    updated_manifest = with_added_ordering_rule_file(
        manifest,
        relative_path="rules/ordering/30_combat.toml",
    )
    _ = (pack_root / "pack.toml").write_text(
        render_pack_toml(updated_manifest),
        encoding="utf-8",
        newline="\n",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(
        app,
        [
            "add-ordering-rule",
            "before",
            "packageid:my.ui.mod",
            "packageid:ceteam.combatextended",
            "--file",
            "experimental",
        ],
    )

    assert result.exit_code == 0
    assert "Added ordering rule to rules/ordering/40_experimental.toml" in result.stdout
    assert load_pack_toml(pack_root / "pack.toml").paths.ordering_rule_files == (
        "rules/ordering/30_combat.toml",
        "rules/ordering/40_experimental.toml",
    )
    assert (pack_root / "rules" / "ordering" / "40_experimental.toml").read_text(
        encoding="utf-8"
    ) == (
        "[[rules]]\n"
        'type = "before"\n'
        'mod = "packageid:my.ui.mod"\n'
        'target = "packageid:ceteam.combatextended"\n'
    )


def test_add_ordering_rule_registers_missing_explicit_rule_file(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    pack_root = tmp_path / "PathOrderingRulePack"
    _initialize_pack_root(pack_root)
    monkeypatch.chdir(pack_root)

    result = runner.invoke(
        app,
        [
            "add-ordering-rule",
            "after",
            "local:MyFork",
            "steam:2009463077",
            "--file",
            "rules/ordering/40_content.toml",
        ],
    )

    assert result.exit_code == 0
    assert "Added ordering rule to rules/ordering/40_content.toml" in result.stdout
    assert load_pack_toml(pack_root / "pack.toml").paths.ordering_rule_files == (
        "rules/ordering/40_content.toml",
    )
    assert (pack_root / "rules" / "ordering" / "40_content.toml").read_text(
        encoding="utf-8"
    ) == (
        "[[rules]]\n"
        'type = "after"\n'
        'mod = "local:MyFork"\n'
        'target = "steam:2009463077"\n'
    )


def test_load_mods_displays_metadata_for_mod_folder(tmp_path: Path) -> None:
    mod_path = tmp_path / "ExampleMod"
    about_path = mod_path / "About" / "About.xml"
    load_folders_path = mod_path / "LoadFolders.xml"
    about_path.parent.mkdir(parents=True)
    _ = about_path.write_text(
        """\
<ModMetaData>
  <packageId>author.example</packageId>
  <name>Example Mod</name>
  <author>Author One, Author Two</author>
  <description>Example description</description>
  <supportedVersions>
    <li>1.5</li>
    <li>1.6</li>
  </supportedVersions>
  <url>https://example.invalid/mod</url>
  <modVersion IgnoreIfNoMatchingField="True">2.0.0</modVersion>
</ModMetaData>
""",
        encoding="utf-8",
    )
    _ = load_folders_path.write_text(
        """\
<loadFolders>
  <v1.6>
    <li>Defs</li>
  </v1.6>
</loadFolders>
""",
        encoding="utf-8",
    )

    result = runner.invoke(app, ["load-mods", str(mod_path)])

    assert result.exit_code == 0
    assert "Mod Metadata" in result.stdout
    assert "Example Mod" in result.stdout
    assert "author.example" in result.stdout
    assert "Author One, Author Two" in result.stdout
    assert "1.5, 1.6" in result.stdout
    assert "2.0.0 (IgnoreIfNoMatchingField=True)" in result.stdout
    assert "present" in result.stdout


def test_load_mods_returns_error_for_invalid_path(tmp_path: Path) -> None:
    missing_path = tmp_path / "missing-mod"

    result = runner.invoke(app, ["load-mods", str(missing_path)])

    assert result.exit_code == 1
    assert "Error:" in result.stderr
    assert "not a directory" in result.stderr


def test_load_mods_loads_all_real_mods_and_skips_non_mods(tmp_path: Path) -> None:
    mods_root = tmp_path / "mods"
    write_about_xml(mods_root / "Alpha", "author.alpha", "Alpha Mod")
    write_about_xml(mods_root / "Beta", "author.beta", "Beta Mod")
    (mods_root / "notes").mkdir(parents=True)
    _ = (mods_root / "README.txt").write_text("ignored", encoding="utf-8")

    result = runner.invoke(app, ["load-mods", str(mods_root)])

    assert result.exit_code == 0
    assert "Scanning" in result.stdout
    assert "Load Summary" in result.stdout
    assert "Mods Loaded" in result.stdout
    assert "2" in result.stdout


def test_load_mods_fails_on_first_invalid_real_mod(tmp_path: Path) -> None:
    mods_root = tmp_path / "mods"
    write_about_xml(mods_root / "Alpha", "author.alpha", "Alpha Mod")
    broken_about_path = mods_root / "Broken" / "About" / "About.xml"
    broken_about_path.parent.mkdir(parents=True)
    _ = broken_about_path.write_text("<ModMetaData>", encoding="utf-8")

    result = runner.invoke(app, ["load-mods", str(mods_root)])

    assert result.exit_code == 1
    assert isinstance(result.exception, AboutXmlError)
    assert result.exception.__notes__ == [
        f"While loading mod: {(mods_root / 'Broken').resolve()}",
    ]


def test_load_mods_discovers_default_roots(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)

    steam_root = tmp_path / "Steam"
    rimworld_data_root = steam_root / "steamapps" / "common" / "RimWorld" / "Data"
    rimworld_mods_root = steam_root / "steamapps" / "common" / "RimWorld" / "Mods"
    write_about_xml(rimworld_mods_root / "LocalMod", "author.local", "Local Mod")
    write_about_xml(rimworld_data_root / "Core", "ludeon.rimworld", "Core")
    workshop_root = steam_root / "steamapps" / "workshop" / "content" / "294100"
    write_about_xml(workshop_root / "12345", "author.workshop", "Workshop Mod")
    libraryfolders_path = steam_root / "steamapps" / "libraryfolders.vdf"
    libraryfolders_path.parent.mkdir(parents=True, exist_ok=True)
    _ = libraryfolders_path.write_text('"libraryfolders"\n{\n}\n', encoding="utf-8")

    monkeypatch.setenv("STEAM_PATH", str(steam_root))
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["load-mods"])

    assert result.exit_code == 0
    assert "Mods Loaded" in result.stdout
    assert "3" in result.stdout


def test_load_mods_discovers_current_modpack_local_mods(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    pack_root = tmp_path / "LoadLocalPack"
    _initialize_pack_root(pack_root)
    write_about_xml(
        pack_root / "local" / "mods" / "LocalOnlyMod",
        "author.localonly",
        "Local Only Mod",
    )
    monkeypatch.chdir(pack_root)

    result = runner.invoke(app, ["load-mods"])

    assert result.exit_code == 0
    assert "Mods Loaded" in result.stdout
    assert "1" in result.stdout


def test_load_mods_skips_inaccessible_steam_library_root(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    steam_root = tmp_path / "Steam"
    workshop_root = steam_root / "steamapps" / "workshop" / "content" / "294100"
    write_about_xml(workshop_root / "12345", "author.workshop", "Workshop Mod")
    inaccessible_library_root = tmp_path / "BlockedLibrary"
    libraryfolders_path = steam_root / "steamapps" / "libraryfolders.vdf"
    libraryfolders_path.parent.mkdir(parents=True, exist_ok=True)
    _ = libraryfolders_path.write_text(
        (
            '"libraryfolders"\n'
            "{\n"
            f'\t"0"\n\t{{\n\t\t"path"\t\t"{_escape_vdf_path(str(steam_root))}"\n\t}}\n'
            f'\t"1"\n\t{{\n\t\t"path"\t\t"{_escape_vdf_path(str(inaccessible_library_root))}"\n\t}}\n'
            "}\n"
        ),
        encoding="utf-8",
    )

    original_is_dir = Path.is_dir

    def patched_is_dir(self: Path) -> bool:
        if self == inaccessible_library_root.resolve():
            msg = "Access is denied"
            raise PermissionError(5, msg, str(self))
        return original_is_dir(self)

    monkeypatch.setenv("STEAM_PATH", str(steam_root))
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)
    monkeypatch.setattr(Path, "is_dir", patched_is_dir)

    result = runner.invoke(app, ["load-mods"])

    assert result.exit_code == 0
    assert "Workshop Mod" not in result.stdout
    assert "Mods Loaded" in result.stdout
    assert "1" in result.stdout
    assert "Warning: skipping inaccessible Steam library root" in result.stderr
    assert inaccessible_library_root.name in result.stderr


def test_load_mods_uses_configured_roots(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    rimworld_folder = tmp_path / "ConfiguredRimWorld"
    workshop_root = tmp_path / "ConfiguredWorkshop"
    write_about_xml(rimworld_folder / "Mods" / "LocalMod", "author.local", "Local Mod")
    write_about_xml(rimworld_folder / "Data" / "Core", "ludeon.rimworld", "Core")
    write_about_xml(workshop_root / "12345", "author.workshop", "Workshop Mod")
    config_path.parent.mkdir(parents=True, exist_ok=True)
    _ = config_path.write_text(
        (
            f'rimworld_folder = "{rimworld_folder.as_posix()}"\n'
            f'steam_workshop_folder = "{workshop_root.as_posix()}"\n'
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.delenv("RIMWORLD_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_WORKSHOP_FOLDER", raising=False)
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["load-mods"])

    assert result.exit_code == 0
    assert "Mods Loaded" in result.stdout
    assert "3" in result.stdout


def test_load_mods_uses_environment_roots_when_config_missing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_path = tmp_path / "config" / "config.toml"
    rimworld_folder = tmp_path / "EnvRimWorld"
    workshop_root = tmp_path / "EnvWorkshop"
    write_about_xml(rimworld_folder / "Mods" / "LocalMod", "author.local", "Local Mod")
    write_about_xml(rimworld_folder / "Data" / "Core", "ludeon.rimworld", "Core")
    write_about_xml(workshop_root / "12345", "author.workshop", "Workshop Mod")
    monkeypatch.setattr(cli_main, "get_config_file_path", lambda: config_path)
    monkeypatch.setenv("RIMWORLD_FOLDER", str(rimworld_folder))
    monkeypatch.setenv("STEAM_WORKSHOP_FOLDER", str(workshop_root))
    monkeypatch.delenv("STEAM_PATH", raising=False)
    monkeypatch.delenv("PROGRAMFILES", raising=False)
    monkeypatch.delenv("PROGRAMFILES(X86)", raising=False)

    result = runner.invoke(app, ["load-mods"])

    assert result.exit_code == 0
    assert "Mods Loaded" in result.stdout
    assert "3" in result.stdout
def _initialize_pack_root(pack_root: Path) -> None:
    initialize_result = runner.invoke(app, ["init", str(pack_root)])
    assert initialize_result.exit_code == 0


def _escape_vdf_path(path: str) -> str:
    return path.replace("\\", "\\\\")
