from pathlib import Path
from typing import Annotated

from rich.console import Console
from rich.table import Table
import typer

from rimpack.cli.init_modpack import initialize_modpack_directory
from rimpack.config import (
    RimpackConfig,
    RimpackConfigError,
    get_config_file_path,
    load_config_if_exists,
    write_config,
)
from rimpack.core.mods.about_xml import AboutTextValue
from rimpack.core.mods.mod_folder import ModFolder, load_mod_folder, try_load_mod_folder
from rimpack.core.modpack import (
    add_modpack_file,
    Modpack,
    ModpackEntryAddition,
    ModpackError,
    ModpackEntryMatch,
    ModpackListReference,
    ModpackResolutionRoots,
    ModpackValidationResult,
    OrderingRule,
    OrderingRuleType,
    add_entries_to_modpack_file,
    append_ordering_rule,
    find_modpack_entry_matches,
    load_modpack,
    parse_ordering_reference,
    remove_modpack_entry_matches,
    replace_modpack_file,
    resolve_modpack_file_target,
    validate_modpack,
    with_added_ordering_rule_file,
    write_modpack,
)
from rimpack.launching import launch_modpack
from rimpack.steam_installation import (
    configured_or_discovered_rimworld_folder as _shared_configured_or_discovered_rimworld_folder,
    discover_rimworld_folder as _shared_discover_rimworld_folder,
    discover_steam_workshop_folder as _shared_discover_steam_workshop_folder,
    configured_or_discovered_steam_workshop_folder_with_callback as _shared_configured_or_discovered_steam_workshop_folder_with_callback,
)
from rimpack.steamworks import (
    SteamworksError,
    coerce_workshop_item_id,
    resolve_workshop_by_id,
    subscribe_to_workshop_item,
    unsubscribe_from_workshop_item,
)

app = typer.Typer(
    add_completion=False,
    no_args_is_help=False,
    help="Inspect RimWorld mods and initialize RimWorld modpack projects.",
)
console = Console()
error_console = Console(stderr=True)
resolve_app = typer.Typer(
    add_completion=False,
    help="Resolve mod references through supported backends.",
)
app.add_typer(resolve_app, name="resolve")

_DEFAULT_ORDERING_RULE_FILE = "rules/ordering/90_manual.toml"


@app.callback(invoke_without_command=True)
def callback(ctx: typer.Context) -> None:
    """Run the rimpack CLI."""
    if ctx.invoked_subcommand is not None:
        return
    _ensure_user_config_file()


def _ensure_user_config_file() -> None:
    config_path = get_config_file_path()
    if config_path.is_file():
        return
    should_create = typer.confirm(
        f"Create configuration file at {config_path}?",
        default=True,
    )
    if not should_create:
        return

    discovered_config = _discover_default_config()
    rimworld_folder = _confirm_detected_path(
        "RimWorld folder",
        discovered_config.rimworld_folder,
    )
    steam_workshop_folder = _confirm_detected_path(
        "Steam Workshop folder",
        discovered_config.steam_workshop_folder,
    )
    _ = write_config(
        RimpackConfig(
            rimworld_folder=rimworld_folder,
            steam_workshop_folder=steam_workshop_folder,
        ),
        path=config_path,
    )


def _confirm_detected_path(label: str, path: Path | None) -> Path | None:
    if path is None:
        return None
    should_use = typer.confirm(f"Use {label} at {path}?", default=True)
    if should_use:
        return path
    return None


