"""Tests for AppConfig, TaskRoute, and load_config."""
from __future__ import annotations

import json
import logging
import os
import re
import stat
import sys
from pathlib import Path

import pytest
from pydantic import ValidationError

from clawstu.orchestrator.config import (
    AppConfig,
    TaskRoute,
)
from clawstu.orchestrator.task_kinds import TaskKind


def test_task_route_is_frozen() -> None:
    route = TaskRoute(provider="ollama", model="llama3.2")
    with pytest.raises(ValidationError):
        route.provider = "anthropic"  # type: ignore[misc]


def test_task_route_defaults() -> None:
    route = TaskRoute(provider="ollama", model="llama3.2")
    assert route.max_tokens == 1024
    # Pin the actual shipped default rather than a loose range: the
    # validator allows up to 2.0, so a range check here would let a
    # future bump to 1.5 silently slip through.
    assert route.temperature == 0.2


def test_app_config_has_defaults_for_every_task_kind() -> None:
    cfg = AppConfig()
    for kind in TaskKind:
        assert kind in cfg.task_routing, f"missing default routing for {kind}"
        route = cfg.task_routing[kind]
        assert isinstance(route, TaskRoute)
        assert route.provider in (*cfg.fallback_chain, "echo"), (
            f"default routing for {kind} uses provider {route.provider!r} "
            f"which is not in fallback_chain {cfg.fallback_chain}"
        )


def test_app_config_default_data_dir_is_under_home() -> None:
    cfg = AppConfig()
    assert cfg.data_dir == Path.home() / ".claw-stu"


def test_app_config_default_primary_provider_is_ollama() -> None:
    cfg = AppConfig()
    assert cfg.primary_provider == "ollama"


def test_app_config_default_fallback_chain_ends_at_openrouter() -> None:
    cfg = AppConfig()
    assert cfg.fallback_chain == ("ollama", "openai", "anthropic", "openrouter")


def test_default_task_routing_matches_spec_table() -> None:
    """The §4.2.4 default routing table is the contract."""
    cfg = AppConfig()
    assert cfg.task_routing[TaskKind.SOCRATIC_DIALOGUE].provider == "ollama"
    assert cfg.task_routing[TaskKind.BLOCK_GENERATION].provider == "openrouter"
    assert cfg.task_routing[TaskKind.CHECK_GENERATION].provider == "openrouter"
    assert cfg.task_routing[TaskKind.RUBRIC_EVALUATION].provider == "anthropic"
    assert cfg.task_routing[TaskKind.PATHWAY_PLANNING].provider == "openrouter"
    assert cfg.task_routing[TaskKind.CONTENT_CLASSIFY].provider == "ollama"
    assert cfg.task_routing[TaskKind.DREAM_CONSOLIDATION].provider == "openrouter"
    # Model names per §4.2.4:
    assert cfg.task_routing[TaskKind.RUBRIC_EVALUATION].model == "claude-haiku-4-5"
    assert cfg.task_routing[TaskKind.BLOCK_GENERATION].model == "z-ai/glm-4.5-air"


def test_load_config_reads_env_var_api_keys(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    """Env var keys populate AppConfig.

    Isolation note: we point CLAW_STU_DATA_DIR at tmp_path so this
    test does not accidentally read a real ~/.claw-stu/secrets.json
    when Task 7 adds file-based loading. Without this isolation, the
    test's result would depend on whatever is in the executor's home
    directory, which is neither deterministic nor safe.
    """
    from clawstu.orchestrator.config import load_config

    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-abc")
    monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.test/v1")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-test-def")
    monkeypatch.setenv("OPENAI_BASE_URL", "https://openai.test/v1")
    monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-ghi")
    monkeypatch.setenv("OPENROUTER_BASE_URL", "https://openrouter.test/v1")
    monkeypatch.setenv("OLLAMA_API_KEY", "ollama-token")
    monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:22222")
    monkeypatch.setenv("STU_PRIMARY_PROVIDER", "openrouter")

    cfg = load_config()
    # Every env var row in load_config's docstring must have a matching
    # assertion here, otherwise a typo in env_map can ship silently.
    assert cfg.anthropic_api_key == "sk-ant-test-abc"
    assert cfg.anthropic_base_url == "https://anthropic.test/v1"
    assert cfg.openai_api_key == "sk-test-def"
    assert cfg.openai_base_url == "https://openai.test/v1"
    assert cfg.openrouter_api_key == "sk-or-test-ghi"
    assert cfg.openrouter_base_url == "https://openrouter.test/v1"
    assert cfg.ollama_api_key == "ollama-token"
    assert cfg.ollama_base_url == "http://localhost:22222"
    assert cfg.primary_provider == "openrouter"


