"""
Tests for hierarchical configuration system.

Tests config resolution hierarchy (global → tenant → bank),
key normalization, API endpoints, validation, and caching.
"""

import os

import pytest

from hindsight_api import MemoryEngine
from hindsight_api.config import HindsightConfig, normalize_config_dict, normalize_config_key
from hindsight_api.config_resolver import ConfigResolver
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext


class MockTenantExtension(TenantExtension):
    """Mock tenant extension for testing tenant-level config."""

    def __init__(self, tenant_config: dict):
        self.tenant_config = tenant_config

    async def authenticate(self, context):
        from hindsight_api.extensions.tenant import TenantContext

        return TenantContext(schema_name="public")

    async def list_tenants(self):
        from hindsight_api.extensions.tenant import Tenant

        return [Tenant(schema="public")]

    async def get_tenant_config(self, context):
        """Return mock tenant config."""
        return self.tenant_config


@pytest.mark.asyncio
async def test_config_key_normalization():
    """Test that env var keys are normalized to Python field names."""
    # Test basic normalization
    assert normalize_config_key("HINDSIGHT_API_LLM_PROVIDER") == "llm_provider"
    assert normalize_config_key("HINDSIGHT_API_LLM_MODEL") == "llm_model"
    assert normalize_config_key("HINDSIGHT_API_RETAIN_LLM_PROVIDER") == "retain_llm_provider"

    # Test already normalized keys
    assert normalize_config_key("llm_provider") == "llm_provider"
    assert normalize_config_key("llm_model") == "llm_model"

    # Test dict normalization
    input_dict = {
        "HINDSIGHT_API_LLM_PROVIDER": "openai",
        "HINDSIGHT_API_LLM_MODEL": "gpt-4",
        "llm_base_url": "https://api.openai.com",
    }
    expected = {"llm_provider": "openai", "llm_model": "gpt-4", "llm_base_url": "https://api.openai.com"}
    assert normalize_config_dict(input_dict) == expected


@pytest.mark.asyncio
async def test_hierarchical_fields_categorization():
    """Test that fields are correctly categorized as configurable, credentials, or static."""
    configurable = HindsightConfig.get_configurable_fields()
    credentials = HindsightConfig.get_credential_fields()
    static = HindsightConfig.get_static_fields()

    # Verify no overlap between configurable and credentials
    assert len(configurable & credentials) == 0, "Configurable fields should not include credentials"

    # Verify configurable fields include behavioral settings (safe to modify)
    assert "retain_extraction_mode" in configurable
    assert "retain_mission" in configurable
    assert "retain_custom_instructions" in configurable
    assert "retain_chunk_size" in configurable
    assert "enable_observations" in configurable
    assert "consolidation_llm_batch_size" in configurable
    assert "consolidation_source_facts_max_tokens" in configurable
    assert "consolidation_source_facts_max_tokens_per_observation" in configurable
    assert "observations_mission" in configurable
    assert "reflect_mission" in configurable
    assert "disposition_skepticism" in configurable
    assert "disposition_literalism" in configurable
    assert "disposition_empathy" in configurable

    # Verify entity labels fields are included
    assert "entities_allow_free_form" in configurable
    assert "entity_labels" in configurable

    # Verify other configurable fields
    assert "retain_default_strategy" in configurable
    assert "retain_strategies" in configurable
    assert "max_observations_per_scope" in configurable
    assert "reflect_source_facts_max_tokens" in configurable
    assert "llm_gemini_safety_settings" in configurable
    assert "mcp_enabled_tools" in configurable
    assert "retain_chunk_batch_size" in configurable

    # Verify count is correct
    assert len(configurable) == 35

    # Verify credential fields (NEVER exposed)
    assert "llm_api_key" in credentials
    assert "llm_base_url" in credentials
    assert "retain_llm_api_key" in credentials
    assert "reflect_llm_api_key" in credentials

    # Verify static fields include server settings AND non-configurable LLM fields
    assert "database_url" in static
    assert "port" in static
    assert "host" in static
    assert "embeddings_provider" in static
    assert "reranker_provider" in static
    assert "worker_enabled" in static
    assert "llm_provider" in static  # Not configurable (needs presets)
    assert "llm_model" in static  # Not configurable (needs presets)
    assert "graph_retriever" in static  # Performance tuning, not configurable
    assert "llm_max_concurrent" in static  # Performance tuning, not configurable


