"""Tests for loom.skills.providers — LLM provider abstraction layer.

Covers:
- LLMResponse dataclass fields and immutability
- create_client factory for known and unknown providers
- AnthropicClient / OpenAIClient raise clear ImportError when SDK missing
- Config defaults and env var overrides (provider, base_url, api_key fallback)
- run_skill works end-to-end with a mock LLMClient
- Retry on truncation uses the same client interface
- Per-skill provider override creates the correct client
- Auth error detection is provider-aware
"""

from __future__ import annotations

import logging
from copy import copy
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from loom.config import SkillsConfig, load_config
from loom.exceptions import SkillError
from loom.skills.loader import SkillDefinition
from loom.skills.providers import (
    AnthropicClient,
    LLMResponse,
    OpenAIClient,
    create_client,
)


# ---------------------------------------------------------------------------
# Env vars to clear so host environment doesn't bleed into config tests
# ---------------------------------------------------------------------------

_ENV_VARS_TO_CLEAR = (
    "LOOM_SKILLS_API_KEY",
    "ANTHROPIC_API_KEY",
    "OPENAI_API_KEY",
    "LOOM_SKILLS_PROVIDER",
    "LOOM_SKILLS_BASE_URL",
    "LOOM_SKILLS_MODEL",
    "LOOM_DATABASE_URL",
    "LOOM_REDIS_URL",
    "LOOM_PROJECT_ID",
    "LOOM_LOG_LEVEL",
    "LOOM_MCP_PORT",
    "LOOM_HEALTH_PORT",
    "LOOM_CLAIM_TTL",
    "LOOM_MAX_RETRIES",
    "LOOM_RETRY_BASE_DELAY",
    "LOOM_RETRY_MAX_DELAY",
    "LOOM_SLACK_WEBHOOK_URL",
    "LOOM_OBSIDIAN_VAULT_PATH",
    "LOOM_AUTH_ENABLED",
    "LOOM_DATABASE_POOL_MIN",
    "LOOM_DATABASE_POOL_MAX",
    "LOOM_ENRICHMENT_CONCURRENCY",
)


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch, tmp_path):
    """Remove all Loom/cloud env vars and point HOME away from host YAML."""
    for var in _ENV_VARS_TO_CLEAR:
        monkeypatch.delenv(var, raising=False)
    monkeypatch.setenv("HOME", str(tmp_path))


# ---------------------------------------------------------------------------
# 1. LLMResponse dataclass
# ---------------------------------------------------------------------------


def test_llm_response_fields():
    """LLMResponse stores all four fields correctly."""
    resp = LLMResponse(text="hello", input_tokens=10, output_tokens=5, truncated=False)
    assert resp.text == "hello"
    assert resp.input_tokens == 10
    assert resp.output_tokens == 5
    assert resp.truncated is False


def test_llm_response_frozen():
    """LLMResponse is immutable (frozen dataclass)."""
    resp = LLMResponse(text="x", input_tokens=1, output_tokens=2, truncated=True)
    with pytest.raises((AttributeError, TypeError)):
        resp.text = "y"  # type: ignore[misc]


# ---------------------------------------------------------------------------
# 2. create_client factory
# ---------------------------------------------------------------------------


def test_create_client_anthropic():
    """Factory returns AnthropicClient when provider='anthropic'."""
    config = SkillsConfig(provider="anthropic", api_key="sk-test")
    # Patch AsyncAnthropic so no real network call is made
    with patch("loom.skills.providers.AnthropicClient.__init__", return_value=None):
        client = create_client(config)
    assert isinstance(client, AnthropicClient)


def test_create_client_openai():
    """Factory returns OpenAIClient when provider='openai'."""
    config = SkillsConfig(provider="openai", api_key="sk-oai-test")
    with patch("loom.skills.providers.OpenAIClient.__init__", return_value=None):
        client = create_client(config)
    assert isinstance(client, OpenAIClient)


def test_create_client_invalid_provider():
    """Factory raises ValueError for an unrecognised provider."""
    config = SkillsConfig(provider="unicorn")
    with pytest.raises(ValueError, match="Unknown LLM provider"):
        create_client(config)


# ---------------------------------------------------------------------------
# 3. Missing SDK — clear ImportError messages
# ---------------------------------------------------------------------------


def test_anthropic_missing_sdk():
    """AnthropicClient raises ImportError with helpful message when anthropic not installed."""
    with patch.dict("sys.modules", {"anthropic": None}):
        with pytest.raises(ImportError, match="anthropic"):
            AnthropicClient(api_key="x")


def test_openai_missing_sdk():
    """OpenAIClient raises ImportError with helpful message when openai not installed."""
    with patch.dict("sys.modules", {"openai": None}):
        with pytest.raises(ImportError, match="openai"):
            OpenAIClient(api_key="x")