@app.command(
    "load-mods",
    help=(
        "Load RimWorld mods from a specific folder or from the current modpack local "
        "mods folder plus configured or detected RimWorld Mods, Data, and Steam "
        "Workshop roots."
    ),
)
def load_mods(
    root: Annotated[
        Path | None,
        typer.Argument(
            help=(
                "Optional mod folder or directory containing mod folders. When omitted, "
                "rimpack scans the current modpack local mods folder plus configured "
                "or detected RimWorld Mods, Data, and Steam Workshop roots."
            ),
            show_default=False,
        ),
    ] = None,
) -> None:
    """Load mod metadata from one mod folder or scan a mod root."""
    if root is not None:
        resolved_root = root.resolve()
        if not resolved_root.is_dir():
            error_console.print(
                f"Error: mod folder path is not a directory: {resolved_root}"
            )
            raise typer.Exit(code=1)
        if _is_real_mod_directory(resolved_root):
            _show_mod_metadata(resolved_root)
            return

    mod_roots = [root] if root is not None else _discover_default_mod_roots()
    if not mod_roots:
        error_console.print("Error: could not locate any RimWorld mod roots")
        raise typer.Exit(code=1)

    loaded_mods = 0
    scanned_roots = 0
    for root in mod_roots:
        resolved_root = root.resolve()
        if not resolved_root.is_dir():
            continue
        scanned_roots += 1
        console.print(f"Scanning {resolved_root}")
        for mod_path in _iter_real_mod_directories(resolved_root):
            try:
                _ = load_mod_folder(mod_path)
            except Exception as exc:
                exc.add_note(f"While loading mod: {mod_path.resolve()}")
                raise
            loaded_mods += 1

    summary = Table(title="Load Summary")
    summary.add_column("Field")
    summary.add_column("Value")
    summary.add_row("Roots Scanned", str(scanned_roots))
    summary.add_row("Mods Loaded", str(loaded_mods))
    console.print(summary)


@app.command(
    "init",
    help=(
        "Create a new modpack project folder with the currently supported manifest "
        "and list-file defaults."
    ),
)
def init_modpack(
    directory: Annotated[
        Path | None,
        typer.Argument(
            help=(
                "Target modpack directory. When omitted, rimpack initializes the current "
                "working directory."
            ),
            show_default=False,
        ),
    ] = None,
) -> None:
    target_directory = (directory or Path.cwd()).resolve()
    config = _load_config_or_none()
    rimworld_folder = _configured_or_discovered_rimworld_folder(config)
    pack_toml_path = target_directory / "pack.toml"
    if pack_toml_path.exists():
        error_console.print(f"Error: pack.toml already exists: {pack_toml_path}")
        raise typer.Exit(code=1)
    initialize_modpack_directory(target_directory, rimworld_folder=rimworld_folder)
    console.print(f"Initialized modpack at {target_directory}")


