from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, replace
import heapq
from pathlib import Path
from typing import Literal

from rimpack.core.mods.about_xml import (
    AboutDependency,
    AboutModMetadata,
    VersionedDependencyList,
    VersionedPackageIdList,
)
from rimpack.core.mods.mod_folder import ModFolder, load_mod_folder, try_load_mod_folder

from .activation_toml import (
    ActivationReference,
    ActivationRule,
    load_activation_toml,
)
from .list_file import (
    AliasLine,
    BlankLine,
    CommentLine,
    EntryLine,
    ModpackListDocument,
    ModpackListLine,
    ModpackListReference,
    load_modpack_list,
    new_modpack_list_document,
    render_modpack_list,
)
from .ordering_toml import OrderingReference, OrderingRule, load_ordering_toml
from .pack_toml import (
    PackTomlManifest,
    load_pack_toml,
    render_pack_toml,
    with_added_mod_file,
)


class ModpackError(ValueError):
    """Raised when a modpack project cannot be loaded or written."""


@dataclass(frozen=True)
class ModpackFile:
    path: str
    document: ModpackListDocument


@dataclass(frozen=True)
class ModpackEntryAddition:
    reference: ModpackListReference
    trailing_comment: str | None = None


@dataclass(frozen=True)
class ModpackEntryMatch:
    file_index: int
    line_index: int
    path: str


@dataclass(frozen=True)
class Modpack:
    manifest: PackTomlManifest
    mod_files: tuple[ModpackFile, ...]


@dataclass(frozen=True)
class ModpackResolutionRoots:
    local_mods_dir: Path | None = None
    steam_workshop_folder: Path | None = None
    rimworld_mods_folder: Path | None = None
    rimworld_data_folder: Path | None = None
    steam_cleanup: bool = True


@dataclass(frozen=True)
class ModpackLoadOrderEntry:
    path: Path
    source_file: str
    source_line: int
    requested: str
    package_id: str
    name: str


ValidationSeverity = Literal["error", "warning", "info"]


@dataclass(frozen=True)
class ModpackValidationDiagnostic:
    severity: ValidationSeverity
    message: str
    source_file: str | None = None
    source_line: int | None = None


@dataclass(frozen=True)
class ModpackValidationResult:
    load_order: tuple[ModpackLoadOrderEntry, ...]
    diagnostics: tuple[ModpackValidationDiagnostic, ...]


@dataclass(frozen=True)
class _MetadataOrderingRules:
    before: tuple[str, ...]
    after: tuple[str, ...]


def load_modpack(root_directory: Path) -> Modpack:
    resolved_root = root_directory.resolve()
    if not resolved_root.is_dir():
        raise ModpackError(f"modpack directory does not exist: {resolved_root}")

    manifest_path = resolved_root / "pack.toml"
    if not manifest_path.is_file():
        raise ModpackError(f"pack.toml does not exist: {manifest_path}")

    manifest = load_pack_toml(manifest_path)
    mod_files = tuple(
        ModpackFile(
            path=relative_path,
            document=_load_declared_mod_file(resolved_root, relative_path),
        )
        for relative_path in manifest.layout.mod_files
    )
    return Modpack(manifest=manifest, mod_files=mod_files)


def write_modpack(root_directory: Path, modpack: Modpack) -> None:
    declared_paths = modpack.manifest.layout.mod_files
    provided_paths = tuple(mod_file.path for mod_file in modpack.mod_files)
    if declared_paths != provided_paths:
        raise ModpackError(
            "modpack file set must exactly match manifest.layout.mod_files order",
        )

    resolved_root = root_directory.resolve()
    resolved_root.mkdir(parents=True, exist_ok=True)
    _write_text_file(
        resolved_root / "pack.toml",
        render_pack_toml(modpack.manifest),
    )
    for mod_file in modpack.mod_files:
        _write_text_file(
            resolved_root / Path(mod_file.path),
            render_modpack_list(mod_file.document),
        )


def generate_modpack_load_order(
    modpack: Modpack,
    *,
    root_directory: Path,
    resolution_roots: ModpackResolutionRoots,
) -> tuple[ModpackLoadOrderEntry, ...]:
    """Generate final load order for enabled, resolved entries in a modpack.

    Note for maintainers: this function is the central ordering boundary and
    must be updated whenever mod metadata ordering semantics, authored ordering
    rules, activation semantics, or stable-sort behavior changes.
    """

    activation_rules = _load_modpack_activation_rules(modpack, root_directory=root_directory)
    ordering_rules = _load_modpack_ordering_rules(modpack, root_directory=root_directory)
    return _generate_load_order_entries(
        modpack,
        activation_rules=activation_rules,
        ordering_rules=ordering_rules,
        resolution_roots=resolution_roots,
    )


def validate_modpack(
    modpack: Modpack,
    *,
    root_directory: Path,
    resolution_roots: ModpackResolutionRoots,
) -> ModpackValidationResult:
    activation_rules = _load_modpack_activation_rules(modpack, root_directory=root_directory)
    ordering_rules = _load_modpack_ordering_rules(modpack, root_directory=root_directory)
    return _validate_modpack_entries(
        modpack,
        activation_rules=activation_rules,
        ordering_rules=ordering_rules,
        resolution_roots=resolution_roots,
    )


