from __future__ import annotations

from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
import shlex

from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
from govoplan_core.server.registry import parse_enabled_modules

MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")


class ModuleManagementError(ValueError):
    pass


@dataclass(frozen=True, slots=True)
class ModuleStatePlan:
    enabled_modules: tuple[str, ...]
    added_dependencies: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class ModuleInstallPlanItem:
    module_id: str
    action: str
    source: str = "manual"
    catalog: Mapping[str, object] | None = None
    python_package: str | None = None
    python_ref: str | None = None
    webui_package: str | None = None
    webui_ref: str | None = None
    artifact_integrity: Mapping[str, object] | None = None
    data_safety_acknowledged: bool = False
    destroy_data: bool = False
    status: str = "planned"
    notes: str | None = None

    def as_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "module_id": self.module_id,
            "action": self.action,
            "source": self.source,
            "status": self.status,
        }
        if self.destroy_data:
            payload["destroy_data"] = True
        if self.data_safety_acknowledged:
            payload["data_safety_acknowledged"] = True
        if self.catalog:
            payload["catalog"] = dict(self.catalog)
        for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
            value = getattr(self, key)
            if value:
                payload[key] = value
        if self.artifact_integrity:
            payload["artifact_integrity"] = dict(self.artifact_integrity)
        return payload


@dataclass(frozen=True, slots=True)
class _NormalizedModuleInstallPlanItem:
    module_id: str
    action: str
    source: str
    catalog: Mapping[str, object] | None
    status: str
    python_package: str | None
    python_ref: str | None
    webui_package: str | None
    webui_ref: str | None
    artifact_integrity: Mapping[str, object] | None
    data_safety_acknowledged: bool
    destroy_data: bool
    notes: str | None


@dataclass(frozen=True, slots=True)
class ModuleInstallPlan:
    items: tuple[ModuleInstallPlanItem, ...] = ()
    updated_at: str | None = None


def configured_enabled_modules(value: str | Iterable[str]) -> tuple[str, ...]:
    return tuple(dict.fromkeys(parse_enabled_modules(value)))


def startup_candidate_module_ids(
    configured: str | Iterable[str],
    desired: Iterable[str] | None = None,
) -> tuple[str, ...]:
    fallback = configured_enabled_modules(configured)
    candidates = [*fallback]
    if desired is not None:
        candidates.extend(str(item).strip() for item in desired if str(item).strip())
    candidates.extend(REQUIRED_PLATFORM_MODULES)
    candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
    if "admin" in fallback:
        candidates.append("admin")
    return tuple(dict.fromkeys(candidates))


def saved_desired_enabled_modules(session: Session, fallback: Iterable[str]) -> tuple[str, ...]:
    item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
    if item is None:
        return tuple(fallback)
    return desired_enabled_modules_from_settings(item.settings or {}, fallback)


def desired_enabled_modules_from_settings(settings: Mapping[str, object], fallback: Iterable[str]) -> tuple[str, ...]:
    raw_management = settings.get(MODULE_SETTINGS_KEY)
    if not isinstance(raw_management, Mapping):
        return tuple(fallback)
    raw_enabled = raw_management.get("desired_enabled")
    if not isinstance(raw_enabled, list | tuple):
        return tuple(fallback)
    normalized = [str(item).strip() for item in raw_enabled if str(item).strip()]
    return tuple(dict.fromkeys(normalized)) or tuple(fallback)


def load_startup_enabled_modules(
    configured: str | Iterable[str],
    *,
    available: Mapping[str, ModuleManifest] | None = None,
) -> tuple[str, ...]:
    fallback = configured_enabled_modules(configured)
    try:
        with get_database().session() as session:
            desired = saved_desired_enabled_modules(session, fallback)
    except (RuntimeError, SQLAlchemyError):
        desired = fallback
    if available is None:
        return desired

    protected = list(REQUIRED_PLATFORM_MODULES)
    if "admin" in fallback:
        protected.append("admin")
    protected = [module_id for module_id in protected if module_id in available]
    requested = [module_id for module_id in desired if module_id in available]
    if not requested:
        requested = [module_id for module_id in fallback if module_id in available]
    return plan_desired_enabled_modules(requested, available, protected_modules=protected).enabled_modules


def save_desired_enabled_modules(session: Session, enabled_modules: Iterable[str]) -> tuple[str, ...]:
    item = get_system_settings(session)
    settings = dict(item.settings or {})
    management = _management_settings(settings)
    management["desired_enabled"] = list(dict.fromkeys(enabled_modules))
    settings[MODULE_SETTINGS_KEY] = management
    item.settings = settings
    session.add(item)
    session.flush()
    return tuple(management["desired_enabled"])  # type: ignore[arg-type]