@pytest.mark.asyncio
async def test_config_hierarchy_resolution(memory, request_context):
    """Test that config resolution follows global → tenant → bank hierarchy."""
    bank_id = "test-hierarchy-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        # Set up mock tenant extension with tenant-level config (use configurable fields only)
        tenant_config = {"retain_chunk_size": 5000, "retain_extraction_mode": "tenant-mode"}
        mock_tenant = MockTenantExtension(tenant_config)

        # Create config resolver with mock tenant extension
        resolver = ConfigResolver(pool=memory._pool, tenant_extension=mock_tenant)

        # Test 1: Global config only (no overrides)
        context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
        config = await resolver.get_bank_config(bank_id, context)

        # Should have configurable fields from global config (NOT credentials or llm_provider/model)
        assert "retain_chunk_size" in config  # Configurable field
        assert "llm_api_key" not in config  # Credential - never exposed
        assert "llm_provider" not in config  # Not configurable (needs presets)

        # Test 2: Add tenant-level overrides
        config = await resolver.get_bank_config(bank_id, context)

        # Should apply tenant overrides (only configurable fields)
        assert config["retain_chunk_size"] == 5000  # Tenant override
        assert config["retain_extraction_mode"] == "tenant-mode"  # Tenant override

        # Test 3: Add bank-level overrides (should take precedence)
        await resolver.update_bank_config(
            bank_id,
            {"retain_chunk_size": 2000, "retain_extraction_mode": "bank-mode"},  # Override tenant settings
            context,
        )

        # Config should reflect changes immediately (no caching)
        config = await resolver.get_bank_config(bank_id, context)

        # Bank overrides should take precedence over tenant
        assert config["retain_chunk_size"] == 2000  # Bank override wins
        assert config["retain_extraction_mode"] == "bank-mode"  # Bank override wins

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_validation_rejects_static_fields(memory, request_context):
    """Test that attempting to override static fields raises ValueError."""
    bank_id = "test-validation-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Test 1: Configurable fields should work
        await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"})

        # Test 2: Static fields should raise ValueError
        with pytest.raises(ValueError, match="Cannot override static"):
            await resolver.update_bank_config(bank_id, {"port": 9000})

        with pytest.raises(ValueError, match="Cannot override static"):
            await resolver.update_bank_config(bank_id, {"database_url": "postgresql://fake"})

        with pytest.raises(ValueError, match="Cannot override static"):
            await resolver.update_bank_config(bank_id, {"embeddings_provider": "openai"})

        # Test 3: Credential fields should raise ValueError
        with pytest.raises(ValueError, match="Cannot set credential fields"):
            await resolver.update_bank_config(bank_id, {"llm_api_key": "sk-fake"})

        # Test 4: Non-configurable LLM fields should raise ValueError (need presets)
        with pytest.raises(ValueError, match="Cannot override static"):
            await resolver.update_bank_config(bank_id, {"llm_model": "gpt-4"})

        # Test 5: Mix of configurable and static should fail
        with pytest.raises(ValueError, match="Cannot override static"):
            await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "port": 9000})

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_validation_rejects_malformed_entity_labels(memory, request_context):
    """Test that passing strings instead of LabelGroup dicts to entity_labels raises ValueError.

    Regression test for the fix in PR #902: entity_labels PATCH must validate the
    format before saving to prevent silent corruption that previously caused 500s on
    subsequent retain calls (reported in issue #946).
    """
    bank_id = "test-entity-labels-validation"

    try:
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # String list instead of LabelGroup dicts must raise ValueError, not silently accept.
        # Previously this produced HTTP 200, then 500 on the next retain call (issue #946).
        with pytest.raises(ValueError, match="Invalid entity_labels format"):
            await resolver.update_bank_config(
                bank_id,
                {"entity_labels": ["person", "client", "tool"]},
            )

        # The correct LabelGroup format must succeed
        await resolver.update_bank_config(
            bank_id,
            {
                "entity_labels": [
                    {
                        "key": "kind",
                        "type": "value",
                        "values": [{"value": "person"}, {"value": "client"}],
                    }
                ]
            },
        )

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_freshness_across_updates(memory, request_context):
    """Test that config changes are immediately visible (no stale cache)."""
    bank1 = "freshness-test-1"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank1, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Test 1: Initial config reflects global defaults
        config1 = await resolver.get_bank_config(bank1, None)
        initial_chunk_size = config1["retain_chunk_size"]

        # Test 2: Update config
        await resolver.update_bank_config(bank1, {"retain_chunk_size": 4000})

        # Test 3: Next call should see updated value immediately (no stale cache)
        config2 = await resolver.get_bank_config(bank1, None)
        assert config2["retain_chunk_size"] == 4000

        # Test 4: Multiple updates are all immediately visible
        await resolver.update_bank_config(bank1, {"retain_chunk_size": 4500})
        config3 = await resolver.get_bank_config(bank1, None)
        assert config3["retain_chunk_size"] == 4500

        # Test 5: Reset restores global defaults immediately
        await resolver.reset_bank_config(bank1)
        config4 = await resolver.get_bank_config(bank1, None)
        assert config4["retain_chunk_size"] == initial_chunk_size  # Back to global default

        # Test 6: Each call returns a fresh config dict (not a cached reference)
        config5 = await resolver.get_bank_config(bank1, None)
        config6 = await resolver.get_bank_config(bank1, None)
        assert config5 is not config6  # Different object instances

    finally:
        await memory.delete_bank(bank1, request_context=request_context)