def test_load_config_falls_back_to_defaults_without_env(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    # Unset every env var load_config reads so ambient environment
    # cannot pollute this test. If load_config grows a new env row,
    # add it here too.
    for key in (
        "ANTHROPIC_API_KEY",
        "ANTHROPIC_BASE_URL",
        "OPENAI_API_KEY",
        "OPENAI_BASE_URL",
        "OPENROUTER_API_KEY",
        "OPENROUTER_BASE_URL",
        "OLLAMA_API_KEY",
        "OLLAMA_BASE_URL",
        "STU_PRIMARY_PROVIDER",
        "CLAW_STU_DATA_DIR",
    ):
        monkeypatch.delenv(key, raising=False)
    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))

    cfg = load_config()
    assert cfg.anthropic_api_key is None
    assert cfg.openai_api_key is None
    assert cfg.openrouter_api_key is None
    assert cfg.ollama_api_key is None
    assert cfg.primary_provider == "ollama"  # default from AppConfig
    assert cfg.data_dir == tmp_path


def test_load_config_respects_claw_stu_data_dir(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path / "custom"))
    cfg = load_config()
    assert cfg.data_dir == tmp_path / "custom"


def test_load_config_reads_secrets_json(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    for key in (
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY",
    ):
        monkeypatch.delenv(key, raising=False)
    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))

    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text(json.dumps({
        "anthropic_api_key": "sk-ant-from-file",
        "openai_api_key": "sk-openai-from-file",
    }))
    if os.name != "nt":
        secrets_path.chmod(0o600)

    cfg = load_config()
    assert cfg.anthropic_api_key == "sk-ant-from-file"
    assert cfg.openai_api_key == "sk-openai-from-file"