def module_install_plan_from_settings(settings: Mapping[str, object]) -> ModuleInstallPlan:
    raw_management = settings.get(MODULE_SETTINGS_KEY)
    if not isinstance(raw_management, Mapping):
        return ModuleInstallPlan()
    raw_plan = raw_management.get(INSTALL_PLAN_KEY)
    if not isinstance(raw_plan, Mapping):
        return ModuleInstallPlan()
    raw_items = raw_plan.get("items")
    if not isinstance(raw_items, list | tuple):
        return ModuleInstallPlan(updated_at=_clean_optional_string(raw_plan.get("updated_at")))
    return ModuleInstallPlan(
        items=tuple(normalize_module_install_plan_item(item) for item in raw_items),
        updated_at=_clean_optional_string(raw_plan.get("updated_at")),
    )


def saved_module_install_plan(session: Session) -> ModuleInstallPlan:
    item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
    if item is None:
        return ModuleInstallPlan()
    return module_install_plan_from_settings(item.settings or {})


def save_module_install_plan(
    session: Session,
    items: Iterable[Mapping[str, object] | ModuleInstallPlanItem],
) -> ModuleInstallPlan:
    normalized = tuple(normalize_module_install_plan_item(item) for item in items)
    item = get_system_settings(session)
    settings = dict(item.settings or {})
    management = _management_settings(settings)
    updated_at = datetime.now(tz=UTC).isoformat()
    management[INSTALL_PLAN_KEY] = {
        "updated_at": updated_at,
        "items": [entry.as_dict() for entry in normalized],
    }
    settings[MODULE_SETTINGS_KEY] = management
    item.settings = settings
    session.add(item)
    session.flush()
    return ModuleInstallPlan(items=normalized, updated_at=updated_at)


def clear_module_install_plan(session: Session) -> ModuleInstallPlan:
    return save_module_install_plan(session, ())


def module_install_plan_commands(
    plan: ModuleInstallPlan | Iterable[ModuleInstallPlanItem],
    *,
    statuses: Iterable[str] = ("planned",),
) -> tuple[str, ...]:
    items = plan.items if isinstance(plan, ModuleInstallPlan) else tuple(plan)
    included_statuses = {status for status in statuses}
    commands: list[str] = []
    for item in items:
        if item.status not in included_statuses:
            continue
        if item.action in {"install", "update"}:
            if item.python_ref:
                commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
            if item.webui_package and item.webui_ref:
                commands.append(
                    "npm pkg set "
                    + shlex.quote(f"dependencies.{item.webui_package}={item.webui_ref}")
                    + " && npm install"
                )
        elif item.action == "uninstall":
            if item.python_package:
                commands.append(f"python -m pip uninstall -y {shlex.quote(item.python_package)}")
            if item.webui_package:
                commands.append(
                    "npm pkg delete "
                    + shlex.quote(f"dependencies.{item.webui_package}")
                    + " && npm install"
                )
    return tuple(commands)


def installed_module_distribution_name(module_id: str) -> str | None:
    clean_module_id = str(module_id).strip()
    if not clean_module_id:
        return None
    for entry_point in iter_module_entry_points():
        if entry_point.name != clean_module_id:
            continue
        distribution = getattr(entry_point, "dist", None)
        metadata = getattr(distribution, "metadata", None)
        if metadata is None:
            return None
        name = metadata.get("Name")
        return str(name).strip() if name else None
    return None


def module_uninstall_plan_item(
    module_id: str,
    available: Mapping[str, ModuleManifest],
) -> ModuleInstallPlanItem:
    clean_module_id = str(module_id).strip()
    if not clean_module_id:
        raise ModuleManagementError("Uninstall plan needs a module id.")
    manifest = available.get(clean_module_id)
    if manifest is None:
        raise ModuleManagementError(f"Module {clean_module_id!r} is not installed.")
    python_package = installed_module_distribution_name(clean_module_id)
    if not python_package:
        raise ModuleManagementError(
            f"Could not determine the installed Python distribution for module {clean_module_id!r}."
        )
    frontend = manifest.frontend
    return ModuleInstallPlanItem(
        module_id=clean_module_id,
        action="uninstall",
        python_package=python_package,
        webui_package=frontend.package_name if frontend and frontend.package_name else None,
        notes="Non-destructive uninstall leaves module data and schema dormant.",
    )