# ---------------------------------------------------------------------------
# 4. Config defaults and env var overrides
# ---------------------------------------------------------------------------


def test_config_provider_default():
    """SkillsConfig defaults provider to 'anthropic'."""
    cfg = SkillsConfig()
    assert cfg.provider == "anthropic"


def test_config_base_url_default():
    """SkillsConfig defaults base_url to empty string."""
    cfg = SkillsConfig()
    assert cfg.base_url == ""


def test_config_provider_env_override(monkeypatch):
    """LOOM_SKILLS_PROVIDER env var overrides the default provider."""
    monkeypatch.setenv("LOOM_SKILLS_PROVIDER", "openai")
    config = load_config(project_dir=None)
    assert config.skills.provider == "openai"


def test_config_base_url_env_override(monkeypatch):
    """LOOM_SKILLS_BASE_URL env var sets base_url in config."""
    monkeypatch.setenv("LOOM_SKILLS_BASE_URL", "http://localhost:11434/v1")
    config = load_config(project_dir=None)
    assert config.skills.base_url == "http://localhost:11434/v1"


def test_config_openai_api_key_fallback(monkeypatch, caplog):
    """When provider=openai, OPENAI_API_KEY is used as fallback if LOOM_SKILLS_API_KEY not set."""
    monkeypatch.setenv("LOOM_SKILLS_PROVIDER", "openai")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-oai-fallback")
    with caplog.at_level(logging.DEBUG, logger="loom.config"):
        config = load_config(project_dir=None)
    assert config.skills.api_key == "sk-oai-fallback"
    assert "OPENAI_API_KEY" in caplog.text


def test_config_openai_loom_key_wins_over_openai_key(monkeypatch):
    """LOOM_SKILLS_API_KEY takes priority over OPENAI_API_KEY even for openai provider."""
    monkeypatch.setenv("LOOM_SKILLS_PROVIDER", "openai")
    monkeypatch.setenv("LOOM_SKILLS_API_KEY", "sk-loom-wins")
    monkeypatch.setenv("OPENAI_API_KEY", "sk-oai-loses")
    config = load_config(project_dir=None)
    assert config.skills.api_key == "sk-loom-wins"


def test_config_anthropic_api_key_fallback_still_works(monkeypatch, caplog):
    """When provider=anthropic (default), ANTHROPIC_API_KEY is still used as fallback."""
    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthro-fallback")
    with caplog.at_level(logging.DEBUG, logger="loom.config"):
        config = load_config(project_dir=None)
    assert config.skills.api_key == "sk-anthro-fallback"
    assert "ANTHROPIC_API_KEY" in caplog.text


# ---------------------------------------------------------------------------
# 5. run_skill with a mock LLMClient
# ---------------------------------------------------------------------------


def _make_skill(name: str = "test_skill", provider: str | None = None) -> SkillDefinition:
    return SkillDefinition(
        name=name,
        prompt_template='{"result": "ok"}',
        provider=provider,
    )


def _mock_client_returning(text: str, truncated: bool = False):
    """Return a mock that satisfies the LLMClient protocol."""
    resp = LLMResponse(text=text, input_tokens=10, output_tokens=5, truncated=truncated)
    mock = MagicMock()
    mock.complete = AsyncMock(return_value=resp)
    return mock


@pytest.mark.asyncio
async def test_run_skill_with_mock_client(monkeypatch):
    """run_skill works end-to-end when create_client is monkeypatched."""
    from loom.skills.runner import run_skill

    mock_client = _mock_client_returning('{"result": "ok"}')
    monkeypatch.setattr("loom.skills.runner.create_client", lambda cfg: mock_client)

    config = SkillsConfig(api_key="x")
    skill = _make_skill()
    result = await run_skill(skill, {}, config)

    assert result == {"result": "ok"}
    mock_client.complete.assert_awaited_once()


@pytest.mark.asyncio
async def test_truncation_retry_uses_same_client(monkeypatch):
    """When the first call is truncated, run_skill retries with doubled max_tokens."""
    from loom.skills.runner import run_skill

    # First call: truncated; second call: success
    truncated_resp = LLMResponse(
        text='{"result": "partial"}', input_tokens=5, output_tokens=5, truncated=True
    )
    full_resp = LLMResponse(
        text='{"result": "full"}', input_tokens=10, output_tokens=10, truncated=False
    )
    mock_client = MagicMock()
    mock_client.complete = AsyncMock(side_effect=[truncated_resp, full_resp])
    monkeypatch.setattr("loom.skills.runner.create_client", lambda cfg: mock_client)

    config = SkillsConfig(api_key="x", max_tokens=100)
    skill = _make_skill()
    result = await run_skill(skill, {}, config)

    assert result == {"result": "full"}
    assert mock_client.complete.await_count == 2
    # Second call should use doubled max_tokens (200), capped at 16384
    second_call_kwargs = mock_client.complete.await_args_list[1]
    assert second_call_kwargs.kwargs["max_tokens"] == 200


