"""Module discovery, dependency, and conflict resolution.

Reads ``modules.yml`` (the registry) and each module's manifest at
``template/core/module.yml`` / ``template/modules/<id>/module.yml``.
Resolution (dependencies, ``provides``/``conflicts``, enabled set) lands in the
module-system task.
"""
from __future__ import annotations

import os

import yaml

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# Ids promoted INTO core (no longer a template/modules/<id> tree) but that an existing
# vault's config may still list in modules.enabled. Tolerated as a no-op in resolution —
# no manifest, no render, no "unknown module" error — so those vaults keep resolving,
# updating, and doctoring cleanly. `federation` became core in 4.0.0 (relay push is the
# always-present capability, docs/V4-FEDERATION.md §9.4); its id ALSO still gates the
# legacy V1 email-ingest processor in the runners (agent_listener / alexandria-inbox.yml),
# so the string is deliberately kept meaningful in an enabled set. This is NOT a removed
# stub (tests/test_modules.py REMOVED_STUBS) — the capability lives on, in core.
RETIRED_MODULES = {"federation"}


def load_registry(repo_root: str = REPO_ROOT) -> list:
    """Return the list of module entries from ``modules.yml``."""
    with open(os.path.join(repo_root, "modules.yml"), "r", encoding="utf-8") as f:
        return (yaml.safe_load(f) or {}).get("modules", [])


def module_manifest_path(module_id: str, repo_root: str = REPO_ROOT) -> str:
    if module_id == "core":
        return os.path.join(repo_root, "template", "core", "module.yml")
    return os.path.join(repo_root, "template", "modules", module_id, "module.yml")


def load_manifest(module_id: str, repo_root: str = REPO_ROOT) -> dict:
    with open(module_manifest_path(module_id, repo_root), "r", encoding="utf-8") as f:
        return yaml.safe_load(f) or {}


def template_dir(module_id: str, repo_root: str = REPO_ROOT) -> str:
    """The template tree for a module — core lives at template/core."""
    if module_id == "core":
        return os.path.join(repo_root, "template", "core")
    return os.path.join(repo_root, "template", "modules", module_id)


def resolve(enabled, repo_root: str = REPO_ROOT):
    """Order + validate an enabled module set. ``core`` is always present and first.

    Checks each module's manifest for: existence, ``provides`` (at most one enabled
    module per capability), ``requires`` (an id or a provided capability must be present),
    and ``conflicts``. Returns (ordered_ids, problems) where problems are ERROR:/WARN:
    strings (same convention as config.validate)."""
    ids = [m for m in dict.fromkeys(["core"] + list(enabled or []))
           if m not in RETIRED_MODULES]        # retired-to-core ids resolve as a silent no-op
    problems = []
    manifests = {}
    for mid in ids:
        try:
            manifests[mid] = load_manifest(mid, repo_root)
        except OSError:
            problems.append(f"ERROR: unknown module '{mid}' (no manifest found).")

    provided = {}
    for mid in ids:
        for cap in (manifests.get(mid, {}).get("provides") or []):
            provided.setdefault(cap, []).append(mid)
    for cap, owners in provided.items():
        if len(owners) > 1:
            problems.append(f"ERROR: modules {owners} both provide '{cap}' — enable only one.")

    present = set(ids) | set(provided)
    for mid in ids:
        for req in (manifests.get(mid, {}).get("requires") or []):
            if req not in present:
                problems.append(f"ERROR: module '{mid}' requires '{req}', which isn't enabled.")
        for con in (manifests.get(mid, {}).get("conflicts") or []):
            if con in ids:
                problems.append(f"ERROR: module '{mid}' conflicts with enabled module '{con}'.")
    return ids, problems


def required_secrets(enabled, repo_root: str = REPO_ROOT) -> list:
    """The credential *names* a vault needs = the union of ``secrets_required`` across
    its enabled modules (``core`` always included), deduplicated and ordered core-first.

    Each ``module.yml`` is the single source of truth, so the list is correct per
    deployment without special-casing in the config wizard: a ``server-macos`` vault requires
    ``IMAP_*`` (its real-time IDLE listener) and ``principal-inbox`` pulls in
    ``OWNER_PASSPHRASE_SHA256``. (No software-repo token anywhere: the engine wheel is
    vendored into the vault at ``.github/engine/`` by the render.)"""
    out = []
    for mid in dict.fromkeys(["core"] + list(enabled or [])):
        if mid in RETIRED_MODULES:
            continue                            # no manifest; its secrets come via the runner
        try:
            manifest = load_manifest(mid, repo_root)
        except OSError:
            continue
        for name in (manifest.get("secrets_required") or []):
            if name not in out:
                out.append(name)
    return out
