"""ABI Configuration Loader — layered resolution with validation.

Resolution order (highest priority wins):
  1. CLI arguments / explicit overrides
  2. Environment variables (ABI_* prefix)
  3. Environment overlay (e.g. configs/environments/local.yaml)
  4. Profile overlay (e.g. configs/profiles/strict_review.yaml)
  5. Base config from abi-config repo (configs/base/abi.config.yaml)
  6. Legacy project-level abi.config.yaml (fallback)
  7. User-level ~/.abi/config.yaml
  8. Built-in defaults

The loader prefers abi-config repo when ABI_CONFIG_REPO_ROOT is set or the repo
is discoverable as a sibling directory. Falls back to legacy abi.config.yaml
for backward compatibility.

Usage:
    from abi_control_core.sdk.config_loader import load_config

    cfg = load_config()                       # auto-discover
    cfg = load_config("path/to/config.yaml")  # explicit path
    model = cfg.get_model("claude-sonnet")    # look up model entry
    role = cfg.get_role("builder")            # look up runtime role
"""

from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

_SCHEMA_PATH = (
    Path(__file__).resolve().parent.parent.parent.parent / "schemas" / "abi_config.schema.json"
)

# ---------------------------------------------------------------------------
# Config data classes
# ---------------------------------------------------------------------------


@dataclass
class ProviderConfig:
    name: str
    api_key_env: str = ""
    models: list[str] = field(default_factory=list)
    enabled: bool = True
    status: str = "approved"  # approved | experimental | blocked


@dataclass
class ModelEntry:
    provider: str
    model_id: str
    input_cost_per_1m: float = 0.0
    output_cost_per_1m: float = 0.0
    context_window: int = 4096
    is_premium: bool = False
    capabilities: list[str] = field(default_factory=list)
    status: str = "active"  # active | candidate | deprecated | blocked | discovered
    added_date: str = ""  # ISO date string
    deprecated_date: str = ""  # ISO date string


@dataclass
class RoleAssignment:
    """Maps a runtime role to a model alias in the model registry."""
    model_alias: str
    provider: str = ""
    fallback_model_alias: str = ""


@dataclass
class BudgetConfig:
    max_total_calls: int = 10
    max_premium_calls: int = 6
    max_wall_time_seconds: int = 900
    max_cost_usd: float | None = None
    throttle_behavior: str = "abort"


@dataclass
class HILConfig:
    enabled: bool = True
    timeout_seconds: int = 300
    auto_approve_tiers: list[str] = field(default_factory=lambda: ["tier_0"])
    default_profile: str = "LOW"


@dataclass
class TelemetryConfig:
    enabled: bool = True
    output_dir: str = ".abi-runs"
    emit_tokens: bool = True
    emit_cost: bool = True


@dataclass
class PathsConfig:
    workspace_dir: str = ""
    output_dir: str = "runs"
    evidence_dir: str = ".abi-runs"


@dataclass
class ExecutionConfig:
    default_max_retries: int = 3
    max_verify_retries: int = 1
    timeout_per_invocation_seconds: int = 600


@dataclass
class VisualVerificationConfig:
    enabled: bool = False
    base_url: str = "http://localhost:3000"
    routes: list[list[str]] = field(default_factory=lambda: [["/", "home"], ["/portfolio", "portfolio"], ["/live", "live"], ["/settings", "settings"]])
    viewports: list[list] = field(default_factory=lambda: [["desktop", 1440, 900], ["tablet", 768, 1024], ["mobile", 390, 844]])
    healthcheck_timeout_seconds: int = 30
    capture_timeout_seconds: int = 120
    page_timeout_ms: int = 30000


@dataclass
class FleetScannerConfig:
    doc_max_age_days: int = 90
    required_governance_files: list[str] = field(default_factory=lambda: ["governance_manifest.json", "boundaries.yaml", "models.yaml", "git_policy.yaml"])
    api_timeout_ms: int = 300000


@dataclass
class VerificationConfig:
    mode: str = "full"
    specialists: list[str] = field(default_factory=lambda: ["correctness", "security", "requirements", "standards"])
    max_parallel_specialists: int = 4
    fail_on: str = "action_required"
    specialist_timeout_seconds: int = 120


@dataclass
class QualityWeights:
    test_coverage: float = 0.4
    doc_completeness: float = 0.2
    known_issues: float = 0.2
    governance: float = 0.2


@dataclass
class GradeThresholds:
    A: float = 90
    B: float = 80
    C: float = 70
    D: float = 60


@dataclass
class QualityConfig:
    weights: QualityWeights = field(default_factory=QualityWeights)
    grade_thresholds: GradeThresholds = field(default_factory=GradeThresholds)


@dataclass
class DiagnosticsConfig:
    build_output_chars: int = 2000
    verify_output_chars: int = 3000
    doom_loop_diff_chars: int = 5000
    max_findings_in_retry_prompt: int = 5


@dataclass
class PromotionConfig:
    enforcement_enabled: bool = False