def resolve_modpack_file_target(
    modpack: Modpack,
    *,
    root_directory: Path,
    target: str,
) -> int:
    if target.startswith("@"):
        return _resolve_modpack_file_alias_target(modpack, target)
    path_target = _find_modpack_file_path_target(
        modpack,
        root_directory=root_directory,
        target=target,
    )
    if path_target is not None:
        return path_target
    return _resolve_modpack_file_alias_target(modpack, target)


def add_entries_to_modpack_file(
    modpack_file: ModpackFile,
    additions: tuple[ModpackEntryAddition, ...],
) -> ModpackFile:
    if not additions:
        return modpack_file

    line_ending = _preferred_line_ending(modpack_file.document)
    lines = list(modpack_file.document.lines)
    if lines and _line_ending_of(lines[-1]) == "":
        lines[-1] = replace(lines[-1], line_ending=line_ending)

    for addition in additions:
        lines.append(
            _new_entry_line(
                reference=addition.reference,
                trailing_comment=addition.trailing_comment,
                line_ending=line_ending,
            )
        )

    return ModpackFile(
        path=modpack_file.path,
        document=ModpackListDocument(
            lines=tuple(lines),
            source_text="",
        ),
    )


def find_modpack_entry_matches(
    modpack: Modpack,
    *,
    reference: ModpackListReference,
) -> tuple[ModpackEntryMatch, ...]:
    matches: list[ModpackEntryMatch] = []
    for file_index, modpack_file in enumerate(modpack.mod_files):
        for line_index, line in enumerate(modpack_file.document.lines):
            if isinstance(line, EntryLine) and line.reference == reference:
                matches.append(
                    ModpackEntryMatch(
                        file_index=file_index,
                        line_index=line_index,
                        path=modpack_file.path,
                    )
                )
    return tuple(matches)


def remove_modpack_entry_matches(
    modpack: Modpack,
    matches: tuple[ModpackEntryMatch, ...],
) -> Modpack:
    if not matches:
        return modpack

    line_indexes_by_file: dict[int, set[int]] = {}
    for match in matches:
        line_indexes_by_file.setdefault(match.file_index, set()).add(match.line_index)

    updated_files: list[ModpackFile] = []
    for file_index, modpack_file in enumerate(modpack.mod_files):
        removed_indexes = line_indexes_by_file.get(file_index)
        if not removed_indexes:
            updated_files.append(modpack_file)
            continue

        updated_lines = tuple(
            line
            for line_index, line in enumerate(modpack_file.document.lines)
            if line_index not in removed_indexes
        )
        updated_files.append(
            ModpackFile(
                path=modpack_file.path,
                document=ModpackListDocument(lines=updated_lines, source_text=""),
            )
        )

    return Modpack(manifest=modpack.manifest, mod_files=tuple(updated_files))


def replace_modpack_file(
    modpack: Modpack,
    *,
    file_index: int,
    modpack_file: ModpackFile,
) -> Modpack:
    updated_files = list(modpack.mod_files)
    updated_files[file_index] = modpack_file
    return Modpack(
        manifest=modpack.manifest,
        mod_files=tuple(updated_files),
    )


def add_modpack_file(
    modpack: Modpack,
    *,
    relative_path: str,
    alias: str,
) -> tuple[Modpack, int]:
    normalized_alias = _normalize_new_modpack_alias(alias)
    normalized_path = relative_path.replace("\\", "/")
    for index, modpack_file in enumerate(modpack.mod_files):
        if modpack_file.path == normalized_path:
            return modpack, index
        if _document_alias(modpack_file.document) == normalized_alias:
            return modpack, index

    updated_manifest = with_added_mod_file(
        modpack.manifest,
        relative_path=normalized_path,
    )
    updated_files = list(modpack.mod_files)
    updated_files.append(
        ModpackFile(
            path=normalized_path,
            document=_new_modpack_file_document(alias=normalized_alias),
        )
    )
    return (
        Modpack(
            manifest=updated_manifest,
            mod_files=tuple(updated_files),
        ),
        len(updated_files) - 1,
    )


def _load_declared_mod_file(
    root_directory: Path,
    relative_path: str,
) -> ModpackListDocument:
    path = root_directory / Path(relative_path)
    if not path.is_file():
        raise ModpackError(f"declared mod list file does not exist: {path}")
    return load_modpack_list(path)