# ---------------------------------------------------------------------------
# 6. Per-skill provider override
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_per_skill_provider_override(monkeypatch):
    """SkillDefinition.provider overrides the config-level provider."""
    from loom.skills.runner import run_skill

    created_configs = []

    def capture_create(cfg):
        created_configs.append(copy(cfg))
        return _mock_client_returning('{"ok": true}')

    monkeypatch.setattr("loom.skills.runner.create_client", capture_create)

    config = SkillsConfig(provider="anthropic", api_key="x")
    skill = _make_skill(provider="openai")
    await run_skill(skill, {}, config)

    assert len(created_configs) == 1
    assert created_configs[0].provider == "openai"


@pytest.mark.asyncio
async def test_no_per_skill_provider_uses_config_provider(monkeypatch):
    """When skill.provider is None, the config-level provider is used."""
    from loom.skills.runner import run_skill

    created_configs = []

    def capture_create(cfg):
        created_configs.append(copy(cfg))
        return _mock_client_returning('{"ok": true}')

    monkeypatch.setattr("loom.skills.runner.create_client", capture_create)

    config = SkillsConfig(provider="openai", api_key="x")
    skill = _make_skill(provider=None)
    await run_skill(skill, {}, config)

    assert created_configs[0].provider == "openai"


# ---------------------------------------------------------------------------
# 7. Auth error detection is provider-aware
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_auth_error_anthropic_hint(monkeypatch):
    """Auth errors for the anthropic provider mention ANTHROPIC_API_KEY."""
    from loom.skills.runner import run_skill

    mock_client = MagicMock()
    mock_client.complete = AsyncMock(
        side_effect=Exception("Authentication error: invalid api_key")
    )
    monkeypatch.setattr("loom.skills.runner.create_client", lambda cfg: mock_client)

    config = SkillsConfig(provider="anthropic", api_key="bad-key")
    skill = _make_skill()
    with pytest.raises(SkillError) as exc_info:
        await run_skill(skill, {}, config)
    assert "ANTHROPIC_API_KEY" in str(exc_info.value)


@pytest.mark.asyncio
async def test_auth_error_openai_hint(monkeypatch):
    """Auth errors for the openai provider mention OPENAI_API_KEY."""
    from loom.skills.runner import run_skill

    mock_client = MagicMock()
    mock_client.complete = AsyncMock(
        side_effect=Exception("401 Unauthorized: api key invalid")
    )
    monkeypatch.setattr("loom.skills.runner.create_client", lambda cfg: mock_client)

    config = SkillsConfig(provider="openai", api_key="bad-oai-key")
    skill = _make_skill()
    with pytest.raises(SkillError) as exc_info:
        await run_skill(skill, {}, config)
    assert "OPENAI_API_KEY" in str(exc_info.value)


# ---------------------------------------------------------------------------
# 8. create_client raises SkillError on bad provider / missing SDK
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_skill_invalid_provider_raises_skill_error():
    """run_skill raises SkillError (not ValueError) when provider is unknown."""
    from loom.skills.runner import run_skill

    config = SkillsConfig(provider="unicorn", api_key="x")
    skill = _make_skill()
    with pytest.raises(SkillError, match="Unknown LLM provider"):
        await run_skill(skill, {}, config)


# ---------------------------------------------------------------------------
# 9. SkillDefinition parses provider from frontmatter
# ---------------------------------------------------------------------------


def test_skill_definition_provider_field():
    """SkillDefinition stores provider=None by default."""
    skill = SkillDefinition(name="foo", prompt_template="...")
    assert skill.provider is None


def test_skill_definition_provider_set():
    """SkillDefinition accepts an explicit provider."""
    skill = SkillDefinition(name="foo", prompt_template="...", provider="openai")
    assert skill.provider == "openai"


def test_parse_skill_file_provider_frontmatter(tmp_path):
    """parse_skill_file reads 'provider' from YAML frontmatter."""
    from loom.skills.loader import parse_skill_file

    skill_md = tmp_path / "my_skill.md"
    skill_md.write_text(
        "---\n"
        "name: my_skill\n"
        "provider: openai\n"
        "---\n"
        "Return {\"result\": \"ok\"}\n"
    )
    skill = parse_skill_file(skill_md)
    assert skill.provider == "openai"


def test_parse_skill_file_provider_missing_defaults_to_none(tmp_path):
    """parse_skill_file sets provider=None when not in frontmatter."""
    from loom.skills.loader import parse_skill_file

    skill_md = tmp_path / "plain.md"
    skill_md.write_text(
        "---\n"
        "name: plain\n"
        "---\n"
        "Hello\n"
    )
    skill = parse_skill_file(skill_md)
    assert skill.provider is None