@dataclass
class LaunchDefaultsConfig:
    builder_model: str = "claude-opus-4-6"
    verifier_model: str = "gpt-4.1"
    hil_profile: str = "medium"
    auto_commit: bool = True
    auto_push: bool = False


@dataclass
class PreCompletionConfig:
    """Pre-completion checks run before a task is marked done."""
    enabled: bool = True
    checks: list[str] = field(default_factory=lambda: [
        "no_todos", "imports_resolve", "test_file_exists",
        "no_debug_code", "type_hints_present", "no_hardcoded_secrets",
    ])
    fail_on_violation: bool = True


@dataclass
class DoomLoopConfig:
    """Doom loop detection — detects non-converging build/verify cycles."""
    enabled: bool = True
    threshold: float = 0.7
    max_consecutive_failures: int = 3
    escalate_to_hil: bool = True


@dataclass
class ArchitectEngageConfig:
    """Triggers for conditional architect engagement in the development phase."""
    changed_path_globs: list[str] = field(default_factory=lambda: [
        "**/migrations/**", "**/*.schema.json", "**/boundaries.yaml",
        "**/layers.yaml", "**/docs/architecture/**",
    ])
    task_types: list[str] = field(default_factory=lambda: [
        "api_change", "schema_change", "migration", "boundary_change",
        "cross_repo", "infra_change",
    ])
    verifier_categories: list[str] = field(default_factory=lambda: [
        "invariant_violation", "schema_drift", "boundary_violation",
    ])
    wave_exit: bool = True


@dataclass
class DevArchitectConfig:
    """Architect role configuration for the development phase."""
    enabled: bool = True
    mode: str = "conditional"  # "always" | "conditional" | "off"
    review_position: str = "post_verify"
    max_reviews_per_task: int = 1
    engage_on: ArchitectEngageConfig = field(default_factory=ArchitectEngageConfig)


@dataclass
class DevSpecialistsConfig:
    """Specialist verifier configuration for the development phase."""
    enabled: list[str] = field(default_factory=lambda: [
        "correctness", "security", "requirements", "standards",
    ])
    max_parallel: int = 4
    fail_on: str = "action_required"
    specialist_timeout_seconds: int = 120


@dataclass
class DevExecutionConfig:
    """Execution settings for the development phase."""
    max_builder_retries: int = 3
    max_verifier_retries: int = 1
    timeout_per_invocation_seconds: int = 600


@dataclass
class DevHILConfig:
    """HIL settings specific to the development phase."""
    on_architect_verifier_conflict: str = "required"
    on_repeated_rejection: str = "required"
    on_high_risk_change: str = "advisory"


@dataclass
class DevVerifierConfig:
    """Verifier configuration for the development phase."""
    specialists: DevSpecialistsConfig = field(default_factory=DevSpecialistsConfig)


@dataclass
class DevelopmentPhaseConfig:
    """Development phase configuration — single-builder, single-verifier, conditional architect."""
    enabled: bool = True
    execution: DevExecutionConfig = field(default_factory=DevExecutionConfig)
    architect: DevArchitectConfig = field(default_factory=DevArchitectConfig)
    verifier: DevVerifierConfig = field(default_factory=DevVerifierConfig)
    hil: DevHILConfig = field(default_factory=DevHILConfig)


@dataclass
class ConvergenceConfig:
    enabled: bool = False
    similarity_threshold: float = 0.95
    min_rounds: int = 2


@dataclass
class DeliberationPhaseConfig:
    """Configuration for one phase of the pre-development deliberation pipeline."""
    participants: list[str] = field(default_factory=lambda: ["claude-opus"])
    participant_count: int | None = None
    rounds: int = 3
    peer_exchange_mode: str = "all_to_all"
    artifact_type: str = "generic"
    prompt_strategy: str = "default"
    primary_decider: str = ""
    decision_policy: str = "primary_decider"
    human_review: str = "off"
    human_actions: list[str] = field(default_factory=lambda: ["approve", "reject", "modify"])
    max_tokens: int = 8192
    max_cost_usd: float = 5.0
    max_wall_time_seconds: int = 600
    max_concurrency: int = 4
    convergence: ConvergenceConfig = field(default_factory=ConvergenceConfig)
    enabled: bool = True


@dataclass
class PreDevelopmentConfig:
    """Pre-development deliberation pipeline configuration."""
    enabled: bool = False
    phases: dict[str, DeliberationPhaseConfig] = field(default_factory=dict)
    artifact_dir: str = ".abi-deliberation"
    global_max_cost_usd: float = 25.0
    global_max_wall_time_seconds: int = 3600


@dataclass
class ConfigProvenance:
    """Tracks where config values came from for debugging and auditing."""
    sources: list[str] = field(default_factory=list)
    config_repo_root: str = ""
    environment: str = ""
    profile: str = ""
    legacy_fallback: bool = False