def desired_modules_after_package_plan(
    desired_enabled: Iterable[str],
    plan: ModuleInstallPlan,
    *,
    activate_installed_modules: bool = True,
    remove_uninstalled_modules_from_desired: bool = True,
) -> tuple[str, ...]:
    desired = [str(item).strip() for item in desired_enabled if str(item).strip()]
    planned_items = tuple(item for item in plan.items if item.status == "planned")
    if remove_uninstalled_modules_from_desired:
        removed = {item.module_id for item in planned_items if item.action == "uninstall"}
        desired = [module_id for module_id in desired if module_id not in removed]
    if activate_installed_modules:
        desired.extend(item.module_id for item in planned_items if item.action in {"install", "update"})
    return tuple(dict.fromkeys(desired))


def normalize_module_install_plan_item(
    item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem:
    normalized = _normalized_module_install_plan_item(item)
    _validate_module_install_plan_item(normalized)
    return ModuleInstallPlanItem(
        module_id=normalized.module_id,
        action=normalized.action,
        source=normalized.source,
        catalog=normalized.catalog,
        python_package=normalized.python_package,
        python_ref=normalized.python_ref,
        webui_package=normalized.webui_package,
        webui_ref=normalized.webui_ref,
        artifact_integrity=normalized.artifact_integrity,
        data_safety_acknowledged=normalized.data_safety_acknowledged,
        destroy_data=normalized.destroy_data,
        status=normalized.status,
        notes=normalized.notes,
    )


def _normalized_module_install_plan_item(
    item: Mapping[str, object] | ModuleInstallPlanItem,
) -> _NormalizedModuleInstallPlanItem:
    if isinstance(item, ModuleInstallPlanItem):
        raw = item.as_dict()
    elif isinstance(item, Mapping):
        raw = item
    else:
        raise ModuleManagementError("Install plan item must be an object.")

    module_id = _required_string(raw.get("module_id"), "module_id")
    action = _required_string(raw.get("action"), "action")
    source = _clean_optional_string(raw.get("source")) or "manual"
    catalog = _clean_optional_mapping(raw.get("catalog"), field="catalog", module_id=module_id)
    status = _clean_optional_string(raw.get("status")) or "planned"
    python_package = _clean_optional_string(raw.get("python_package"))
    python_ref = _clean_optional_string(raw.get("python_ref"))
    webui_package = _clean_optional_string(raw.get("webui_package"))
    webui_ref = _clean_optional_string(raw.get("webui_ref"))
    artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
    data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
    destroy_data = _clean_bool(raw.get("destroy_data"))
    notes = _clean_optional_string(raw.get("notes"))
    return _NormalizedModuleInstallPlanItem(
        module_id=module_id,
        action=action,
        source=source,
        catalog=catalog,
        status=status,
        python_package=python_package,
        python_ref=python_ref,
        webui_package=webui_package,
        webui_ref=webui_ref,
        artifact_integrity=artifact_integrity,
        data_safety_acknowledged=data_safety_acknowledged,
        destroy_data=destroy_data,
        notes=notes,
    )


def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
    if item.action not in INSTALL_PLAN_ACTIONS:
        raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
    if item.status not in INSTALL_PLAN_STATUSES:
        raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
    if item.source not in INSTALL_PLAN_SOURCES:
        raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
    _validate_module_install_plan_item_requirements(item)
    _validate_module_install_plan_item_refs(item)


def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
    if item.action in {"install", "update"} and not item.python_ref:
        raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
    if item.action == "uninstall" and not item.python_package:
        raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
    if item.action != "uninstall" and item.destroy_data:
        raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
    if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
        raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
    if item.action == "uninstall" and item.webui_ref and not item.webui_package:
        raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")


def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
    if item.python_ref:
        _validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
    if item.webui_ref:
        _validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)


def plan_desired_enabled_modules(
    requested_enabled: Iterable[str],
    available: Mapping[str, ModuleManifest],
    *,
    protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
    requested = _normalized_module_ids(requested_enabled)
    requested.update(_normalized_module_ids(protected_modules))
    missing = sorted(module_id for module_id in requested if module_id not in available)
    if missing:
        raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))

    added_dependencies = _expand_enabled_module_closure(requested, available)
    ordered = _order_enabled_modules(requested, available, added_dependencies)
    return ModuleStatePlan(
        enabled_modules=tuple(dict.fromkeys(ordered)),
        added_dependencies=tuple(sorted(added_dependencies)),
    )


def _normalized_module_ids(values: Iterable[str]) -> set[str]:
    return {str(item).strip() for item in values if str(item).strip()}