@app.command(
    "add",
    help=(
        "Resolve a mod reference and append it to a declared mod list in the current "
        "modpack folder. Missing list targets are created and registered automatically."
    ),
)
def add_mods(
    target: Annotated[
        str,
        typer.Argument(
            help="Declared mod list path or @alias within the current modpack.",
            show_default=False,
        ),
    ],
    reference: Annotated[
        str,
        typer.Argument(
            help=(
                "Mod reference to add: local:<name>, steam:<id>, a Steam sharedfiles "
                "URL, or a package ID."
            ),
            show_default=False,
        ),
    ],
    cleanup: Annotated[
        bool,
        typer.Option(
            "--cleanup/--no-cleanup",
            help="Unsubscribe after resolving when the command had to subscribe temporarily.",
        ),
    ] = True,
) -> None:
    modpack_root = Path.cwd()
    try:
        modpack = load_modpack(modpack_root)
        modpack, file_index = _resolve_or_create_add_target(
            modpack,
            modpack_root=modpack_root,
            target=target,
        )
        requested_reference = _coerce_add_request_reference(reference)
        modpack, addition, resolved_mod = _prepare_addition(
            modpack,
            modpack_root=modpack_root,
            file_index=file_index,
            requested_reference=requested_reference,
            cleanup=cleanup,
        )

        selected_file = modpack.mod_files[file_index]
        updated_file = add_entries_to_modpack_file(selected_file, (addition,))
        updated_modpack = replace_modpack_file(
            modpack,
            file_index=file_index,
            modpack_file=updated_file,
        )
        write_modpack(modpack_root, updated_modpack)
    except (ModpackError, SteamworksError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc

    console.print(
        f"Added {addition.reference.kind}:{addition.reference.value} to {selected_file.path}"
    )
    _show_mod_details(resolved_mod)


@app.command(
    "add-ordering-rule",
    help=(
        "Append an ordering rule to a declared modpack ordering rule file in the "
        "current modpack folder. Missing rule targets are created and registered automatically."
    ),
)
def add_ordering_rule_command(
    rule_type: Annotated[
        OrderingRuleType,
        typer.Argument(
            help="Ordering constraint type: before or after.",
            show_default=False,
        ),
    ],
    mod: Annotated[
        str,
        typer.Argument(
            help="Mod reference being constrained: steam:<id>, packageid:<id>, or local:<name>.",
            show_default=False,
        ),
    ],
    target: Annotated[
        str,
        typer.Argument(
            help="Target mod reference that the rule is relative to.",
            show_default=False,
        ),
    ],
    reason: Annotated[
        str | None,
        typer.Option(
            "--reason",
            help="Optional human-readable explanation for why the rule exists.",
        ),
    ] = None,
    rule_file: Annotated[
        str | None,
        typer.Option(
            "--file",
            help=(
                "Pack-relative ordering rule file to append to. Defaults to the last "
                "declared ordering rule file or rules/ordering/90_manual.toml."
            ),
        ),
    ] = None,
) -> None:
    modpack_root = Path.cwd()
    try:
        modpack = load_modpack(modpack_root)
        ordering_rule = _coerce_ordering_rule(
            rule_type=rule_type,
            mod=mod,
            target=target,
            reason=reason,
        )
        relative_rule_file = _resolve_or_create_ordering_rule_file_target(
            modpack,
            rule_file=rule_file,
        )
        updated_manifest = with_added_ordering_rule_file(
            modpack.manifest,
            relative_path=relative_rule_file,
        )
        append_ordering_rule(modpack_root / Path(relative_rule_file), ordering_rule)
        write_modpack(
            modpack_root,
            Modpack(manifest=updated_manifest, mod_files=modpack.mod_files),
        )
    except (ModpackError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc

    console.print(
        f"Added ordering rule to {relative_rule_file}: "
        + f"{mod} {ordering_rule.rule_type} {target}"
    )


@app.command(
    "launch",
    help=(
        "Subscribe workshop mods from the current modpack, generate the final load "
        "order, build the symlinked runtime tree, and launch RimWorld."
    ),
)
def launch(
    clean: Annotated[
        bool,
        typer.Option(
            "--clean/--no-clean",
            help="After RimWorld exits, unsubscribe workshop items not declared by enabled steam entries in the modpack.",
        ),
    ] = True,
) -> None:
    modpack_root = Path.cwd()
    try:
        modpack = load_modpack(modpack_root)
        config = _load_config_or_none()
        result = launch_modpack(
            modpack,
            root_directory=modpack_root,
            config=_merged_runtime_config(config),
            clean=clean,
        )
    except (ModpackError, SteamworksError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc

    console.print(
        f"RimWorld exited from {result.launch_root} after running with {len(result.load_order)} active mods"
    )


@app.command(
    "validate",
    help=(
        "Validate the current modpack and report unresolved entries, missing "
        "dependencies, incompatible active mods, activation issues, and load-order cycles."
    ),
)
def validate() -> None:
    modpack_root = Path.cwd()
    try:
        modpack = load_modpack(modpack_root)
        config = _load_config_or_none()
        result = validate_modpack(
            modpack,
            root_directory=modpack_root,
            resolution_roots=_modpack_resolution_roots(
                modpack_root,
                modpack=modpack,
                config=config,
            ),
        )
    except ModpackError as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc

    _print_validation_result(result)
    if any(diagnostic.severity == "error" for diagnostic in result.diagnostics):
        raise typer.Exit(code=1)


@app.command(
    "subscribe",
    help="Subscribe to a Steam Workshop mod by workshop item ID or Steam Workshop URL.",
)
def subscribe(
    workshop_id: Annotated[
        str,
        typer.Argument(
            help="Steam Workshop item ID or sharedfiles URL to subscribe to.",
            show_default=False,
        ),
    ],
) -> None:
    try:
        published_file_id = coerce_workshop_item_id(workshop_id)
        _ = subscribe_to_workshop_item(published_file_id)
    except (SteamworksError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc
    console.print(f"Subscribed to workshop item {published_file_id}")


@app.command(
    "unsubscribe",
    help="Unsubscribe from a Steam Workshop mod by workshop item ID or Steam Workshop URL.",
)
def unsubscribe(
    workshop_id: Annotated[
        str,
        typer.Argument(
            help="Steam Workshop item ID or sharedfiles URL to unsubscribe from.",
            show_default=False,
        ),
    ],
) -> None:
    try:
        published_file_id = coerce_workshop_item_id(workshop_id)
        _ = unsubscribe_from_workshop_item(published_file_id)
    except (SteamworksError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc
    console.print(f"Unsubscribed from workshop item {published_file_id}")


@resolve_app.command(
    "steam",
    help="Resolve a Steam Workshop mod by workshop item ID or Steam Workshop URL.",
)
def resolve_steam(
    workshop_id: Annotated[
        str,
        typer.Argument(
            help="Steam Workshop item ID or sharedfiles URL to resolve.",
            show_default=False,
        ),
    ],
    cleanup: Annotated[
        bool,
        typer.Option(
            "--cleanup/--no-cleanup",
            help="Unsubscribe after resolving when the command had to subscribe temporarily.",
        ),
    ] = True,
) -> None:
    try:
        mod = resolve_workshop_by_id(workshop_id, cleanup=cleanup)
    except (SteamworksError, ValueError) as exc:
        error_console.print(f"Error: {exc}")
        raise typer.Exit(code=1) from exc
    _show_mod_details(mod)


def _show_mod_metadata(path: Path) -> None:
    mod = load_mod_folder(path)
    _show_mod_details(mod)


def _print_validation_result(result: ModpackValidationResult) -> None:
    error_count = 0
    warning_count = 0
    info_count = 0
    for diagnostic in result.diagnostics:
        if diagnostic.severity == "error":
            error_count += 1
        elif diagnostic.severity == "warning":
            warning_count += 1
        else:
            info_count += 1

    summary = Table(title="Validation Summary")
    summary.add_column("Field")
    summary.add_column("Value")
    summary.add_row("Active Mods", str(len(result.load_order)))
    summary.add_row("Errors", str(error_count))
    summary.add_row("Warnings", str(warning_count))
    summary.add_row("Info", str(info_count))
    console.print(summary)

    if not result.diagnostics:
        console.print("Validation passed.")
        return

    console.print("Validation Diagnostics")
    for diagnostic in result.diagnostics:
        location = "-"
        if diagnostic.source_file is not None and diagnostic.source_line is not None:
            location = f"{diagnostic.source_file}:{diagnostic.source_line}"
        elif diagnostic.source_file is not None:
            location = diagnostic.source_file
        console.print(
            f"[{diagnostic.severity.upper()}] {location} {diagnostic.message}",
        )


def _show_mod_details(mod: ModFolder) -> None:
    table = Table(title="Mod Metadata")
    table.add_column("Field")
    table.add_column("Value")
    table.add_row("Path", str(mod.path))
    table.add_row("Name", mod.about.name)
    table.add_row("Package ID", mod.about.package_id)
    table.add_row("Authors", ", ".join(mod.about.authors))
    table.add_row("Supported Versions", ", ".join(mod.about.supported_versions))
    table.add_row("Description", mod.about.description)
    table.add_row("URL", mod.about.url or "-")
    table.add_row("Mod Version", _format_optional_text_value(mod.about.mod_version))
    table.add_row(
        "LoadFolders.xml", "present" if mod.load_folders is not None else "missing"
    )
    if mod.load_folders is not None:
        table.add_row("Load Folder Versions", str(len(mod.load_folders.versions)))
    console.print(table)


def _format_optional_text_value(value: AboutTextValue | None) -> str:
    if value is None:
        return "-"
    if not value.ignore_if_no_matching_field:
        return value.value
    return f"{value.value} (IgnoreIfNoMatchingField=True)"


def _resolve_addition_reference(
    reference: ModpackListReference,
    *,
    modpack: Modpack,
    modpack_root: Path,
    cleanup: bool,
) -> tuple[ModpackEntryAddition, ModFolder]:
    if reference.kind == "local":
        return _resolve_local_addition(
            reference,
            modpack=modpack,
            modpack_root=modpack_root,
        )
    if reference.kind == "packageid":
        return _resolve_package_id_addition(
            reference,
            modpack=modpack,
            modpack_root=modpack_root,
        )

    published_file_id = int(reference.value)
    mod = resolve_workshop_by_id(published_file_id, cleanup=cleanup)
    return (
        ModpackEntryAddition(
            reference=reference,
            trailing_comment=(mod.about.name or None),
        ),
        mod,
    )


def _prepare_addition(
    modpack: Modpack,
    *,
    modpack_root: Path,
    file_index: int,
    requested_reference: ModpackListReference,
    cleanup: bool,
) -> tuple[Modpack, ModpackEntryAddition, ModFolder]:
    duplicate_reference = (
        requested_reference if requested_reference.kind == "steam" else None
    )
    if duplicate_reference is not None:
        modpack = _remove_duplicate_matches_if_confirmed(
            modpack,
            file_index=file_index,
            reference=duplicate_reference,
        )

    addition, resolved_mod = _resolve_addition_reference(
        requested_reference,
        modpack=modpack,
        modpack_root=modpack_root,
        cleanup=cleanup,
    )

    if duplicate_reference is None:
        modpack = _remove_duplicate_matches_if_confirmed(
            modpack,
            file_index=file_index,
            reference=addition.reference,
        )

    return modpack, addition, resolved_mod


def _remove_duplicate_matches_if_confirmed(
    modpack: Modpack,
    *,
    file_index: int,
    reference: ModpackListReference,
) -> Modpack:
    matches = find_modpack_entry_matches(modpack, reference=reference)
    if not matches:
        return modpack
    _confirm_duplicate_replacement(
        reference,
        matches=matches,
        target_path=modpack.mod_files[file_index].path,
    )
    return remove_modpack_entry_matches(modpack, matches)


def _coerce_add_request_reference(reference: str) -> ModpackListReference:
    normalized_reference = reference
    if reference.startswith("steam:"):
        normalized_reference = reference.removeprefix("steam:")
    elif reference.startswith("packageid:"):
        package_id = reference.removeprefix("packageid:").strip()
        return _coerce_package_id_reference(package_id)
    elif reference.startswith("local:"):
        return _coerce_local_reference(reference.removeprefix("local:"))
    elif "://" not in reference:
        return _coerce_package_id_reference(reference)

    try:
        published_file_id = coerce_workshop_item_id(normalized_reference)
    except ValueError as exc:
        raise ValueError(
            "add currently supports steam:<id>, Steam sharedfiles URLs, and package IDs",
        ) from exc

    return ModpackListReference(kind="steam", value=str(published_file_id))


def _coerce_local_reference(local_name: str) -> ModpackListReference:
    normalized_local_name = local_name.strip()
    if normalized_local_name == "" or any(
        character.isspace() for character in normalized_local_name
    ):
        raise ValueError("local mod name must be a single non-whitespace token")
    return ModpackListReference(kind="local", value=normalized_local_name)


def _coerce_package_id_reference(package_id: str) -> ModpackListReference:
    normalized_package_id = package_id.strip()
    if normalized_package_id == "" or any(character.isspace() for character in normalized_package_id):
        raise ValueError("package_id must be a single non-whitespace token")
    return ModpackListReference(kind="packageid", value=normalized_package_id)


def _resolve_or_create_add_target(
    modpack: Modpack,
    *,
    modpack_root: Path,
    target: str,
) -> tuple[Modpack, int]:
    try:
        return (
            modpack,
            resolve_modpack_file_target(
                modpack,
                root_directory=modpack_root,
                target=target,
            ),
        )
    except ModpackError as exc:
        if not str(exc).startswith("declared mod list alias not found:"):
            raise
        normalized_target = target.strip()
        if normalized_target == "":
            raise
        if normalized_target.startswith("@") or not normalized_target.endswith(".list"):
            alias = normalized_target.removeprefix("@")
            relative_path = _derive_new_modpack_list_path(modpack, alias=alias)
            return add_modpack_file(
                modpack,
                relative_path=relative_path,
                alias=alias,
            )
        return add_modpack_file(
            modpack,
            relative_path=_normalize_pack_relative_path(normalized_target),
            alias=_alias_from_modpack_list_path(normalized_target),
        )


def _coerce_ordering_rule(
    *,
    rule_type: OrderingRuleType,
    mod: str,
    target: str,
    reason: str | None,
) -> OrderingRule:
    normalized_reason = None if reason is None else reason.strip()
    if normalized_reason == "":
        raise ValueError("ordering rule reason must not be empty")

    return OrderingRule(
        rule_type=rule_type,
        mod=parse_ordering_reference(mod.strip()),
        target=parse_ordering_reference(target.strip()),
        reason=normalized_reason,
    )


def _resolve_ordering_rule_file_target(
    modpack: Modpack,
    *,
    rule_file: str | None,
) -> str:
    if rule_file is not None:
        return _normalize_pack_relative_path(rule_file)
    if modpack.manifest.paths.ordering_rule_files:
        return modpack.manifest.paths.ordering_rule_files[-1]
    return _DEFAULT_ORDERING_RULE_FILE


def _resolve_or_create_ordering_rule_file_target(
    modpack: Modpack,
    *,
    rule_file: str | None,
) -> str:
    if rule_file is None:
        return _resolve_ordering_rule_file_target(modpack, rule_file=None)

    normalized_target = rule_file.strip()
    if normalized_target == "":
        raise ValueError("ordering rule file path must not be empty")
    if normalized_target.endswith(".toml"):
        return _normalize_pack_relative_path(normalized_target)

    alias = normalized_target.removeprefix("@")
    return _derive_new_ordering_rule_file_path(modpack, alias=alias)


def _normalize_pack_relative_path(raw_path: str) -> str:
    normalized_path = raw_path.strip().replace("\\", "/")
    if normalized_path == "":
        raise ValueError("ordering rule file path must not be empty")
    if normalized_path.endswith("/"):
        raise ValueError("ordering rule file path must point to a file")
    return normalized_path


def _alias_from_modpack_list_path(relative_path: str) -> str:
    normalized_path = _normalize_pack_relative_path(relative_path)
    if not normalized_path.endswith(".list"):
        raise ValueError("mod list path must end with .list")
    stem = Path(normalized_path).stem
    alias = stem
    if "_" in stem and stem.split("_", maxsplit=1)[0].isdigit():
        alias = stem.split("_", maxsplit=1)[1]
    if alias == "":
        raise ValueError("could not derive mod list alias from path")
    return alias


def _derive_new_modpack_list_path(modpack: Modpack, *, alias: str) -> str:
    normalized_alias = alias.strip()
    if normalized_alias == "":
        raise ValueError("mod list alias must not be empty")
    if any(character.isspace() for character in normalized_alias):
        raise ValueError("mod list alias must be a single non-whitespace token")

    last_declared_path = modpack.manifest.layout.mod_files[-1]
    last_path = Path(last_declared_path)
    next_prefix = _next_modpack_list_numeric_prefix(last_path.stem)
    file_name = f"{next_prefix}_{normalized_alias}.list"
    parent = last_path.parent.as_posix()
    if parent in {"", "."}:
        return file_name
    return f"{parent}/{file_name}"


def _next_modpack_list_numeric_prefix(stem: str) -> str:
    prefix, _, _ = stem.partition("_")
    if prefix.isdigit():
        width = len(prefix)
        return f"{int(prefix) + 10:0{width}d}"
    return "99"


def _derive_new_ordering_rule_file_path(modpack: Modpack, *, alias: str) -> str:
    normalized_alias = alias.strip()
    if normalized_alias == "":
        raise ValueError("ordering rule file alias must not be empty")
    if any(character.isspace() for character in normalized_alias):
        raise ValueError("ordering rule file alias must be a single non-whitespace token")

    declared_rule_files = modpack.manifest.paths.ordering_rule_files
    if not declared_rule_files:
        return f"rules/ordering/90_{normalized_alias}.toml"

    last_declared_path = declared_rule_files[-1]
    last_path = Path(last_declared_path)
    next_prefix = _next_modpack_list_numeric_prefix(last_path.stem)
    file_name = f"{next_prefix}_{normalized_alias}.toml"
    parent = last_path.parent.as_posix()
    if parent in {"", "."}:
        return file_name
    return f"{parent}/{file_name}"


def _resolve_package_id_addition(
    reference: ModpackListReference,
    *,
    modpack: Modpack,
    modpack_root: Path,
) -> tuple[ModpackEntryAddition, ModFolder]:
    package_id = reference.value.casefold()
    config = _load_config_or_none()
    for source_kind, root in _package_id_search_roots(
        config,
        modpack=modpack,
        modpack_root=modpack_root,
    ):
        for mod_path in _iter_real_mod_directories(root):
            mod = _try_load_mod_for_add_resolution(mod_path)
            if mod is None or mod.about.package_id.casefold() != package_id:
                continue
            if source_kind == "local":
                resolved_reference = ModpackListReference(
                    kind="local",
                    value=mod.path.name,
                )
            elif source_kind == "workshop":
                workshop_id = _coerce_workshop_item_id_from_mod_path(mod.path)
                resolved_reference = ModpackListReference(kind="steam", value=workshop_id)
            else:
                resolved_reference = ModpackListReference(
                    kind="packageid",
                    value=mod.about.package_id,
                )
            return (
                ModpackEntryAddition(
                    reference=resolved_reference,
                    trailing_comment=(mod.about.name or None),
                ),
                mod,
            )

    raise ValueError(f"could not find a mod for package ID {reference.value}")


def _resolve_local_addition(
    reference: ModpackListReference,
    *,
    modpack: Modpack,
    modpack_root: Path,
) -> tuple[ModpackEntryAddition, ModFolder]:
    local_mods_root = _modpack_local_mods_root(modpack_root, modpack=modpack)
    if local_mods_root is None:
        raise ValueError("current modpack does not declare a local_mods_dir")
    mod = try_load_mod_folder(local_mods_root / reference.value)
    if mod is None:
        raise ValueError(
            f"could not find local mod {reference.value!r} in {local_mods_root}",
        )
    return (
        ModpackEntryAddition(
            reference=reference,
            trailing_comment=(mod.about.name or None),
        ),
        mod,
    )


def _package_id_search_roots(
    config: RimpackConfig | None,
    *,
    modpack: Modpack,
    modpack_root: Path,
) -> tuple[tuple[str, Path], ...]:
    roots: list[tuple[str, Path]] = []
    local_mods_root = _modpack_local_mods_root(modpack_root, modpack=modpack)
    if local_mods_root is not None:
        roots.append(("local", local_mods_root))

    steam_workshop_folder = _configured_or_discovered_steam_workshop_folder(config)
    if steam_workshop_folder is not None:
        roots.append(("workshop", steam_workshop_folder))

    rimworld_folder = _configured_or_discovered_rimworld_folder(config)
    if rimworld_folder is not None:
        roots.append(("packageid", rimworld_folder / "Mods"))
        roots.append(("packageid", rimworld_folder / "Data"))

    return tuple((source_kind, root) for source_kind, root in roots if root.is_dir())


def _modpack_local_mods_root(modpack_root: Path, *, modpack: Modpack) -> Path | None:
    local_mods_dir = modpack.manifest.paths.local_mods_dir
    if local_mods_dir is None:
        return None
    return (modpack_root / Path(local_mods_dir)).resolve()


def _modpack_resolution_roots(
    modpack_root: Path,
    *,
    modpack: Modpack,
    config: RimpackConfig | None,
) -> ModpackResolutionRoots:
    local_mods_dir = _modpack_local_mods_root(modpack_root, modpack=modpack)
    steam_workshop_folder = _configured_or_discovered_steam_workshop_folder(config)
    rimworld_folder = _configured_or_discovered_rimworld_folder(config)
    rimworld_mods_folder = None
    rimworld_data_folder = None
    if rimworld_folder is not None:
        rimworld_mods_folder = rimworld_folder / "Mods"
        rimworld_data_folder = rimworld_folder / "Data"
    return ModpackResolutionRoots(
        local_mods_dir=local_mods_dir,
        steam_workshop_folder=steam_workshop_folder,
        rimworld_mods_folder=rimworld_mods_folder,
        rimworld_data_folder=rimworld_data_folder,
    )


def _current_modpack_local_mods_root() -> Path | None:
    modpack_root = Path.cwd()
    try:
        modpack = load_modpack(modpack_root)
    except ModpackError:
        return None
    return _modpack_local_mods_root(modpack_root, modpack=modpack)


def _try_load_mod_for_add_resolution(mod_path: Path) -> ModFolder | None:
    return try_load_mod_folder(mod_path)


def _coerce_workshop_item_id_from_mod_path(mod_path: Path) -> str:
    folder_name = mod_path.name
    if not folder_name.isdigit():
        raise ValueError(f"workshop mod path does not end in a numeric ID: {mod_path}")
    return folder_name


def _confirm_duplicate_replacement(
    reference: ModpackListReference,
    *,
    matches: tuple[ModpackEntryMatch, ...],
    target_path: str,
) -> None:
    match_paths = ", ".join(match.path for match in matches)
    should_replace = typer.confirm(
        (
            f"{reference.kind}:{reference.value} already exists in {match_paths}. "
            f"Remove the old definition and add it to {target_path}?"
        ),
        default=True,
    )
    if not should_replace:
        console.print("Aborted.")
        raise typer.Exit(code=1)


def _discover_default_mod_roots() -> list[Path]:
    config = _load_config_or_none()
    roots: list[Path] = []
    seen: set[Path] = set()

    local_mods_root = _current_modpack_local_mods_root()
    if local_mods_root is not None:
        _append_existing_path(roots, seen, local_mods_root)

    rimworld_folder = _configured_or_discovered_rimworld_folder(config)
    if rimworld_folder is not None:
        _append_existing_path(roots, seen, rimworld_folder / "Mods")
        _append_existing_path(roots, seen, rimworld_folder / "Data")

    steam_workshop_folder = _configured_or_discovered_steam_workshop_folder(config)
    if steam_workshop_folder is not None:
        _append_existing_path(roots, seen, steam_workshop_folder)

    return roots


def _load_config_or_none() -> RimpackConfig | None:
    config_path = get_config_file_path()
    try:
        return load_config_if_exists(config_path)
    except RimpackConfigError as exc:
        error_console.print(f"Warning: ignoring invalid rimpack config ({exc})")
        return None


def _discover_default_config() -> RimpackConfig:
    return RimpackConfig(
        rimworld_folder=_shared_discover_rimworld_folder(),
        steam_workshop_folder=_shared_discover_steam_workshop_folder(),
    )


def _merged_runtime_config(config: RimpackConfig | None) -> RimpackConfig:
    rimworld_folder = _configured_or_discovered_rimworld_folder(config)
    steam_workshop_folder = _configured_or_discovered_steam_workshop_folder(config)
    return RimpackConfig(
        rimworld_folder=rimworld_folder,
        steam_workshop_folder=steam_workshop_folder,
    )


def _append_existing_path(paths: list[Path], seen: set[Path], path: Path) -> None:
    if not path.is_dir():
        return
    resolved_path = path.resolve()
    if resolved_path in seen:
        return
    seen.add(resolved_path)
    paths.append(resolved_path)


def _iter_real_mod_directories(root: Path) -> tuple[Path, ...]:
    mod_directories: list[Path] = []
    for child in sorted(root.iterdir(), key=lambda path: path.name.casefold()):
        if _is_real_mod_directory(child):
            mod_directories.append(child)
    return tuple(mod_directories)


def _is_real_mod_directory(path: Path) -> bool:
    return path.is_dir() and (path / "About" / "About.xml").is_file()


def _configured_or_discovered_rimworld_folder(
    config: RimpackConfig | None,
) -> Path | None:
    return _shared_configured_or_discovered_rimworld_folder(config)


def _configured_or_discovered_steam_workshop_folder(
    config: RimpackConfig | None,
) -> Path | None:
    def on_inaccessible_root(path: Path, exc: OSError) -> None:
        error_console.print(
            f"Warning: skipping inaccessible Steam library root: {path} ({exc})"
        )
    return _shared_configured_or_discovered_steam_workshop_folder_with_callback(
        config,
        on_inaccessible_root=on_inaccessible_root,
    )


def main() -> None:
    app(standalone_mode=False)