@dataclass
class ABIConfig:
    """Merged ABI configuration from all layers."""

    version: str = "1.0"
    providers: dict[str, ProviderConfig] = field(default_factory=dict)
    models: dict[str, ModelEntry] = field(default_factory=dict)
    runtime_roles: dict[str, RoleAssignment] = field(default_factory=dict)
    budget: BudgetConfig = field(default_factory=BudgetConfig)
    hil: HILConfig = field(default_factory=HILConfig)
    telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
    paths: PathsConfig = field(default_factory=PathsConfig)
    project_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
    execution: ExecutionConfig = field(default_factory=ExecutionConfig)
    visual_verification: VisualVerificationConfig = field(default_factory=VisualVerificationConfig)
    fleet_scanner: FleetScannerConfig = field(default_factory=FleetScannerConfig)
    verification: VerificationConfig = field(default_factory=VerificationConfig)
    quality: QualityConfig = field(default_factory=QualityConfig)
    diagnostics: DiagnosticsConfig = field(default_factory=DiagnosticsConfig)
    promotion: PromotionConfig = field(default_factory=PromotionConfig)
    launch_defaults: LaunchDefaultsConfig = field(default_factory=LaunchDefaultsConfig)
    pre_completion: PreCompletionConfig = field(default_factory=PreCompletionConfig)
    doom_loop: DoomLoopConfig = field(default_factory=DoomLoopConfig)
    development: DevelopmentPhaseConfig = field(default_factory=DevelopmentPhaseConfig)
    pre_development: PreDevelopmentConfig = field(default_factory=PreDevelopmentConfig)
    provenance: ConfigProvenance = field(default_factory=ConfigProvenance)

    def get_model(self, alias: str) -> ModelEntry | None:
        """Look up a model by alias."""
        return self.models.get(alias)

    def get_provider(self, role: str) -> ProviderConfig | None:
        """Look up a provider by role (primary/secondary/local)."""
        return self.providers.get(role)

    def get_role(self, role_name: str) -> RoleAssignment | None:
        """Look up a runtime role assignment."""
        return self.runtime_roles.get(role_name)

    def resolve_role_model(self, role_name: str) -> ModelEntry | None:
        """Resolve a runtime role to its configured ModelEntry.

        Respects provider ``enabled`` toggles: if the resolved model's provider
        is disabled, falls back to the fallback model alias.  If both are
        disabled, returns None so the caller can surface a validation error.
        """
        assignment = self.get_role(role_name)
        if not assignment:
            return None

        model = self.get_model(assignment.model_alias)
        if model and not self._is_provider_enabled(model.provider):
            logger.warning(
                "Provider '%s' is disabled — skipping model '%s' for role '%s'",
                model.provider, assignment.model_alias, role_name,
            )
            model = None

        if model is None and assignment.fallback_model_alias:
            model = self.get_model(assignment.fallback_model_alias)
            if model and not self._is_provider_enabled(model.provider):
                logger.warning(
                    "Fallback provider '%s' also disabled for role '%s'",
                    model.provider, role_name,
                )
                model = None

        return model

    def get_active_models(self) -> dict[str, "ModelEntry"]:
        """Return models with status 'active' or 'candidate' (eligible for use)."""
        return {
            alias: m for alias, m in self.models.items()
            if m.status in ("active", "candidate")
        }

    def get_blocked_providers(self) -> list[str]:
        """Return canonical names of blocked providers."""
        return [
            p.name for p in self.providers.values()
            if p.status == "blocked"
        ]

    def get_selectable_models(self) -> dict[str, "ModelEntry"]:
        """Return models eligible for production selection (only status='active')."""
        return {
            alias: m for alias, m in self.models.items()
            if m.status == "active"
        }

    def validate_governance(self) -> list[str]:
        """Validate governance rules and return a list of violation messages.

        Rules checked:
        - Blocked/discovered/candidate model must not be in runtime_roles
        - Deprecated model in runtime_roles must have an active fallback
        - Role-assigned models must belong to approved providers
        - Fallback models must also be eligible (active status)
        - Blocked provider must not have enabled=True
        - Blocked provider must not have active models referencing it
        - Deprecated model must have a deprecated_date
        """
        issues: list[str] = []

        # Build provider name-to-status map
        provider_status: dict[str, str] = {}
        for key, prov in self.providers.items():
            provider_status[prov.name] = prov.status
            provider_status[key] = prov.status

        for role_name, assignment in self.runtime_roles.items():
            model = self.models.get(assignment.model_alias)
            if model and model.status in ("blocked", "discovered"):
                issues.append(
                    f"Role '{role_name}' uses {model.status} model '{assignment.model_alias}'"
                )
            if model and model.status == "candidate":
                issues.append(
                    f"Role '{role_name}' uses candidate model '{assignment.model_alias}'"
                )
            if model and model.status == "deprecated":
                fallback = self.models.get(assignment.fallback_model_alias)
                if not fallback or fallback.status != "active":
                    issues.append(
                        f"Role '{role_name}' uses deprecated model '{assignment.model_alias}' "
                        f"without a viable fallback"
                    )
            # Role-assigned model must belong to approved provider
            if model and model.provider:
                pstatus = provider_status.get(model.provider)
                if pstatus and pstatus != "approved":
                    issues.append(
                        f"Role '{role_name}' uses model '{assignment.model_alias}' from "
                        f"{pstatus}-status provider '{model.provider}'"
                    )
            # Fallback must also be eligible
            if assignment.fallback_model_alias:
                fb = self.models.get(assignment.fallback_model_alias)
                if fb and fb.status != "active":
                    issues.append(
                        f"Role '{role_name}' fallback '{assignment.fallback_model_alias}' "
                        f"has non-eligible status '{fb.status}'"
                    )

        for key, prov in self.providers.items():
            if prov.status == "blocked" and prov.enabled:
                issues.append(
                    f"Provider '{key}' ({prov.name}) is blocked but enabled=True"
                )
            if prov.status == "blocked":
                for alias, m in self.models.items():
                    if m.provider == prov.name and m.status == "active":
                        issues.append(
                            f"Blocked provider '{prov.name}' has active model '{alias}'"
                        )

        for alias, m in self.models.items():
            if m.status == "deprecated" and not m.deprecated_date:
                issues.append(
                    f"Deprecated model '{alias}' is missing deprecated_date"
                )

        return issues

    def _is_provider_enabled(self, provider_id: str) -> bool:
        """Check if a provider is enabled by its canonical name.

        Providers are keyed by role (primary/secondary/local) but models
        reference them by canonical name (anthropic/openai/ollama).  This
        method resolves by both dict key and the ``name`` field on each
        ProviderConfig so the toggle works regardless of keying convention.

        A provider with ``status='blocked'`` is always treated as disabled.
        """
        # Direct key lookup (works when providers are keyed by canonical name)
        provider = self.providers.get(provider_id)
        if provider is not None:
            return provider.enabled and provider.status != "blocked"
        # Resolve by provider name field (shipped config keys: primary/secondary/local)
        for prov in self.providers.values():
            if prov.name == provider_id:
                return prov.enabled and prov.status != "blocked"
        return True  # unknown provider — don't block