def _expand_enabled_module_closure(
    requested: set[str],
    available: Mapping[str, ModuleManifest],
) -> set[str]:
    added: set[str] = set()
    while True:
        dependencies = {
            dependency
            for module_id in requested
            for dependency in available[module_id].dependencies
        }
        _require_installed_modules(
            dependencies,
            available,
            message="Required modules are not installed: ",
        )
        dependency_additions = dependencies - requested
        added.update(dependency_additions)
        requested.update(dependency_additions)

        providers = _missing_capability_providers(requested, available)
        _require_installed_modules(
            providers,
            available,
            message="Required capability providers are not installed: ",
        )
        additions = providers - requested
        if not additions:
            return added
        added.update(additions)
        requested.update(additions)


def _missing_capability_providers(
    requested: set[str],
    available: Mapping[str, ModuleManifest],
) -> set[str]:
    provided = {
        capability
        for module_id in requested
        for capability in available[module_id].capability_factories
    }
    return {
        provider_id
        for module_id in requested
        for capability in available[module_id].required_capabilities
        if capability not in provided
        for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
        if provider_id is not None
    }


def _require_installed_modules(
    module_ids: set[str],
    available: Mapping[str, ModuleManifest],
    *,
    message: str,
) -> None:
    missing = sorted(module_id for module_id in module_ids if module_id not in available)
    if missing:
        raise ModuleManagementError(message + ", ".join(missing))


def _order_enabled_modules(
    requested: set[str],
    available: Mapping[str, ModuleManifest],
    added_dependencies: set[str],
) -> list[str]:
    visiting: set[str] = set()
    visited: set[str] = set()
    ordered: list[str] = []
    for module_id in sorted(requested):
        _visit_enabled_module(
            module_id,
            requested=requested,
            available=available,
            added_dependencies=added_dependencies,
            visiting=visiting,
            visited=visited,
            ordered=ordered,
        )
    return ordered


def _visit_enabled_module(
    module_id: str,
    *,
    requested: set[str],
    available: Mapping[str, ModuleManifest],
    added_dependencies: set[str],
    visiting: set[str],
    visited: set[str],
    ordered: list[str],
) -> None:
    if module_id in visited:
        return
    if module_id in visiting:
        raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
    manifest = available.get(module_id)
    if manifest is None:
        raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
    visiting.add(module_id)
    for dependency_id in manifest.dependencies:
        if dependency_id not in available:
            raise ModuleManagementError(
                f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
            )
        if dependency_id not in requested:
            added_dependencies.add(dependency_id)
            requested.add(dependency_id)
        _visit_enabled_module(
            dependency_id,
            requested=requested,
            available=available,
            added_dependencies=added_dependencies,
            visiting=visiting,
            visited=visited,
            ordered=ordered,
        )
    visiting.remove(module_id)
    visited.add(module_id)
    ordered.append(module_id)


def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
    dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
    for manifest in available.values():
        for dependency_id in manifest.dependencies:
            dependents.setdefault(dependency_id, []).append(manifest.id)
    return {module_id: tuple(sorted(items)) for module_id, items in dependents.items()}


def _management_settings(settings: Mapping[str, object]) -> dict[str, object]:
    raw_management = settings.get(MODULE_SETTINGS_KEY)
    if isinstance(raw_management, Mapping):
        return dict(raw_management)
    return {}


def _required_string(value: object, field: str) -> str:
    cleaned = _clean_optional_string(value)
    if not cleaned:
        raise ModuleManagementError(f"Install plan item needs {field}.")
    return cleaned


def _clean_optional_string(value: object) -> str | None:
    if value is None:
        return None
    cleaned = str(value).strip()
    return cleaned or None


def _clean_bool(value: object) -> bool:
    if isinstance(value, bool):
        return value
    if value is None:
        return False
    if isinstance(value, str):
        return value.strip().lower() in {"1", "true", "yes", "on"}
    return bool(value)


def _clean_optional_mapping(value: object, *, field: str, module_id: str) -> dict[str, object] | None:
    if value is None:
        return None
    if not isinstance(value, Mapping):
        raise ModuleManagementError(f"Install plan item {module_id!r} field {field} must be an object.")
    return {str(key): item for key, item in value.items() if str(key).strip()}


def _validate_dependency_ref(value: str, *, field: str, module_id: str) -> None:
    lowered = value.lower()
    if lowered.startswith(LOCAL_DEPENDENCY_REF_PREFIXES) or value.startswith(("/", "./", "../", "~")):
        raise ModuleManagementError(
            f"Install plan item {module_id!r} uses a local {field}; use a tagged package or git ref instead."
        )