def _write_text_file(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    _ = path.write_text(content, encoding="utf-8", newline="\n")


def _resolve_modpack_file_alias_target(modpack: Modpack, target: str) -> int:
    alias = target.removeprefix("@")
    if alias == "":
        raise ModpackError("mod list alias must not be empty")

    matching_indexes = [
        index
        for index, modpack_file in enumerate(modpack.mod_files)
        if _document_alias(modpack_file.document) == alias
    ]
    if not matching_indexes:
        raise ModpackError(f"declared mod list alias not found: @{alias}")
    if len(matching_indexes) > 1:
        raise ModpackError(f"declared mod list alias is ambiguous: @{alias}")
    return matching_indexes[0]


def _find_modpack_file_path_target(
    modpack: Modpack,
    *,
    root_directory: Path,
    target: str,
) -> int | None:
    declared_paths = modpack.manifest.layout.mod_files
    if target in declared_paths:
        return declared_paths.index(target)

    resolved_target = (root_directory / Path(target)).resolve()
    for index, declared_path in enumerate(declared_paths):
        if (root_directory / Path(declared_path)).resolve() == resolved_target:
            return index
    return None


def _document_alias(document: ModpackListDocument) -> str | None:
    for line in document.lines:
        if isinstance(line, AliasLine):
            return line.alias
    return None


def _preferred_line_ending(document: ModpackListDocument) -> str:
    for line in document.lines:
        line_ending = _line_ending_of(line)
        if line_ending != "":
            return line_ending
    return "\n"


def _line_ending_of(line: ModpackListLine) -> str:
    return line.line_ending


def _new_entry_line(
    *,
    reference: ModpackListReference,
    trailing_comment: str | None,
    line_ending: str,
) -> EntryLine:
    raw_reference_text = f"{reference.kind}:{reference.value}"
    spacing_before_comment = "  " if trailing_comment is not None else ""
    trailing_comment_text = (
        f" {trailing_comment}" if trailing_comment is not None else None
    )
    raw_text = raw_reference_text
    if trailing_comment_text is not None:
        raw_text = f"{raw_reference_text}{spacing_before_comment}#{trailing_comment_text}"

    return EntryLine(
        raw_text=raw_text,
        leading_whitespace="",
        disabled=False,
        whitespace_after_disabled="",
        raw_reference_text=raw_reference_text,
        reference=reference,
        spacing_before_comment=spacing_before_comment,
        trailing_comment_text=trailing_comment_text,
        line_ending=line_ending,
    )


def _new_modpack_file_document(*, alias: str) -> ModpackListDocument:
    title = alias.replace("_", " ").replace("-", " ").title()
    return new_modpack_list_document(
        (
            AliasLine(
                raw_text=f"@alias {alias}",
                leading_whitespace="",
                whitespace_after_directive=" ",
                alias=alias,
                trailing_whitespace="",
                line_ending="\n",
            ),
            BlankLine(raw_text="", line_ending="\n"),
            CommentLine(
                raw_text=f"# {title}",
                leading_whitespace="",
                comment_text=f" {title}",
                line_ending="\n",
            ),
        )
    )


def _normalize_new_modpack_alias(alias: str) -> str:
    normalized_alias = alias.strip()
    if normalized_alias.startswith("@"):
        normalized_alias = normalized_alias.removeprefix("@")
    if normalized_alias == "" or any(character.isspace() for character in normalized_alias):
        raise ModpackError("mod list alias must be a single non-whitespace token")
    return normalized_alias


def _load_modpack_ordering_rules(
    modpack: Modpack,
    *,
    root_directory: Path,
) -> tuple[OrderingRule, ...]:
    rules: list[OrderingRule] = []
    for relative_path in modpack.manifest.paths.ordering_rule_files:
        document = load_ordering_toml(root_directory / Path(relative_path))
        rules.extend(rule for rule in document.rules if rule.enabled)
    return tuple(rules)


def _load_modpack_activation_rules(
    modpack: Modpack,
    *,
    root_directory: Path,
) -> tuple[ActivationRule, ...]:
    rules: list[ActivationRule] = []
    for relative_path in modpack.manifest.paths.activation_rule_files:
        document = load_activation_toml(root_directory / Path(relative_path))
        rules.extend(rule for rule in document.rules if rule.enabled)
    return tuple(rules)


@dataclass(frozen=True)
class _LoadOrderNode:
    base_index: int
    path: Path
    source_file: str
    source_line: int
    requested: str
    package_id: str
    name: str
    requested_reference: ModpackListReference
    activation_rule: ActivationRule | None
    ordering_rules: tuple[str, ...]
    ordering_after: tuple[str, ...]


@dataclass(frozen=True)
class _CollectedNodesResult:
    nodes: tuple[_LoadOrderNode, ...]
    diagnostics: tuple[ModpackValidationDiagnostic, ...]


@dataclass(frozen=True)
class _ActivationRuleIndex:
    rules_by_reference: dict[tuple[str, str], ActivationRule]
    diagnostics: tuple[ModpackValidationDiagnostic, ...]


@dataclass(frozen=True)
class _TopologicalSortResult:
    ordered_nodes: tuple[_LoadOrderNode, ...]
    cycle_node_indexes: tuple[int, ...] = ()


def _generate_load_order_entries(
    modpack: Modpack,
    *,
    activation_rules: tuple[ActivationRule, ...],
    ordering_rules: tuple[OrderingRule, ...],
    resolution_roots: ModpackResolutionRoots,
) -> tuple[ModpackLoadOrderEntry, ...]:
    nodes = _collect_load_order_nodes(
        modpack,
        activation_rules=activation_rules,
        resolution_roots=resolution_roots,
    )
    if not nodes:
        return ()

    indexes_by_package_id = _build_indexes_by_package_id(nodes)
    edges: dict[int, set[int]] = {node.base_index: set() for node in nodes}

    for node in nodes:
        _add_metadata_ordering_edges(
            node,
            indexes_by_package_id=indexes_by_package_id,
            edges=edges,
        )

    for rule in ordering_rules:
        _add_authored_rule_edges(
            rule,
            nodes=nodes,
            indexes_by_package_id=indexes_by_package_id,
            edges=edges,
        )

    ordered_nodes = _stable_topological_sort(nodes, edges=edges)
    return tuple(
        ModpackLoadOrderEntry(
            path=node.path,
            source_file=node.source_file,
            source_line=node.source_line,
            requested=node.requested,
            package_id=node.package_id,
            name=node.name,
        )
        for node in ordered_nodes
    )


def _validate_modpack_entries(
    modpack: Modpack,
    *,
    activation_rules: tuple[ActivationRule, ...],
    ordering_rules: tuple[OrderingRule, ...],
    resolution_roots: ModpackResolutionRoots,
) -> ModpackValidationResult:
    collected_nodes = _collect_load_order_nodes_for_validation(
        modpack,
        activation_rules=activation_rules,
        resolution_roots=resolution_roots,
    )
    nodes = collected_nodes.nodes
    diagnostics = list(collected_nodes.diagnostics)
    if not nodes:
        return ModpackValidationResult(load_order=(), diagnostics=tuple(diagnostics))

    diagnostics.extend(_duplicate_package_id_diagnostics(nodes))

    indexes_by_package_id = _build_indexes_by_package_id(nodes)
    edges: dict[int, set[int]] = {node.base_index: set() for node in nodes}

    for node in nodes:
        _add_metadata_ordering_edges(
            node,
            indexes_by_package_id=indexes_by_package_id,
            edges=edges,
        )

    for rule in ordering_rules:
        diagnostics.extend(
            _authored_rule_reference_diagnostics(
                rule,
                nodes=nodes,
                indexes_by_package_id=indexes_by_package_id,
            )
        )
        _add_authored_rule_edges(
            rule,
            nodes=nodes,
            indexes_by_package_id=indexes_by_package_id,
            edges=edges,
        )

    diagnostics.extend(_missing_dependency_diagnostics(nodes, rimworld_version=modpack.manifest.pack.rimworld_version))
    diagnostics.extend(_incompatibility_diagnostics(nodes, rimworld_version=modpack.manifest.pack.rimworld_version))

    ordered_nodes = _topological_sort(nodes, edges=edges)
    if ordered_nodes.cycle_node_indexes:
        cycle_package_ids = ", ".join(
            sorted(
                {
                    _node_by_index(nodes, node_index).package_id
                    for node_index in ordered_nodes.cycle_node_indexes
                },
                key=str.casefold,
            )
        )
        diagnostics.append(
            ModpackValidationDiagnostic(
                severity="error",
                message=f"load-order cycle detected among active mods: {cycle_package_ids}",
            )
        )
        return ModpackValidationResult(load_order=(), diagnostics=tuple(diagnostics))

    return ModpackValidationResult(
        load_order=tuple(
            ModpackLoadOrderEntry(
                path=node.path,
                source_file=node.source_file,
                source_line=node.source_line,
                requested=node.requested,
                package_id=node.package_id,
                name=node.name,
            )
            for node in ordered_nodes.ordered_nodes
        ),
        diagnostics=tuple(diagnostics),
    )


def _collect_load_order_nodes(
    modpack: Modpack,
    *,
    activation_rules: tuple[ActivationRule, ...],
    resolution_roots: ModpackResolutionRoots,
) -> tuple[_LoadOrderNode, ...]:
    activation_rule_by_reference = _activation_rules_by_reference(activation_rules).rules_by_reference
    nodes: list[_LoadOrderNode] = []
    for modpack_file in modpack.mod_files:
        for source_line, line in enumerate(modpack_file.document.lines, start=1):
            if not isinstance(line, EntryLine) or line.disabled:
                continue

            mod = _resolve_entry_mod_folder(
                line,
                resolution_roots=resolution_roots,
                source_file=modpack_file.path,
                source_line=source_line,
            )
            metadata_rules = _ordering_rules_from_metadata(
                mod.about,
                rimworld_version=modpack.manifest.pack.rimworld_version,
            )
            nodes.append(
                _LoadOrderNode(
                    base_index=len(nodes),
                    path=mod.path,
                    source_file=modpack_file.path,
                    source_line=source_line,
                    requested=line.raw_reference_text,
                    package_id=mod.about.package_id,
                    name=mod.about.name,
                    requested_reference=line.reference,
                    activation_rule=_activation_rule_for_entry(
                        line.reference,
                        package_id=mod.about.package_id,
                        activation_rule_by_reference=activation_rule_by_reference,
                    ),
                    ordering_rules=metadata_rules.before,
                    ordering_after=metadata_rules.after,
                )
            )
    return _apply_activation_rules(tuple(nodes))


def _collect_load_order_nodes_for_validation(
    modpack: Modpack,
    *,
    activation_rules: tuple[ActivationRule, ...],
    resolution_roots: ModpackResolutionRoots,
) -> _CollectedNodesResult:
    activation_rule_index = _activation_rules_by_reference(activation_rules)
    diagnostics = list(activation_rule_index.diagnostics)
    nodes: list[_LoadOrderNode] = []
    for modpack_file in modpack.mod_files:
        for source_line, line in enumerate(modpack_file.document.lines, start=1):
            if not isinstance(line, EntryLine) or line.disabled:
                continue

            mod = _try_resolve_entry_mod_folder(
                line,
                resolution_roots=resolution_roots,
            )
            if mod is None:
                diagnostics.append(
                    ModpackValidationDiagnostic(
                        severity="error",
                        message=f"enabled mod entry is not available locally: {line.raw_reference_text}",
                        source_file=modpack_file.path,
                        source_line=source_line,
                    )
                )
                continue

            metadata_rules = _ordering_rules_from_metadata(
                mod.about,
                rimworld_version=modpack.manifest.pack.rimworld_version,
            )
            nodes.append(
                _LoadOrderNode(
                    base_index=len(nodes),
                    path=mod.path,
                    source_file=modpack_file.path,
                    source_line=source_line,
                    requested=line.raw_reference_text,
                    package_id=mod.about.package_id,
                    name=mod.about.name,
                    requested_reference=line.reference,
                    activation_rule=_activation_rule_for_entry(
                        line.reference,
                        package_id=mod.about.package_id,
                        activation_rule_by_reference=activation_rule_index.rules_by_reference,
                    ),
                    ordering_rules=metadata_rules.before,
                    ordering_after=metadata_rules.after,
                )
            )

    active_nodes, activation_diagnostics = _apply_activation_rules_for_validation(tuple(nodes))
    diagnostics.extend(activation_diagnostics)
    diagnostics.extend(_unused_activation_rule_diagnostics(active_nodes, activation_rules))
    return _CollectedNodesResult(nodes=active_nodes, diagnostics=tuple(diagnostics))


def _build_indexes_by_package_id(
    nodes: tuple[_LoadOrderNode, ...],
) -> dict[str, tuple[int, ...]]:
    indexes: dict[str, list[int]] = defaultdict(list)
    for node in nodes:
        indexes[node.package_id.casefold()].append(node.base_index)
    return {
        package_id: tuple(sorted(node_indexes))
        for package_id, node_indexes in indexes.items()
    }


def _activation_rules_by_reference(
    rules: tuple[ActivationRule, ...],
) -> _ActivationRuleIndex:
    rules_by_reference: dict[tuple[str, str], ActivationRule] = {}
    diagnostics: list[ModpackValidationDiagnostic] = []
    for rule in rules:
        key = (rule.mod.kind, rule.mod.value.casefold())
        if key in rules_by_reference:
            diagnostics.append(
                ModpackValidationDiagnostic(
                    severity="error",
                    message=(
                        "duplicate activation rule for mod reference: "
                        f"{rule.mod.kind}:{rule.mod.value}"
                    ),
                )
            )
            continue
        rules_by_reference[key] = rule
    return _ActivationRuleIndex(
        rules_by_reference=rules_by_reference,
        diagnostics=tuple(diagnostics),
    )


def _activation_rule_for_entry(
    reference: ModpackListReference,
    *,
    package_id: str,
    activation_rule_by_reference: dict[tuple[str, str], ActivationRule],
) -> ActivationRule | None:
    direct_key = (reference.kind, reference.value.casefold())
    package_key = ("packageid", package_id.casefold())
    direct_rule = activation_rule_by_reference.get(direct_key)
    package_rule = activation_rule_by_reference.get(package_key)

    if (
        direct_rule is not None
        and package_rule is not None
        and direct_rule != package_rule
    ):
        message = (
            f"duplicate activation rule for resolved mod: {reference.kind}:"
            + f"{reference.value} / packageid:{package_id}"
        )
        raise ModpackError(message)

    if direct_rule is not None:
        return direct_rule
    return package_rule


def _apply_activation_rules(
    nodes: tuple[_LoadOrderNode, ...],
) -> tuple[_LoadOrderNode, ...]:
    if not nodes:
        return ()

    nodes_by_index = {node.base_index: node for node in nodes}
    active_indexes = {node.base_index for node in nodes}

    changed = True
    while changed:
        changed = False
        for node in nodes:
            if node.base_index not in active_indexes or node.activation_rule is None:
                continue
            if _activation_rule_passes(
                node.activation_rule,
                nodes_by_index=nodes_by_index,
                active_indexes=active_indexes,
            ):
                continue
            active_indexes.remove(node.base_index)
            changed = True

    return tuple(node for node in nodes if node.base_index in active_indexes)


def _apply_activation_rules_for_validation(
    nodes: tuple[_LoadOrderNode, ...],
) -> tuple[tuple[_LoadOrderNode, ...], tuple[ModpackValidationDiagnostic, ...]]:
    if not nodes:
        return (), ()

    nodes_by_index = {node.base_index: node for node in nodes}
    active_indexes = {node.base_index for node in nodes}
    diagnostics: list[ModpackValidationDiagnostic] = []

    changed = True
    while changed:
        changed = False
        for node in nodes:
            if node.base_index not in active_indexes or node.activation_rule is None:
                continue
            activation_issue = _activation_rule_issue(
                node,
                nodes_by_index=nodes_by_index,
                active_indexes=active_indexes,
            )
            if activation_issue is None:
                continue
            active_indexes.remove(node.base_index)
            diagnostics.append(activation_issue)
            changed = True

    return (
        tuple(node for node in nodes if node.base_index in active_indexes),
        tuple(diagnostics),
    )


def _activation_rule_passes(
    rule: ActivationRule,
    *,
    nodes_by_index: dict[int, _LoadOrderNode],
    active_indexes: set[int],
) -> bool:
    if any(
        not _activation_reference_is_active(
            reference,
            nodes_by_index=nodes_by_index,
            active_indexes=active_indexes,
        )
        for reference in rule.when_all_present
    ):
        return False
    if any(
        _activation_reference_is_active(
            reference,
            nodes_by_index=nodes_by_index,
            active_indexes=active_indexes,
        )
        for reference in rule.when_all_absent
    ):
        return False
    if rule.when_any_present and not any(
        _activation_reference_is_active(
            reference,
            nodes_by_index=nodes_by_index,
            active_indexes=active_indexes,
        )
        for reference in rule.when_any_present
    ):
        return False
    if rule.when_any_absent and not any(
        not _activation_reference_is_active(
            reference,
            nodes_by_index=nodes_by_index,
            active_indexes=active_indexes,
        )
        for reference in rule.when_any_absent
    ):
        return False
    return True


def _activation_reference_is_active(
    reference: ActivationReference,
    *,
    nodes_by_index: dict[int, _LoadOrderNode],
    active_indexes: set[int],
) -> bool:
    for node_index in active_indexes:
        node = nodes_by_index[node_index]
        if reference.kind == "packageid":
            if node.package_id.casefold() == reference.value.casefold():
                return True
            continue
        if (
            node.requested_reference.kind == reference.kind
            and node.requested_reference.value == reference.value
        ):
            return True
    return False


def _activation_rule_issue(
    node: _LoadOrderNode,
    *,
    nodes_by_index: dict[int, _LoadOrderNode],
    active_indexes: set[int],
) -> ModpackValidationDiagnostic | None:
    rule = node.activation_rule
    if rule is None:
        return None
    if _activation_rule_passes(
        rule,
        nodes_by_index=nodes_by_index,
        active_indexes=active_indexes,
    ):
        return None
    return ModpackValidationDiagnostic(
        severity="info",
        message=(
            f"activation rule disabled {node.package_id}: "
            + _describe_activation_rule(rule)
        ),
        source_file=node.source_file,
        source_line=node.source_line,
    )


def _add_metadata_ordering_edges(
    node: _LoadOrderNode,
    *,
    indexes_by_package_id: dict[str, tuple[int, ...]],
    edges: dict[int, set[int]],
) -> None:
    for package_id in node.ordering_rules:
        for target_index in indexes_by_package_id.get(package_id.casefold(), ()):
            _add_edge(edges, node.base_index, target_index)
    for package_id in node.ordering_after:
        for source_index in indexes_by_package_id.get(package_id.casefold(), ()):
            _add_edge(edges, source_index, node.base_index)


def _add_authored_rule_edges(
    rule: OrderingRule,
    *,
    nodes: tuple[_LoadOrderNode, ...],
    indexes_by_package_id: dict[str, tuple[int, ...]],
    edges: dict[int, set[int]],
) -> None:
    mod_indexes = _matching_rule_reference_indexes(
        rule.mod,
        nodes=nodes,
        indexes_by_package_id=indexes_by_package_id,
    )
    target_indexes = _matching_rule_reference_indexes(
        rule.target,
        nodes=nodes,
        indexes_by_package_id=indexes_by_package_id,
    )

    for mod_index in mod_indexes:
        for target_index in target_indexes:
            if rule.rule_type == "before":
                _add_edge(edges, mod_index, target_index)
            else:
                _add_edge(edges, target_index, mod_index)


def _authored_rule_reference_diagnostics(
    rule: OrderingRule,
    *,
    nodes: tuple[_LoadOrderNode, ...],
    indexes_by_package_id: dict[str, tuple[int, ...]],
) -> tuple[ModpackValidationDiagnostic, ...]:
    diagnostics: list[ModpackValidationDiagnostic] = []
    mod_indexes = _matching_rule_reference_indexes(
        rule.mod,
        nodes=nodes,
        indexes_by_package_id=indexes_by_package_id,
    )
    target_indexes = _matching_rule_reference_indexes(
        rule.target,
        nodes=nodes,
        indexes_by_package_id=indexes_by_package_id,
    )
    if not mod_indexes:
        diagnostics.append(
            ModpackValidationDiagnostic(
                severity="warning",
                message=f"ordering rule references missing active mod: {_format_ordering_reference(rule.mod)}",
            )
        )
    if not target_indexes:
        diagnostics.append(
            ModpackValidationDiagnostic(
                severity="warning",
                message=f"ordering rule references missing active target: {_format_ordering_reference(rule.target)}",
            )
        )
    return tuple(diagnostics)


def _matching_rule_reference_indexes(
    reference: OrderingReference,
    *,
    nodes: tuple[_LoadOrderNode, ...],
    indexes_by_package_id: dict[str, tuple[int, ...]],
) -> tuple[int, ...]:
    if reference.kind == "packageid":
        return indexes_by_package_id.get(reference.value.casefold(), ())

    matches = [
        node.base_index
        for node in nodes
        if node.requested_reference.kind == reference.kind
        and node.requested_reference.value == reference.value
    ]
    return tuple(matches)


def _add_edge(edges: dict[int, set[int]], source_index: int, target_index: int) -> None:
    if source_index == target_index:
        return
    _ = edges[source_index].add(target_index)


def _stable_topological_sort(
    nodes: tuple[_LoadOrderNode, ...],
    *,
    edges: dict[int, set[int]],
) -> tuple[_LoadOrderNode, ...]:
    result = _topological_sort(nodes, edges=edges)
    if result.cycle_node_indexes:
        raise ModpackError("ordering rules and metadata produce a cycle")
    return result.ordered_nodes


def _topological_sort(
    nodes: tuple[_LoadOrderNode, ...],
    *,
    edges: dict[int, set[int]],
) -> _TopologicalSortResult:
    indegree: dict[int, int] = {node.base_index: 0 for node in nodes}
    for targets in edges.values():
        for target_index in targets:
            indegree[target_index] += 1

    ready: list[int] = [
        node.base_index for node in nodes if indegree[node.base_index] == 0
    ]
    heapq.heapify(ready)

    nodes_by_index = {node.base_index: node for node in nodes}
    ordered: list[_LoadOrderNode] = []
    while ready:
        node_index = heapq.heappop(ready)
        ordered.append(nodes_by_index[node_index])
        for target_index in sorted(edges[node_index]):
            indegree[target_index] -= 1
            if indegree[target_index] == 0:
                heapq.heappush(ready, target_index)

    if len(ordered) != len(nodes):
        cycle_node_indexes = tuple(
            node.base_index
            for node in nodes
            if indegree[node.base_index] > 0
        )
        return _TopologicalSortResult(
            ordered_nodes=tuple(ordered),
            cycle_node_indexes=cycle_node_indexes,
        )

    return _TopologicalSortResult(ordered_nodes=tuple(ordered))


def _versioned_package_ids(
    groups: tuple[VersionedPackageIdList, ...],
    *,
    rimworld_version: str,
) -> tuple[str, ...]:
    package_ids: list[str] = []
    for group in groups:
        if group.version == rimworld_version:
            package_ids.extend(group.package_ids)
    return tuple(package_ids)


def _dependency_package_ids(
    dependencies: tuple[AboutDependency, ...],
    versioned_dependencies: tuple[VersionedDependencyList, ...],
    *,
    rimworld_version: str,
) -> tuple[str, ...]:
    package_ids = [dependency.package_id for dependency in dependencies]
    for group in versioned_dependencies:
        if group.version != rimworld_version:
            continue
        package_ids.extend(dependency.package_id for dependency in group.dependencies)
    return tuple(package_ids)


def _ordering_rules_from_metadata(
    metadata: AboutModMetadata,
    *,
    rimworld_version: str,
) -> _MetadataOrderingRules:
    return _MetadataOrderingRules(
        before=_deduplicate_package_ids(
            metadata.load_before
            + metadata.force_load_before
            + _versioned_package_ids(
                metadata.load_before_by_version,
                rimworld_version=rimworld_version,
            )
        ),
        after=_deduplicate_package_ids(
            metadata.load_after
            + metadata.force_load_after
            + _versioned_package_ids(
                metadata.load_after_by_version,
                rimworld_version=rimworld_version,
            )
            + _dependency_package_ids(
                metadata.mod_dependencies,
                metadata.mod_dependencies_by_version,
                rimworld_version=rimworld_version,
            )
        ),
    )


def _deduplicate_package_ids(package_ids: tuple[str, ...]) -> tuple[str, ...]:
    ordered: list[str] = []
    seen: set[str] = set()
    for package_id in package_ids:
        normalized = package_id.casefold()
        if normalized in seen:
            continue
        seen.add(normalized)
        ordered.append(package_id)
    return tuple(ordered)


def _describe_activation_rule(rule: ActivationRule) -> str:
    clauses: list[str] = []
    if rule.when_all_present:
        clauses.append(
            "all present: " + ", ".join(_format_activation_reference(reference) for reference in rule.when_all_present)
        )
    if rule.when_all_absent:
        clauses.append(
            "all absent: " + ", ".join(_format_activation_reference(reference) for reference in rule.when_all_absent)
        )
    if rule.when_any_present:
        clauses.append(
            "any present: " + ", ".join(_format_activation_reference(reference) for reference in rule.when_any_present)
        )
    if rule.when_any_absent:
        clauses.append(
            "any absent: " + ", ".join(_format_activation_reference(reference) for reference in rule.when_any_absent)
        )
    return "; ".join(clauses)


def _duplicate_package_id_diagnostics(
    nodes: tuple[_LoadOrderNode, ...],
) -> tuple[ModpackValidationDiagnostic, ...]:
    entries_by_package_id: dict[str, list[_LoadOrderNode]] = defaultdict(list)
    for node in nodes:
        entries_by_package_id[node.package_id.casefold()].append(node)

    diagnostics: list[ModpackValidationDiagnostic] = []
    for duplicates in entries_by_package_id.values():
        if len(duplicates) < 2:
            continue
        duplicate_sources = ", ".join(
            f"{node.source_file}:{node.source_line}"
            for node in duplicates
        )
        diagnostics.append(
            ModpackValidationDiagnostic(
                severity="warning",
                message=(
                    f"duplicate active package ID {duplicates[0].package_id} "
                    f"appears in {duplicate_sources}"
                ),
            )
        )
    return tuple(diagnostics)


def _missing_dependency_diagnostics(
    nodes: tuple[_LoadOrderNode, ...],
    *,
    rimworld_version: str,
) -> tuple[ModpackValidationDiagnostic, ...]:
    active_package_ids = {node.package_id.casefold() for node in nodes}
    diagnostics: list[ModpackValidationDiagnostic] = []
    for node in nodes:
        for dependency in _dependencies_for_validation(node.path, rimworld_version=rimworld_version):
            candidate_ids = (dependency.package_id,) + dependency.alternative_package_ids
            if any(candidate.casefold() in active_package_ids for candidate in candidate_ids):
                continue
            display_name = dependency.display_name or dependency.package_id
            diagnostics.append(
                ModpackValidationDiagnostic(
                    severity="error",
                    message=f"missing dependency for {node.package_id}: {display_name}",
                    source_file=node.source_file,
                    source_line=node.source_line,
                )
            )
    return tuple(diagnostics)


def _dependencies_for_validation(
    mod_path: Path,
    *,
    rimworld_version: str,
) -> tuple[AboutDependency, ...]:
    metadata = load_mod_folder(mod_path).about
    return metadata.mod_dependencies + tuple(
        dependency
        for group in metadata.mod_dependencies_by_version
        if group.version == rimworld_version
        for dependency in group.dependencies
    )


def _incompatibility_diagnostics(
    nodes: tuple[_LoadOrderNode, ...],
    *,
    rimworld_version: str,
) -> tuple[ModpackValidationDiagnostic, ...]:
    active_package_ids = {node.package_id.casefold() for node in nodes}
    diagnostics: list[ModpackValidationDiagnostic] = []
    for node in nodes:
        incompatible = _incompatible_package_ids(node.path, rimworld_version=rimworld_version)
        for package_id in incompatible:
            if package_id.casefold() not in active_package_ids:
                continue
            diagnostics.append(
                ModpackValidationDiagnostic(
                    severity="error",
                    message=f"incompatible mods are both active: {node.package_id} and {package_id}",
                    source_file=node.source_file,
                    source_line=node.source_line,
                )
            )
    return tuple(diagnostics)


def _incompatible_package_ids(
    mod_path: Path,
    *,
    rimworld_version: str,
) -> tuple[str, ...]:
    metadata = load_mod_folder(mod_path).about
    return _deduplicate_package_ids(
        metadata.incompatible_with
        + _versioned_package_ids(
            metadata.incompatible_with_by_version,
            rimworld_version=rimworld_version,
        )
    )


def _unused_activation_rule_diagnostics(
    nodes: tuple[_LoadOrderNode, ...],
    activation_rules: tuple[ActivationRule, ...],
) -> tuple[ModpackValidationDiagnostic, ...]:
    matched_keys = {
        (node.requested_reference.kind, node.requested_reference.value.casefold())
        for node in nodes
    }
    matched_keys.update(("packageid", node.package_id.casefold()) for node in nodes)

    diagnostics: list[ModpackValidationDiagnostic] = []
    seen: set[tuple[str, str]] = set()
    for rule in activation_rules:
        key = (rule.mod.kind, rule.mod.value.casefold())
        if key in seen or key in matched_keys:
            seen.add(key)
            continue
        seen.add(key)
        diagnostics.append(
            ModpackValidationDiagnostic(
                severity="warning",
                message=f"activation rule does not match any enabled resolved mod: {_format_activation_reference(rule.mod)}",
            )
        )
    return tuple(diagnostics)


def _format_ordering_reference(reference: OrderingReference) -> str:
    return f"{reference.kind}:{reference.value}"


def _format_activation_reference(reference: ActivationReference) -> str:
    return f"{reference.kind}:{reference.value}"


def _node_by_index(nodes: tuple[_LoadOrderNode, ...], node_index: int) -> _LoadOrderNode:
    for node in nodes:
        if node.base_index == node_index:
            return node
    raise ModpackError(f"missing load-order node index: {node_index}")


def _resolution_search_roots(
    resolution_roots: ModpackResolutionRoots,
) -> tuple[Path, ...]:
    return tuple(
        root
        for root in (
            resolution_roots.local_mods_dir,
            resolution_roots.steam_workshop_folder,
            resolution_roots.rimworld_mods_folder,
            resolution_roots.rimworld_data_folder,
        )
        if root is not None
    )


def _resolve_entry_mod_folder(
    line: EntryLine,
    *,
    resolution_roots: ModpackResolutionRoots,
    source_file: str,
    source_line: int,
) -> ModFolder:
    reference = line.reference
    if reference.kind == "steam":
        if resolution_roots.steam_workshop_folder is None:
            raise ModpackError(
                f"enabled mod entry {source_file}:{source_line} is not available locally: {line.raw_reference_text}",
            )
        mod = try_load_mod_folder(
            resolution_roots.steam_workshop_folder / reference.value,
        )
        if mod is None:
            raise ModpackError(
                f"enabled mod entry {source_file}:{source_line} is not available locally: {line.raw_reference_text}",
            )
        return mod

    if reference.kind == "local":
        if resolution_roots.local_mods_dir is None:
            raise ModpackError(
                f"enabled mod entry {source_file}:{source_line} is not available locally: {line.raw_reference_text}",
            )
        mod = try_load_mod_folder(resolution_roots.local_mods_dir / reference.value)
        if mod is None:
            raise ModpackError(
                f"enabled mod entry {source_file}:{source_line} is not available locally: {line.raw_reference_text}",
            )
        return mod

    normalized_package_id = reference.value.casefold()
    for root in _resolution_search_roots(resolution_roots):
        for mod in _iter_loaded_mods(root):
            if mod.about.package_id.casefold() == normalized_package_id:
                return mod

    raise ModpackError(
        f"enabled mod entry {source_file}:{source_line} is not available locally: {line.raw_reference_text}",
    )


def _try_resolve_entry_mod_folder(
    line: EntryLine,
    *,
    resolution_roots: ModpackResolutionRoots,
) -> ModFolder | None:
    reference = line.reference
    if reference.kind == "steam":
        if resolution_roots.steam_workshop_folder is None:
            return None
        return try_load_mod_folder(
            resolution_roots.steam_workshop_folder / reference.value,
        )

    if reference.kind == "local":
        if resolution_roots.local_mods_dir is None:
            return None
        return try_load_mod_folder(resolution_roots.local_mods_dir / reference.value)

    normalized_package_id = reference.value.casefold()
    for root in _resolution_search_roots(resolution_roots):
        for mod in _iter_loaded_mods(root):
            if mod.about.package_id.casefold() == normalized_package_id:
                return mod
    return None


def _iter_loaded_mods(root: Path) -> tuple[ModFolder, ...]:
    loaded_mods: list[ModFolder] = []
    for child in sorted(root.iterdir(), key=lambda path: path.name.casefold()):
        mod = try_load_mod_folder(child)
        if mod is not None:
            loaded_mods.append(mod)
    return tuple(loaded_mods)