# ---------------------------------------------------------------------------
# YAML loading (optional dependency)
# ---------------------------------------------------------------------------


def _load_yaml(path: Path) -> dict[str, Any]:
    """Load a YAML file, falling back to JSON if PyYAML not available."""
    text = path.read_text(encoding="utf-8")
    if path.suffix in (".yaml", ".yml"):
        try:
            import yaml

            return yaml.safe_load(text) or {}
        except ImportError:
            logger.warning("PyYAML not installed; trying JSON parse for %s", path)
    return json.loads(text)


# ---------------------------------------------------------------------------
# Schema validation
# ---------------------------------------------------------------------------


def _find_schema() -> Path | None:
    """Find the best available schema file (prefer abi-config repo schema)."""
    repo_root = _discover_config_repo()
    if repo_root:
        repo_schema = repo_root / "schemas" / "abi_config.schema.json"
        if repo_schema.exists():
            return repo_schema
    if _SCHEMA_PATH.exists():
        return _SCHEMA_PATH
    return None


def _validate_config(data: dict[str, Any]) -> None:
    """Validate config data against the JSON Schema (best-effort)."""
    try:
        import jsonschema

        schema_path = _find_schema()
        if schema_path:
            schema = json.loads(schema_path.read_text(encoding="utf-8"))
            jsonschema.validate(instance=data, schema=schema)
    except ImportError:
        logger.debug("jsonschema not installed; skipping validation")
    except Exception as exc:
        logger.warning("Config validation warning: %s", exc)


# ---------------------------------------------------------------------------
# Environment variable overlay
# ---------------------------------------------------------------------------

_ENV_MAP = {
    "ABI_BUDGET_MAX_CALLS": ("budget", "max_total_calls", int),
    "ABI_BUDGET_MAX_PREMIUM": ("budget", "max_premium_calls", int),
    "ABI_BUDGET_MAX_WALL_TIME": ("budget", "max_wall_time_seconds", int),
    "ABI_BUDGET_MAX_COST": ("budget", "max_cost_usd", float),
    "ABI_HIL_ENABLED": ("hil", "enabled", lambda v: v.lower() in ("1", "true", "yes")),
    "ABI_HIL_TIMEOUT": ("hil", "timeout_seconds", int),
    "ABI_HIL_PROFILE": ("hil", "default_profile", lambda v: v.upper() if v.upper() in ("LOW", "MEDIUM", "HIGH") else "LOW"),
    "ABI_TELEMETRY_ENABLED": ("telemetry", "enabled", lambda v: v.lower() in ("1", "true", "yes")),
    "ABI_TELEMETRY_DIR": ("telemetry", "output_dir", str),
    "ABI_OUTPUT_DIR": ("paths", "output_dir", str),
    "ABI_WORKSPACE_DIR": ("paths", "workspace_dir", str),
    "ABI_PREDEV_ENABLED": ("pre_development", "enabled", lambda v: v.lower() in ("1", "true", "yes")),
}


def _apply_env_overrides(data: dict[str, Any]) -> None:
    """Apply ABI_* environment variables into config dict."""
    for env_key, (section, field_name, cast) in _ENV_MAP.items():
        val = os.environ.get(env_key)
        if val is not None:
            data.setdefault(section, {})[field_name] = cast(val)


# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------


def _parse_providers(raw: dict[str, Any]) -> dict[str, ProviderConfig]:
    result: dict[str, ProviderConfig] = {}
    for role, pdata in raw.items():
        if isinstance(pdata, dict):
            result[role] = ProviderConfig(
                name=pdata.get("name", role),
                api_key_env=pdata.get("api_key_env", ""),
                models=pdata.get("models", []),
                enabled=pdata.get("enabled", True),
                status=pdata.get("status", "approved"),
            )
    return result


def _parse_models(raw: dict[str, Any]) -> dict[str, ModelEntry]:
    result: dict[str, ModelEntry] = {}
    for alias, mdata in raw.items():
        if isinstance(mdata, dict):
            result[alias] = ModelEntry(
                provider=mdata.get("provider", ""),
                model_id=mdata.get("model_id", alias),
                input_cost_per_1m=mdata.get("input_cost_per_1m", 0.0),
                output_cost_per_1m=mdata.get("output_cost_per_1m", 0.0),
                context_window=mdata.get("context_window", 4096),
                is_premium=mdata.get("is_premium", False),
                capabilities=mdata.get("capabilities", []),
                status=mdata.get("status", "active"),
                added_date=str(mdata.get("added_date", "")),
                deprecated_date=str(mdata.get("deprecated_date", "")),
            )
    return result


def _parse_roles(raw: dict[str, Any]) -> dict[str, RoleAssignment]:
    result: dict[str, RoleAssignment] = {}
    for role_name, rdata in raw.items():
        if isinstance(rdata, dict):
            result[role_name] = RoleAssignment(
                model_alias=rdata.get("model_alias", ""),
                provider=rdata.get("provider", ""),
                fallback_model_alias=rdata.get("fallback_model_alias", ""),
            )
    return result


def _parse_budget(raw: dict[str, Any]) -> BudgetConfig:
    return BudgetConfig(
        max_total_calls=raw.get("max_total_calls", 10),
        max_premium_calls=raw.get("max_premium_calls", 6),
        max_wall_time_seconds=raw.get("max_wall_time_seconds", 900),
        max_cost_usd=raw.get("max_cost_usd"),
        throttle_behavior=raw.get("throttle_behavior", "abort"),
    )


def _parse_convergence(raw: dict[str, Any]) -> ConvergenceConfig:
    return ConvergenceConfig(
        enabled=raw.get("enabled", False),
        similarity_threshold=raw.get("similarity_threshold", 0.95),
        min_rounds=raw.get("min_rounds", 2),
    )


def _parse_deliberation_phase(raw: dict[str, Any]) -> DeliberationPhaseConfig:
    conv_raw = raw.get("convergence", {})
    return DeliberationPhaseConfig(
        participants=raw.get("participants", ["claude-opus"]),
        participant_count=raw.get("participant_count"),
        rounds=raw.get("rounds", 3),
        peer_exchange_mode=raw.get("peer_exchange_mode", "all_to_all"),
        artifact_type=raw.get("artifact_type", "generic"),
        prompt_strategy=raw.get("prompt_strategy", "default"),
        primary_decider=raw.get("primary_decider", ""),
        decision_policy=raw.get("decision_policy", "primary_decider"),
        human_review=raw.get("human_review", "off"),
        human_actions=raw.get("human_actions", ["approve", "reject", "modify"]),
        max_tokens=raw.get("max_tokens", 8192),
        max_cost_usd=raw.get("max_cost_usd", 5.0),
        max_wall_time_seconds=raw.get("max_wall_time_seconds", 600),
        max_concurrency=raw.get("max_concurrency", 4),
        convergence=_parse_convergence(conv_raw) if conv_raw else ConvergenceConfig(),
        enabled=raw.get("enabled", True),
    )


def _parse_pre_development(raw: dict[str, Any]) -> PreDevelopmentConfig:
    phases_raw = raw.get("phases", {})
    phases: dict[str, DeliberationPhaseConfig] = {}
    for phase_name, phase_data in phases_raw.items():
        if isinstance(phase_data, dict):
            phases[phase_name] = _parse_deliberation_phase(phase_data)
    return PreDevelopmentConfig(
        enabled=raw.get("enabled", False),
        phases=phases,
        artifact_dir=raw.get("artifact_dir", ".abi-deliberation"),
        global_max_cost_usd=raw.get("global_max_cost_usd", 25.0),
        global_max_wall_time_seconds=raw.get("global_max_wall_time_seconds", 3600),
    )