def test_env_overrides_secrets_file(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    # Clear the other API key env vars so ambient environment cannot
    # pollute this test — we only want ANTHROPIC_API_KEY set so the
    # env-wins assertion is meaningful in isolation.
    for key in ("OPENAI_API_KEY", "OPENROUTER_API_KEY"):
        monkeypatch.delenv(key, raising=False)
    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env")

    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text(json.dumps({
        "anthropic_api_key": "sk-ant-from-file",
    }))
    if os.name != "nt":
        secrets_path.chmod(0o600)

    cfg = load_config()
    # Env wins.
    assert cfg.anthropic_api_key == "sk-ant-from-env"


def test_load_config_tolerates_missing_secrets_file(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    for key in (
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY",
    ):
        monkeypatch.delenv(key, raising=False)

    # No secrets.json created.
    cfg = load_config()
    assert cfg.anthropic_api_key is None


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perms only")
def test_load_config_warns_on_non_0600_secrets(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture,
) -> None:
    from clawstu.orchestrator.config import load_config

    # Ambient env may have ANTHROPIC_API_KEY set (even to an empty string,
    # which Task 6 documents as a valid "explicit emptiness" signal).
    # Delete it so the file value is the only source for that field and
    # the assertion below is a real test of the permissions path, not of
    # env-over-file precedence.
    for key in (
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY",
    ):
        monkeypatch.delenv(key, raising=False)
    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text(json.dumps({"anthropic_api_key": "sk-ant-whatever"}))
    secrets_path.chmod(0o644)  # too open

    with caplog.at_level(logging.WARNING, logger="clawstu.orchestrator.config"):
        cfg = load_config()

    assert cfg.anthropic_api_key == "sk-ant-whatever"
    assert any("0600" in record.message for record in caplog.records), (
        f"expected a 0600 warning; got: {[r.message for r in caplog.records]}"
    )


def test_load_config_rejects_malformed_secrets_json(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import load_config

    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text("{not valid json")
    if os.name != "nt":
        secrets_path.chmod(0o600)

    with pytest.raises(ValueError, match=re.escape("secrets.json")):
        load_config()


def test_load_config_rejects_typo_in_secrets_json(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    """A typo'd key in secrets.json must loud-fail, not silently no-op.

    Pydantic v2 defaults to silently ignoring unknown keys in
    model_validate, which would turn `anthropic_apii_key` (double-i
    typo) into a mystifying None instead of a clear error. AppConfig
    uses `extra="forbid"` to flip this behavior so the operator sees
    the typo immediately.
    """
    from clawstu.orchestrator.config import load_config

    for key in (
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY",
    ):
        monkeypatch.delenv(key, raising=False)
    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text(json.dumps({"anthropic_apii_key": "sk-typo"}))
    if os.name != "nt":
        secrets_path.chmod(0o600)

    with pytest.raises(ValidationError, match="anthropic_apii_key"):
        load_config()


def test_load_config_priority_chain_env_beats_file_beats_default(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    """Three-way priority chain: env > file > AppConfig default.

    - `openai_api_key`: only env set -> env value wins
    - `anthropic_api_key`: env + file set -> env wins
    - `openrouter_api_key`: only file set -> file wins
    - `ollama_base_url`: neither set -> default wins
    """
    from clawstu.orchestrator.config import load_config

    monkeypatch.setenv("CLAW_STU_DATA_DIR", str(tmp_path))
    monkeypatch.setenv("ANTHROPIC_API_KEY", "env-anthropic")
    monkeypatch.setenv("OPENAI_API_KEY", "env-openai")
    monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)

    secrets_path = tmp_path / "secrets.json"
    secrets_path.write_text(json.dumps({
        "anthropic_api_key": "file-anthropic",  # env wins over file
        "openrouter_api_key": "file-openrouter",  # file wins over default
    }))
    if os.name != "nt":
        secrets_path.chmod(0o600)

    cfg = load_config()
    assert cfg.anthropic_api_key == "env-anthropic"  # env
    assert cfg.openai_api_key == "env-openai"  # env (no file entry)
    assert cfg.openrouter_api_key == "file-openrouter"  # file
    assert cfg.ollama_base_url == "http://localhost:11434"  # default


def test_ensure_data_dir_creates_with_correct_mode(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import AppConfig, ensure_data_dir

    target = tmp_path / "fresh"
    assert not target.exists()

    cfg = AppConfig(data_dir=target)
    ensure_data_dir(cfg)

    assert target.exists()
    assert target.is_dir()
    if os.name != "nt":
        file_mode = stat.S_IMODE(target.stat().st_mode)
        assert file_mode == 0o700, f"expected 0700, got {file_mode:o}"


def test_ensure_data_dir_is_idempotent(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
    from clawstu.orchestrator.config import AppConfig, ensure_data_dir

    cfg = AppConfig(data_dir=tmp_path / "already-there")
    ensure_data_dir(cfg)
    ensure_data_dir(cfg)  # second call must not raise
    assert cfg.data_dir.exists()


def test_ensure_data_dir_preserves_existing_wider_perms(
    tmp_path: Path,
) -> None:
    """A pre-existing directory's permissions must NOT be rewritten.

    Someone deploying Claw-STU on a shared-family-machine may
    intentionally set ~/.claw-stu to 0750 so a guardian account in
    the same group can read the learner profile. If Stuart stomped
    0700 on every startup, that configuration would silently break.
    The early-exit guard in ensure_data_dir must preserve whatever
    mode the directory already has.
    """
    if os.name == "nt":
        pytest.skip("POSIX-only perms test")
    from clawstu.orchestrator.config import AppConfig, ensure_data_dir

    target = tmp_path / "preexisting"
    target.mkdir(mode=0o755)
    target.chmod(0o755)  # defeat umask on systems with restrictive default

    cfg = AppConfig(data_dir=target)
    ensure_data_dir(cfg)

    file_mode = stat.S_IMODE(target.stat().st_mode)
    assert file_mode == 0o755, (
        f"pre-existing dir perms were rewritten: expected 0755, got {file_mode:o}"
    )
