# Copyright (C) 2026 GoodData Corporation

"""Version-agnostic semantic audit for repo-owned AIDA config files."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import cast

import yaml

from aida_mcp.utils.aida_paths import get_repo_config_root

_IMPLICIT_PROCESSORS = {"passthrough", "external_json", "pytest", "gradle", "typescript_rush_tail"}


@dataclass(frozen=True, slots=True)
class RepoConfigAuditFinding:
    level: str
    file: str
    message: str
    code: str = ""
    subject: str | None = None
    fixable: bool = False


def audit_repo_configs(*, workspace_root: Path) -> tuple[RepoConfigAuditFinding, ...]:
    config_root = get_repo_config_root(workspace_root)
    if not config_root.exists():
        return ()

    change_domains = _load_mapping(config_root / "change_domains.yaml")
    validation_policy = _load_mapping(config_root / "validation_policy.yaml")
    validation_registry = _load_mapping(config_root / "validation_registry.yaml")
    git_policy = _load_mapping(config_root / "git_policy.yaml")
    rules_selection = _load_mapping(config_root / "rules_selection.yaml")

    return audit_loaded_repo_configs(
        config_root=config_root,
        change_domains_root=change_domains,
        validation_policy_root=validation_policy,
        validation_registry_root=validation_registry,
        git_policy_root=git_policy,
        rules_selection_root=rules_selection,
    )


def audit_loaded_repo_configs(
    *,
    config_root: Path,
    change_domains_root: dict[str, object],
    validation_policy_root: dict[str, object],
    validation_registry_root: dict[str, object],
    git_policy_root: dict[str, object],
    rules_selection_root: dict[str, object],
) -> tuple[RepoConfigAuditFinding, ...]:
    findings: list[RepoConfigAuditFinding] = []

    findings.extend(_audit_git_policy(config_root=config_root, root=git_policy_root))
    findings.extend(
        _audit_validation_configs(
            change_domains_root=change_domains_root,
            validation_policy_root=validation_policy_root,
            validation_registry_root=validation_registry_root,
        )
    )
    findings.extend(_audit_rules_selection(root=rules_selection_root))
    return tuple(findings)


def _audit_git_policy(*, config_root: Path, root: dict[str, object]) -> list[RepoConfigAuditFinding]:
    findings: list[RepoConfigAuditFinding] = []
    file = "git_policy.yaml"
    _warn_unknown_keys(findings, file=file, prefix="", mapping=root, allowed={"version", "git_policy"})

    git_policy = _mapping(root.get("git_policy"))
    _warn_unknown_keys(
        findings,
        file=file,
        prefix="git_policy",
        mapping=git_policy,
        allowed={"commit", "pr", "workflow", "enforcement"},
    )

    commit = _mapping(git_policy.get("commit"))
    _warn_unknown_keys(
        findings,
        file=file,
        prefix="git_policy.commit",
        mapping=commit,
        allowed={
            "subject_max_chars",
            "ticket_enabled",
            "risk_values",
            "risk_enabled",
            "require_co_authored_by",
            "template_file",
            "pre_commit_guidance",
            "ticket_prefix",
            "risk_prefix",
        },
    )
    _warn_missing_keys(
        findings,
        file=file,
        prefix="git_policy.commit",
        mapping=commit,
        expected={
            "ticket_enabled",
            "ticket_prefix",
            "risk_enabled",
            "risk_prefix",
            "template_file",
            "pre_commit_guidance",
        },
    )
    _warn_template_file(
        findings,
        file=file,
        config_root=config_root,
        prefix="git_policy.commit.template_file",
        value=commit.get("template_file"),
    )

    pr = _mapping(git_policy.get("pr"))
    _warn_unknown_keys(
        findings,
        file=file,
        prefix="git_policy.pr",
        mapping=pr,
        allowed={"title_template_file", "body_template_file"},
    )
    _warn_missing_keys(
        findings,
        file=file,
        prefix="git_policy.pr",
        mapping=pr,
        expected={"title_template_file", "body_template_file"},
    )
    _warn_template_file(
        findings,
        file=file,
        config_root=config_root,
        prefix="git_policy.pr.title_template_file",
        value=pr.get("title_template_file"),
    )
    _warn_template_file(
        findings,
        file=file,
        config_root=config_root,
        prefix="git_policy.pr.body_template_file",
        value=pr.get("body_template_file"),
    )

    workflow = _mapping(git_policy.get("workflow"))
    _warn_unknown_keys(
        findings,
        file=file,
        prefix="git_policy.workflow",
        mapping=workflow,
        allowed={"prefer_force_with_lease", "autosquash_unpublished_only"},
    )

    enforcement = _mapping(git_policy.get("enforcement"))
    _warn_unknown_keys(findings, file=file, prefix="git_policy.enforcement", mapping=enforcement, allowed={"mode"})
    _warn_missing_keys(findings, file=file, prefix="git_policy.enforcement", mapping=enforcement, expected={"mode"})
    return findings


def _audit_validation_configs(
    *,
    change_domains_root: dict[str, object],
    validation_policy_root: dict[str, object],
    validation_registry_root: dict[str, object],
) -> list[RepoConfigAuditFinding]:
    findings: list[RepoConfigAuditFinding] = []

    change_file = "change_domains.yaml"
    _warn_unknown_keys(
        findings, file=change_file, prefix="", mapping=change_domains_root, allowed={"version", "domains"}
    )
    domains_any = change_domains_root.get("domains")
    domains = cast(list[object], domains_any) if isinstance(domains_any, list) else []
    domain_ids: list[str] = []
    for raw in domains:
        if not isinstance(raw, Mapping):
            continue
        entry = _mapping(cast(object, raw))
        domain_id = entry.get("id")
        if isinstance(domain_id, str) and domain_id.strip():
            domain_ids.append(domain_id.strip())
    _warn_duplicates(findings, file=change_file, prefix="domains[].id", values=domain_ids)
    domain_id_set = set(domain_ids)

    policy_file = "validation_policy.yaml"
    _warn_unknown_keys(
        findings, file=policy_file, prefix="", mapping=validation_policy_root, allowed={"version", "validation_policy"}
    )
    policy = _mapping(validation_policy_root.get("validation_policy"))
    _warn_unknown_keys(
        findings,
        file=policy_file,
        prefix="validation_policy",
        mapping=policy,
        allowed={"codegen", "routes", "pipelines"},
    )

    routes_any = policy.get("routes")
    routes = cast(list[object], routes_any) if isinstance(routes_any, list) else []
    route_domains: set[str] = set()
    referenced_pipelines: set[str] = set()
    for raw in routes:
        if not isinstance(raw, Mapping):
            continue
        entry = _mapping(cast(object, raw))
        domain = entry.get("domain")
        pipeline = entry.get("pipeline")
        if isinstance(domain, str) and domain.strip():
            route_domains.add(domain.strip())
            if domain.strip() not in domain_id_set:
                findings.append(
                    RepoConfigAuditFinding(
                        level="warn",
                        file=policy_file,
                        message=f"validation_policy.routes references unknown domain '{domain.strip()}'",
                        code="unknown_route_domain",
                        subject=domain.strip(),
                    )
                )
        if isinstance(pipeline, str) and pipeline.strip():
            referenced_pipelines.add(pipeline.strip())

    codegen_any = policy.get("codegen")
    codegen_entries = cast(list[object], codegen_any) if isinstance(codegen_any, list) else []
    for raw in codegen_entries:
        if not isinstance(raw, Mapping):
            continue
        entry = _mapping(cast(object, raw))
        pipeline = entry.get("pipeline")
        if isinstance(pipeline, str) and pipeline.strip():
            referenced_pipelines.add(pipeline.strip())

    pipelines_any = policy.get("pipelines")
    pipelines = _mapping(pipelines_any)
    pipeline_ids = set(pipelines.keys())
    for pipeline in sorted(referenced_pipelines - pipeline_ids):
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=policy_file,
                message=f"validation_policy.routes references missing pipeline '{pipeline}'",
                code="missing_pipeline",
                subject=pipeline,
            )
        )

    registry_file = "validation_registry.yaml"
    _warn_unknown_keys(
        findings,
        file=registry_file,
        prefix="",
        mapping=validation_registry_root,
        allowed={"version", "registry"},
    )
    registry = _mapping(validation_registry_root.get("registry"))
    _warn_unknown_keys(
        findings,
        file=registry_file,
        prefix="registry",
        mapping=registry,
        allowed={"includes", "commands", "processors"},
    )
    commands = _mapping(registry.get("commands"))
    processors = _mapping(registry.get("processors"))
    used_commands: set[str] = set()
    used_processors: set[str] = set()

    for pipeline_id, raw_pipeline in pipelines.items():
        if not isinstance(raw_pipeline, Mapping):
            continue
        pipeline = _mapping(cast(object, raw_pipeline))
        steps_any = pipeline.get("steps")
        steps = cast(list[object], steps_any) if isinstance(steps_any, list) else []
        for raw_step in steps:
            if not isinstance(raw_step, Mapping):
                continue
            step = _mapping(cast(object, raw_step))
            command_id = step.get("command_id")
            processor_id = step.get("processor_id")
            if isinstance(command_id, str) and command_id.strip():
                normalized = command_id.strip()
                used_commands.add(normalized)
                if normalized not in commands:
                    findings.append(
                        RepoConfigAuditFinding(
                            level="warn",
                            file=policy_file,
                            message=f"pipeline '{pipeline_id}' references unknown command '{normalized}'",
                            code="unknown_pipeline_command",
                            subject=normalized,
                        )
                    )
            if isinstance(processor_id, str) and processor_id.strip():
                normalized = processor_id.strip()
                used_processors.add(normalized)
                if normalized not in processors:
                    if normalized in _IMPLICIT_PROCESSORS or normalized.startswith("builtin:"):
                        used_processors.add(normalized)
                        continue
                    findings.append(
                        RepoConfigAuditFinding(
                            level="warn",
                            file=policy_file,
                            message=f"pipeline '{pipeline_id}' references unknown processor '{normalized}'",
                            code="unknown_pipeline_processor",
                            subject=normalized,
                        )
                    )

    for pipeline in sorted(pipeline_ids - referenced_pipelines):
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=policy_file,
                message=f"validation_policy.pipelines contains unused pipeline '{pipeline}'",
                code="unused_pipeline",
                subject=pipeline,
                fixable=True,
            )
        )
    for domain in sorted(domain_id_set - route_domains):
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=change_file,
                message=f"change_domains defines unused domain '{domain}'",
                code="unused_domain",
                subject=domain,
                fixable=True,
            )
        )
    for command in sorted(set(commands.keys()) - used_commands):
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=registry_file,
                message=f"registry.commands contains unused command '{command}'",
                code="unused_command",
                subject=command,
                fixable=True,
            )
        )
    for processor in sorted(set(processors.keys()) - used_processors):
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=registry_file,
                message=f"registry.processors contains unused processor '{processor}'",
                code="unused_processor",
                subject=processor,
                fixable=True,
            )
        )
    return findings


def _audit_rules_selection(*, root: dict[str, object]) -> list[RepoConfigAuditFinding]:
    findings: list[RepoConfigAuditFinding] = []
    file = "rules_selection.yaml"
    if not root:
        return findings
    _warn_unknown_keys(
        findings,
        file=file,
        prefix="",
        mapping=root,
        allowed={"version", "defaults", "include", "exclude", "presets", "use_presets"},
    )
    presets = _mapping(root.get("presets"))
    use_presets_any = root.get("use_presets")
    use_presets = cast(list[object], use_presets_any) if isinstance(use_presets_any, list) else []
    for item in use_presets:
        if isinstance(item, str) and item.strip() and item.strip() not in presets:
            findings.append(
                RepoConfigAuditFinding(
                    level="warn",
                    file=file,
                    message=f"use_presets references unknown preset '{item.strip()}'",
                )
            )
    return findings


def _warn_unknown_keys(
    findings: list[RepoConfigAuditFinding],
    *,
    file: str,
    prefix: str,
    mapping: dict[str, object],
    allowed: set[str],
) -> None:
    for key in sorted(mapping.keys() - allowed):
        full = f"{prefix}.{key}" if prefix else key
        findings.append(RepoConfigAuditFinding(level="warn", file=file, message=f"unknown key '{full}'"))


def _warn_missing_keys(
    findings: list[RepoConfigAuditFinding],
    *,
    file: str,
    prefix: str,
    mapping: dict[str, object],
    expected: set[str],
) -> None:
    for key in sorted(expected - set(mapping.keys())):
        full = f"{prefix}.{key}" if prefix else key
        findings.append(RepoConfigAuditFinding(level="warn", file=file, message=f"missing key '{full}'"))


def _warn_template_file(
    findings: list[RepoConfigAuditFinding],
    *,
    file: str,
    config_root: Path,
    prefix: str,
    value: object,
) -> None:
    if not isinstance(value, str) or not value.strip():
        return
    candidate = config_root / value.strip()
    if not candidate.exists():
        findings.append(
            RepoConfigAuditFinding(
                level="warn",
                file=file,
                message=f"configured template '{prefix}' points to missing file '{value.strip()}'",
            )
        )


def _warn_duplicates(
    findings: list[RepoConfigAuditFinding],
    *,
    file: str,
    prefix: str,
    values: list[str],
) -> None:
    seen: set[str] = set()
    duplicates: set[str] = set()
    for value in values:
        if value in seen:
            duplicates.add(value)
        seen.add(value)
    for value in sorted(duplicates):
        findings.append(RepoConfigAuditFinding(level="warn", file=file, message=f"duplicate value '{prefix}={value}'"))


def _load_mapping(path: Path) -> dict[str, object]:
    if not path.exists():
        return {}
    try:
        loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return _mapping(loaded)


def _mapping(raw: object) -> dict[str, object]:
    if not isinstance(raw, Mapping):
        return {}
    return {str(k): v for k, v in cast(Mapping[object, object], raw).items()}