def _parse_development(raw: dict[str, Any]) -> DevelopmentPhaseConfig:
    """Parse the development phase configuration."""
    exec_raw = raw.get("execution", {})
    arch_raw = raw.get("architect", {})
    engage_raw = arch_raw.get("engage_on", {})
    ver_raw = raw.get("verifier", {})
    spec_raw = ver_raw.get("specialists", {})
    hil_raw = raw.get("hil", {})

    return DevelopmentPhaseConfig(
        enabled=raw.get("enabled", True),
        execution=DevExecutionConfig(
            max_builder_retries=exec_raw.get("max_builder_retries", 3),
            max_verifier_retries=exec_raw.get("max_verifier_retries", 1),
            timeout_per_invocation_seconds=exec_raw.get("timeout_per_invocation_seconds", 600),
        ),
        architect=DevArchitectConfig(
            enabled=arch_raw.get("enabled", True),
            mode=arch_raw.get("mode", "conditional"),
            review_position=arch_raw.get("review_position", "post_verify"),
            max_reviews_per_task=arch_raw.get("max_reviews_per_task", 1),
            engage_on=ArchitectEngageConfig(
                changed_path_globs=engage_raw.get("changed_path_globs", ArchitectEngageConfig().changed_path_globs),
                task_types=engage_raw.get("task_types", ArchitectEngageConfig().task_types),
                verifier_categories=engage_raw.get("verifier_categories", ArchitectEngageConfig().verifier_categories),
                wave_exit=engage_raw.get("wave_exit", True),
            ),
        ),
        verifier=DevVerifierConfig(
            specialists=DevSpecialistsConfig(
                enabled=spec_raw.get("enabled", DevSpecialistsConfig().enabled),
                max_parallel=spec_raw.get("max_parallel", 4),
                fail_on=spec_raw.get("fail_on", "action_required"),
                specialist_timeout_seconds=spec_raw.get("specialist_timeout_seconds", 120),
            ),
        ),
        hil=DevHILConfig(
            on_architect_verifier_conflict=hil_raw.get("on_architect_verifier_conflict", "required"),
            on_repeated_rejection=hil_raw.get("on_repeated_rejection", "required"),
            on_high_risk_change=hil_raw.get("on_high_risk_change", "advisory"),
        ),
    )


def _parse_config(data: dict[str, Any], provenance: ConfigProvenance | None = None) -> ABIConfig:
    """Parse a validated config dict into ABIConfig."""
    exec_raw = data.get("execution", {})
    vv_raw = data.get("visual_verification", {})
    fs_raw = data.get("fleet_scanner", {})
    ver_raw = data.get("verification", {})
    qual_raw = data.get("quality", {})
    diag_raw = data.get("diagnostics", {})
    promo_raw = data.get("promotion", {})
    launch_raw = data.get("launch_defaults", {})
    pre_comp_raw = data.get("pre_completion", {})
    doom_raw = data.get("doom_loop", {})
    dev_raw = data.get("development", {})
    predev_raw = data.get("pre_development", {})

    weights_raw = qual_raw.get("weights", {})
    thresholds_raw = qual_raw.get("grade_thresholds", {})

    return ABIConfig(
        version=data.get("version", "1.0"),
        providers=_parse_providers(data.get("providers", {})),
        models=_parse_models(data.get("models", {})),
        runtime_roles=_parse_roles(data.get("runtime_roles", {})),
        budget=_parse_budget(data.get("budget", {})),
        hil=HILConfig(
            enabled=data.get("hil", {}).get("enabled", True),
            timeout_seconds=data.get("hil", {}).get("timeout_seconds", 300),
            auto_approve_tiers=data.get("hil", {}).get("auto_approve_tiers", ["tier_0"]),
            default_profile=data.get("hil", {}).get("default_profile", "LOW"),
        ),
        telemetry=TelemetryConfig(
            enabled=data.get("telemetry", {}).get("enabled", True),
            output_dir=data.get("telemetry", {}).get("output_dir", ".abi-runs"),
            emit_tokens=data.get("telemetry", {}).get("emit_tokens", True),
            emit_cost=data.get("telemetry", {}).get("emit_cost", True),
        ),
        paths=PathsConfig(
            workspace_dir=data.get("paths", {}).get("workspace_dir", ""),
            output_dir=data.get("paths", {}).get("output_dir", "runs"),
            evidence_dir=data.get("paths", {}).get("evidence_dir", ".abi-runs"),
        ),
        project_overrides=data.get("project_overrides", {}),
        execution=ExecutionConfig(
            default_max_retries=exec_raw.get("default_max_retries", 3),
            max_verify_retries=exec_raw.get("max_verify_retries", 1),
            timeout_per_invocation_seconds=exec_raw.get("timeout_per_invocation_seconds", 600),
        ),
        visual_verification=VisualVerificationConfig(
            enabled=vv_raw.get("enabled", False),
            base_url=vv_raw.get("base_url", "http://localhost:3000"),
            routes=vv_raw.get("routes", [["/", "home"], ["/portfolio", "portfolio"], ["/live", "live"], ["/settings", "settings"]]),
            viewports=vv_raw.get("viewports", [["desktop", 1440, 900], ["tablet", 768, 1024], ["mobile", 390, 844]]),
            healthcheck_timeout_seconds=vv_raw.get("healthcheck_timeout_seconds", 30),
            capture_timeout_seconds=vv_raw.get("capture_timeout_seconds", 120),
            page_timeout_ms=vv_raw.get("page_timeout_ms", 30000),
        ),
        fleet_scanner=FleetScannerConfig(
            doc_max_age_days=fs_raw.get("doc_max_age_days", 90),
            required_governance_files=fs_raw.get("required_governance_files", ["governance_manifest.json", "boundaries.yaml", "models.yaml", "git_policy.yaml"]),
            api_timeout_ms=fs_raw.get("api_timeout_ms", 300000),
        ),
        verification=VerificationConfig(
            mode=ver_raw.get("mode", "full"),
            specialists=ver_raw.get("specialists", ["correctness", "security", "requirements", "standards"]),
            max_parallel_specialists=ver_raw.get("max_parallel_specialists", 4),
            fail_on=ver_raw.get("fail_on", "action_required"),
            specialist_timeout_seconds=ver_raw.get("specialist_timeout_seconds", 120),
        ),
        quality=QualityConfig(
            weights=QualityWeights(
                test_coverage=weights_raw.get("test_coverage", 0.4),
                doc_completeness=weights_raw.get("doc_completeness", 0.2),
                known_issues=weights_raw.get("known_issues", 0.2),
                governance=weights_raw.get("governance", 0.2),
            ),
            grade_thresholds=GradeThresholds(
                A=thresholds_raw.get("A", 90),
                B=thresholds_raw.get("B", 80),
                C=thresholds_raw.get("C", 70),
                D=thresholds_raw.get("D", 60),
            ),
        ),
        diagnostics=DiagnosticsConfig(
            build_output_chars=diag_raw.get("build_output_chars", 2000),
            verify_output_chars=diag_raw.get("verify_output_chars", 3000),
            doom_loop_diff_chars=diag_raw.get("doom_loop_diff_chars", 5000),
            max_findings_in_retry_prompt=diag_raw.get("max_findings_in_retry_prompt", 5),
        ),
        promotion=PromotionConfig(
            enforcement_enabled=promo_raw.get("enforcement_enabled", False),
        ),
        launch_defaults=LaunchDefaultsConfig(
            builder_model=launch_raw.get("builder_model", "claude-opus-4-6"),
            verifier_model=launch_raw.get("verifier_model", "gpt-4.1"),
            hil_profile=launch_raw.get("hil_profile", "medium"),
            auto_commit=launch_raw.get("auto_commit", True),
            auto_push=launch_raw.get("auto_push", False),
        ),
        pre_completion=PreCompletionConfig(
            enabled=pre_comp_raw.get("enabled", True),
            checks=pre_comp_raw.get("checks", [
                "no_todos", "imports_resolve", "test_file_exists",
                "no_debug_code", "type_hints_present", "no_hardcoded_secrets",
            ]),
            fail_on_violation=pre_comp_raw.get("fail_on_violation", True),
        ),
        doom_loop=DoomLoopConfig(
            enabled=doom_raw.get("enabled", True),
            threshold=doom_raw.get("threshold", 0.7),
            max_consecutive_failures=doom_raw.get("max_consecutive_failures", 3),
            escalate_to_hil=doom_raw.get("escalate_to_hil", True),
        ),
        development=_parse_development(dev_raw) if dev_raw else DevelopmentPhaseConfig(),
        pre_development=_parse_pre_development(predev_raw),
        provenance=provenance or ConfigProvenance(),
    )


# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------


def _discover_config_repo(start: Path | None = None) -> Path | None:
    """Find the abi-config repo root.

    Checks:
    1. ABI_CONFIG_REPO_ROOT env var
    2. Sibling directory of abi-infra (../../abi-config from this file)
    3. Walk up from start looking for abi-config/
    """
    # 1. Explicit env var
    env_root = os.environ.get("ABI_CONFIG_REPO_ROOT")
    if env_root:
        p = Path(env_root)
        if p.is_dir() and (p / "configs" / "base" / "abi.config.yaml").exists():
            return p

    # 2. Child of abi-infra: this file is in abi-infra/abi-core/src/.../config_loader.py
    #    abi-config lives at abi-infra/abi-config (sibling of abi-core inside the infra root)
    infra_root = Path(__file__).resolve().parent.parent.parent.parent.parent
    child = infra_root / "abi-config"
    if child.is_dir() and (child / "configs" / "base" / "abi.config.yaml").exists():
        return child

    # 3. Walk up from start
    search = start or Path.cwd()
    for directory in [search, *search.parents]:
        candidate = directory / "abi-config"
        if candidate.is_dir() and (candidate / "configs" / "base" / "abi.config.yaml").exists():
            return candidate

    return None


def _discover_legacy_config_path(start: Path | None = None) -> Path | None:
    """Walk up from start looking for abi.config.yaml (legacy fallback)."""
    search = start or Path.cwd()
    for directory in [search, *search.parents]:
        for name in ("abi.config.yaml", "abi.config.yml", "abi.config.json"):
            candidate = directory / name
            if candidate.is_file():
                return candidate
    return None