@pytest.mark.asyncio
async def test_config_reset_to_defaults(memory, request_context):
    """Test that resetting config removes all bank-specific overrides."""
    bank_id = "test-reset-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Add bank-specific overrides
        await resolver.update_bank_config(
            bank_id,
            {
                "retain_chunk_size": 5500,
                "retain_extraction_mode": "custom",
                "retain_custom_instructions": "Custom instructions",
            },
        )

        # Verify overrides applied
        config = await resolver.get_bank_config(bank_id, None)
        assert config["retain_chunk_size"] == 5500
        assert config["retain_extraction_mode"] == "custom"
        assert config["retain_custom_instructions"] == "Custom instructions"

        # Reset to defaults
        await resolver.reset_bank_config(bank_id)

        # Verify overrides removed (back to global defaults)
        config_reset = await resolver.get_bank_config(bank_id, None)
        assert config_reset["retain_chunk_size"] != 5500  # Should be global default
        assert config_reset["retain_extraction_mode"] != "custom"  # Should be global default

        # Verify bank_config is empty
        bank_overrides = await resolver._load_bank_config(bank_id)
        assert bank_overrides == {}

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_supports_both_key_formats(memory, request_context):
    """Test that API accepts both env var and Python field formats."""
    bank_id = "test-key-format-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Test 1: Python field format
        await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000})

        config = await resolver.get_bank_config(bank_id, None)
        assert config["retain_chunk_size"] == 7000

        # Test 2: Env var format (should be normalized)
        await resolver.update_bank_config(bank_id, {"HINDSIGHT_API_RETAIN_CHUNK_SIZE": 8000})

        config = await resolver.get_bank_config(bank_id, None)
        assert config["retain_chunk_size"] == 8000

        # Test 3: Mixed format in same request
        await resolver.update_bank_config(
            bank_id,
            {
                "retain_chunk_size": 9000,  # Python format
                "HINDSIGHT_API_RETAIN_EXTRACTION_MODE": "verbose",  # Env format
            },
        )

        config = await resolver.get_bank_config(bank_id, None)
        assert config["retain_chunk_size"] == 9000
        assert config["retain_extraction_mode"] == "verbose"

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_only_configurable_fields_stored(memory, request_context):
    """Test that only configurable fields are stored in bank config."""
    bank_id = "test-filter-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Add valid configurable field
        await resolver.update_bank_config(bank_id, {"retain_chunk_size": 3500})

        # Load bank config and verify only configurable fields present
        bank_overrides = await resolver._load_bank_config(bank_id)

        for key in bank_overrides.keys():
            assert key in HindsightConfig.get_configurable_fields(), f"Non-configurable field {key} in bank config"

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory, request_context):
    """
    SECURITY TEST: Verify get_bank_config() only returns configurable fields (no static/credentials).

    This prevents leaking sensitive system configuration like database URLs,
    API keys, LLM providers/models, worker counts, etc. when retrieving bank configuration.
    """
    bank_id = "test-security-bank"

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        resolver = ConfigResolver(pool=memory._pool)

        # Get bank config
        config = await resolver.get_bank_config(bank_id, None)

        # Get field categorizations
        configurable_fields = HindsightConfig.get_configurable_fields()
        credential_fields = HindsightConfig.get_credential_fields()
        static_fields = HindsightConfig.get_static_fields()

        # SECURITY: Verify ONLY configurable fields are returned (NO static, NO credentials)
        for key in config.keys():
            assert key in configurable_fields, (
                f"SECURITY VIOLATION: Non-configurable field '{key}' returned by get_bank_config(). "
                f"Only configurable fields should be returned to prevent leaking system config."
            )
            assert key not in credential_fields, (
                f"SECURITY VIOLATION: Credential field '{key}' returned by get_bank_config(). "
                f"Credentials must NEVER be exposed via API."
            )

        # SECURITY: Verify specific sensitive fields are NOT present
        sensitive_fields = [
            "database_url",
            "api_port",
            "host",
            "worker_count",  # Infrastructure
            "llm_api_key",
            "llm_base_url",  # Credentials
            "retain_llm_api_key",
            "reflect_llm_api_key",  # More credentials
            "llm_provider",
            "llm_model",  # Not configurable (need presets)
        ]
        for field in sensitive_fields:
            assert field not in config, (
                f"SECURITY VIOLATION: Sensitive field '{field}' returned by get_bank_config(). "
                f"Must not be exposed via bank config API."
            )

        # Verify we have the expected configurable fields (small set)
        expected_configurable = ["retain_chunk_size", "retain_extraction_mode", "enable_observations"]
        for field in expected_configurable:
            assert field in config, f"Expected configurable field '{field}' missing from config"

        # Should have a small number of configurable fields (not hundreds)
        assert len(config) < 50, f"Too many fields returned: {len(config)}"

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_config_permissions_system(memory, request_context):
    """
    Test that tenant extension can control which fields banks are allowed to modify.

    Tests get_allowed_config_fields() permission system.
    """
    bank_id = "test-permissions-bank"

    class PermissionTenantExtension(TenantExtension):
        """Mock tenant extension with configurable permissions."""

        def __init__(self, allowed_fields: set[str] | None):
            self.allowed_fields = allowed_fields

        async def authenticate(self, context):
            from hindsight_api.extensions.tenant import TenantContext

            return TenantContext(schema_name="public")

        async def list_tenants(self):
            from hindsight_api.extensions.tenant import Tenant

            return [Tenant(schema="public")]

        async def get_allowed_config_fields(self, context, bank_id):
            """Return configured allowed fields."""
            return self.allowed_fields

    try:
        # Ensure bank exists in database
        await memory.get_bank_profile(bank_id, request_context=request_context)

        # Test 1: None = allow all configurable fields
        extension = PermissionTenantExtension(allowed_fields=None)
        resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)

        await resolver.update_bank_config(
            bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"}, request_context
        )
        config = await resolver.get_bank_config(bank_id, request_context)
        assert config["retain_chunk_size"] == 4000
        assert config["retain_extraction_mode"] == "verbose"

        # Reset for next test
        await resolver.reset_bank_config(bank_id)

        # Test 2: Specific set = only those fields allowed
        extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size"})
        resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)

        # Should allow retain_chunk_size
        await resolver.update_bank_config(bank_id, {"retain_chunk_size": 5000}, request_context)
        config = await resolver.get_bank_config(bank_id, request_context)
        assert config["retain_chunk_size"] == 5000

        # Should reject retain_extraction_mode (not in allowed list)
        with pytest.raises(ValueError, match="Not allowed to modify fields"):
            await resolver.update_bank_config(bank_id, {"retain_extraction_mode": "verbose"}, request_context)

        # Should reject mix of allowed and disallowed
        with pytest.raises(ValueError, match="Not allowed to modify fields"):
            await resolver.update_bank_config(
                bank_id, {"retain_chunk_size": 6000, "retain_extraction_mode": "verbose"}, request_context
            )

        # Reset for next test
        await resolver.reset_bank_config(bank_id)

        # Test 3: Empty set = no modifications allowed (read-only)
        extension = PermissionTenantExtension(allowed_fields=set())
        resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)

        with pytest.raises(ValueError, match="Not allowed to modify fields"):
            await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000}, request_context)

        # Test 4: get_bank_config should filter response based on permissions
        extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size", "enable_observations"})
        resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)

        config = await resolver.get_bank_config(bank_id, request_context)

        # Should only return allowed fields
        assert "retain_chunk_size" in config
        assert "enable_observations" in config
        # Other configurable fields should be filtered out
        assert "retain_extraction_mode" not in config
        assert "retain_custom_instructions" not in config

    finally:
        await memory.delete_bank(bank_id, request_context=request_context)