def _user_config_path() -> Path | None:
    """Return ~/.abi/config.yaml if it exists."""
    home = Path.home() / ".abi" / "config.yaml"
    return home if home.is_file() else None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def load_config(
    path: str | Path | None = None,
    *,
    config_repo_root: str | Path | None = None,
    environment: str | None = None,
    profile: str | None = None,
    project_dir: Path | None = None,
    overrides: dict[str, Any] | None = None,
) -> ABIConfig:
    """Load ABI configuration with layered resolution.

    Args:
        path: Explicit config file path. If None, auto-discovers.
        config_repo_root: Explicit path to abi-config repo root.
        environment: Environment name (local/dev/prod) for overlay.
        profile: Profile name (high_autonomy/strict_review) for overlay.
        project_dir: Start directory for config discovery.
        overrides: Dict of overrides applied last (highest priority).

    Returns:
        Merged ABIConfig instance with provenance metadata.
    """
    data: dict[str, Any] = {}
    provenance = ConfigProvenance()

    # Resolve environment from env var if not explicitly passed
    if environment is None:
        environment = os.environ.get("ABI_CONFIG_ENV")
    if profile is None:
        profile = os.environ.get("ABI_CONFIG_PROFILE")

    provenance.environment = environment or ""
    provenance.profile = profile or ""

    # Layer 7: user-level config
    user_path = _user_config_path()
    if user_path:
        logger.debug("Loading user config: %s", user_path)
        data = _deep_merge(data, _load_yaml(user_path))
        provenance.sources.append(f"user:{user_path}")

    if path is not None:
        # Explicit path — skip repo discovery
        config_path = Path(path)
        if not config_path.exists():
            raise FileNotFoundError(f"Config file not found: {config_path}")
        logger.info("Loading config: %s", config_path)
        data = _deep_merge(data, _load_yaml(config_path))
        provenance.sources.append(f"explicit:{config_path}")
    else:
        # Try repo-based config first
        repo_root = None
        if config_repo_root:
            repo_root = Path(config_repo_root)
        else:
            repo_root = _discover_config_repo(project_dir)

        if repo_root:
            provenance.config_repo_root = str(repo_root)

            # Layer 5: base config from abi-config repo
            base_path = repo_root / "configs" / "base" / "abi.config.yaml"
            if base_path.exists():
                logger.info("Loading repo base config: %s", base_path)
                data = _deep_merge(data, _load_yaml(base_path))
                provenance.sources.append(f"repo-base:{base_path}")

            # Layer 4: profile overlay
            if profile:
                profile_path = repo_root / "configs" / "profiles" / f"{profile}.yaml"
                if profile_path.exists():
                    logger.info("Loading profile overlay: %s", profile_path)
                    data = _deep_merge(data, _load_yaml(profile_path))
                    provenance.sources.append(f"profile:{profile_path}")

            # Layer 3: environment overlay
            if environment:
                env_path = repo_root / "configs" / "environments" / f"{environment}.yaml"
                if env_path.exists():
                    logger.info("Loading environment overlay: %s", env_path)
                    data = _deep_merge(data, _load_yaml(env_path))
                    provenance.sources.append(f"env:{env_path}")
        else:
            # Layer 6: legacy fallback — abi.config.yaml in project tree
            discovered = _discover_legacy_config_path(project_dir)
            if discovered:
                logger.info("Legacy fallback — auto-discovered config: %s", discovered)
                data = _deep_merge(data, _load_yaml(discovered))
                provenance.sources.append(f"legacy:{discovered}")
                provenance.legacy_fallback = True

    # Layer 2: environment variables
    _apply_env_overrides(data)

    # Layer 1: explicit overrides
    if overrides:
        data = _deep_merge(data, overrides)
        provenance.sources.append("overrides")

    # Validate
    _validate_config(data)

    return _parse_config(data, provenance)


def load_config_bundle(
    repo_root: str | Path,
    environment: str | None = None,
    profile: str | None = None,
) -> tuple[dict[str, Any], ConfigProvenance]:
    """Load raw config dict from an abi-config repo bundle.

    Useful for the dashboard API which needs both the parsed config
    and the raw YAML for editing.

    Returns:
        (merged_dict, provenance)
    """
    root = Path(repo_root)
    data: dict[str, Any] = {}
    provenance = ConfigProvenance(config_repo_root=str(root))

    base_path = root / "configs" / "base" / "abi.config.yaml"
    if base_path.exists():
        data = _deep_merge(data, _load_yaml(base_path))
        provenance.sources.append(f"repo-base:{base_path}")

    if profile:
        profile_path = root / "configs" / "profiles" / f"{profile}.yaml"
        if profile_path.exists():
            data = _deep_merge(data, _load_yaml(profile_path))
            provenance.sources.append(f"profile:{profile_path}")
        provenance.profile = profile

    if environment:
        env_path = root / "configs" / "environments" / f"{environment}.yaml"
        if env_path.exists():
            data = _deep_merge(data, _load_yaml(env_path))
            provenance.sources.append(f"env:{env_path}")
        provenance.environment = environment

    return data, provenance


def _deep_merge(base: dict, overlay: dict) -> dict:
    """Recursively merge overlay into base (overlay wins)."""
    result = dict(base)
    for key, val in overlay.items():
        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
            result[key] = _deep_merge(result[key], val)
        else:
            result[key] = val
    return result
