"""Tests for ConfigManager."""

from __future__ import annotations

import asyncio
import os
from pathlib import Path

import pytest
import yaml

from homesec.config.errors import (
    CameraAlreadyExistsError,
    CameraConfigInvalidError,
    CameraConfigRedactedPlaceholderError,
    CameraNotFoundError,
)
from homesec.config.loader import load_config_from_dict
from homesec.config.manager import ConfigManager


def _write_config(path: Path, cameras: list[dict[str, object]]) -> ConfigManager:
    payload = {
        "version": 1,
        "cameras": cameras,
        "storage": {"backend": "dropbox", "config": {"root": "/homecam"}},
        "state_store": {"dsn": "postgresql://user:pass@localhost/db"},
        "notifiers": [{"backend": "mqtt", "config": {"host": "localhost"}}],
        "filter": {"backend": "yolo", "config": {}},
        "vlm": {
            "backend": "openai",
            "config": {"api_key_env": "OPENAI_API_KEY", "model": "gpt-4o"},
        },
        "alert_policy": {"backend": "default", "config": {}},
    }
    path.write_text(yaml.safe_dump(payload, sort_keys=False))
    return ConfigManager(path)


@pytest.mark.asyncio
async def test_config_manager_add_update_remove_camera(tmp_path: Path) -> None:
    """ConfigManager should add, update, and remove cameras."""
    # Given a config with no cameras
    config_path = tmp_path / "config.yaml"
    manager = _write_config(config_path, cameras=[])

    # When adding a camera
    await manager.add_camera(
        name="front",
        enabled=True,
        source_backend="local_folder",
        source_config={"watch_dir": "/tmp"},
    )

    # Then the camera is persisted and backup exists
    config = manager.get_config()
    assert any(camera.name == "front" for camera in config.cameras)
    assert (config_path.with_suffix(".yaml.bak")).exists()

    # When updating the camera
    await manager.update_camera(
        camera_name="front",
        enabled=False,
        source_backend=None,
        source_config={"watch_dir": "/new"},
    )

    # Then the camera is updated
    config = manager.get_config()
    updated = next(camera for camera in config.cameras if camera.name == "front")
    assert updated.enabled is False
    assert updated.source.config["watch_dir"] == "/new"

    # When removing the camera
    await manager.remove_camera(camera_name="front")

    # Then the camera is removed
    config = manager.get_config()
    assert not config.cameras


@pytest.mark.asyncio
async def test_config_manager_add_duplicate_raises(tmp_path: Path) -> None:
    """ConfigManager should reject duplicate camera names."""
    # Given a config with one camera
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp"}},
            }
        ],
    )

    # When adding a duplicate camera
    with pytest.raises(CameraAlreadyExistsError):
        await manager.add_camera(
            name="front",
            enabled=True,
            source_backend="local_folder",
            source_config={"watch_dir": "/tmp"},
        )

    # Then it raises a validation error


@pytest.mark.asyncio
async def test_config_manager_update_missing_raises(tmp_path: Path) -> None:
    """ConfigManager should error when updating a missing camera."""
    # Given a config with no cameras
    config_path = tmp_path / "config.yaml"
    manager = _write_config(config_path, cameras=[])

    # When updating a missing camera
    with pytest.raises(CameraNotFoundError):
        await manager.update_camera(
            camera_name="missing",
            enabled=False,
            source_backend=None,
            source_config=None,
        )

    # Then it raises a validation error


@pytest.mark.asyncio
async def test_config_manager_remove_missing_raises(tmp_path: Path) -> None:
    """ConfigManager should error when removing a missing camera."""
    # Given a config with no cameras
    config_path = tmp_path / "config.yaml"
    manager = _write_config(config_path, cameras=[])

    # When removing a missing camera
    with pytest.raises(CameraNotFoundError):
        await manager.remove_camera(camera_name="missing")

    # Then it raises a validation error


@pytest.mark.asyncio
async def test_config_manager_invalid_update_raises(tmp_path: Path) -> None:
    """ConfigManager should reject invalid camera config updates."""
    # Given a config with one camera
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp"}},
            }
        ],
    )

    # When updating with invalid source config
    with pytest.raises(CameraConfigInvalidError):
        await manager.update_camera(
            camera_name="front",
            enabled=None,
            source_backend=None,
            source_config={"poll_interval": -1.0},
        )

    # Then it raises a validation error


@pytest.mark.asyncio
async def test_config_manager_add_camera_concurrent_updates_preserve_all_changes(
    tmp_path: Path,
) -> None:
    """ConfigManager should serialize concurrent camera adds to avoid lost updates."""
    # Given a config with no cameras
    config_path = tmp_path / "config.yaml"
    manager = _write_config(config_path, cameras=[])

    # When adding different cameras concurrently
    await asyncio.gather(
        manager.add_camera(
            name="front",
            enabled=True,
            source_backend="local_folder",
            source_config={"watch_dir": "/tmp/front"},
        ),
        manager.add_camera(
            name="back",
            enabled=True,
            source_backend="local_folder",
            source_config={"watch_dir": "/tmp/back"},
        ),
    )

    # Then both camera additions are preserved
    config = manager.get_config()
    names = {camera.name for camera in config.cameras}
    assert names == {"front", "back"}


@pytest.mark.asyncio
async def test_config_manager_update_camera_merges_source_config_and_preserves_existing_keys(
    tmp_path: Path,
) -> None:
    """Camera updates should merge source_config patches instead of replacing full config."""
    # Given a camera source config containing secret and non-secret fields
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {
                    "backend": "rtsp",
                    "config": {
                        "rtsp_url": "rtsp://user:pass@camera.local/stream",
                        "output_dir": "/tmp/front",
                        "stream": {"connect_timeout_s": 2.0, "io_timeout_s": 2.0},
                    },
                },
            }
        ],
    )

    # When applying a partial patch for one nested config field
    await manager.update_camera(
        camera_name="front",
        enabled=None,
        source_backend=None,
        source_config={"stream": {"connect_timeout_s": 5.0}},
    )

    # Then existing unrelated keys remain and only patched value changes
    config = manager.get_config()
    updated = next(camera for camera in config.cameras if camera.name == "front")
    assert updated.source.config["rtsp_url"] == "rtsp://user:pass@camera.local/stream"
    assert updated.source.config["output_dir"] == "/tmp/front"
    assert updated.source.config["stream"]["connect_timeout_s"] == 5.0
    assert updated.source.config["stream"]["io_timeout_s"] == 2.0


@pytest.mark.asyncio
async def test_config_manager_update_camera_rejects_redacted_placeholder_values(
    tmp_path: Path,
) -> None:
    """Camera update should reject redacted placeholders in source_config patches."""
    # Given a camera with a persisted source config
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {
                    "backend": "rtsp",
                    "config": {"rtsp_url": "rtsp://user:pass@camera.local/stream"},
                },
            }
        ],
    )

    # When applying a patch containing redacted placeholder marker
    with pytest.raises(CameraConfigRedactedPlaceholderError):
        await manager.update_camera(
            camera_name="front",
            enabled=None,
            source_backend=None,
            source_config={"rtsp_url": "rtsp://***redacted***@camera.local/stream"},
        )

    # Then persisted config remains unchanged
    config = manager.get_config()
    updated = next(camera for camera in config.cameras if camera.name == "front")
    assert updated.source.config["rtsp_url"] == "rtsp://user:pass@camera.local/stream"


@pytest.mark.asyncio
async def test_config_manager_update_camera_supports_backend_switch_with_new_config(
    tmp_path: Path,
) -> None:
    """Camera update should allow switching source backend when config patch is valid."""
    # Given a local-folder camera
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp/front"}},
            }
        ],
    )

    # When switching backend to RTSP with an RTSP source_config patch
    await manager.update_camera(
        camera_name="front",
        enabled=None,
        source_backend="rtsp",
        source_config={"rtsp_url": "rtsp://user:pass@camera.local/stream"},
    )

    # Then backend and source config are updated to the new source type
    config = manager.get_config()
    updated = next(camera for camera in config.cameras if camera.name == "front")
    assert updated.source.backend == "rtsp"
    assert updated.source.config["rtsp_url"] == "rtsp://user:pass@camera.local/stream"


@pytest.mark.asyncio
async def test_config_manager_update_camera_allows_null_clear_for_optional_source_fields(
    tmp_path: Path,
) -> None:
    """Null values in source_config patch should clear optional fields."""
    # Given an RTSP camera with optional detect stream URL configured
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "front",
                "enabled": True,
                "source": {
                    "backend": "rtsp",
                    "config": {
                        "rtsp_url": "rtsp://user:pass@camera.local/stream",
                        "detect_rtsp_url": "rtsp://user:pass@camera.local/detect",
                    },
                },
            }
        ],
    )

    # When clearing optional detect_rtsp_url via null patch value
    await manager.update_camera(
        camera_name="front",
        enabled=None,
        source_backend=None,
        source_config={"detect_rtsp_url": None},
    )

    # Then the optional field is removed while required fields remain
    config = manager.get_config()
    updated = next(camera for camera in config.cameras if camera.name == "front")
    assert "detect_rtsp_url" not in updated.source.config
    assert updated.source.config["rtsp_url"] == "rtsp://user:pass@camera.local/stream"


@pytest.mark.asyncio
async def test_config_manager_enforces_restrictive_file_modes_on_save(tmp_path: Path) -> None:
    """Config writes should enforce 0600 mode for config and backup files."""
    if os.name != "posix":
        pytest.skip("File mode enforcement semantics are POSIX-specific")

    # Given a permissive config file that will be mutated
    config_path = tmp_path / "config.yaml"
    manager = _write_config(config_path, cameras=[])
    os.chmod(config_path, 0o644)

    # When adding a camera (which writes config + backup)
    await manager.add_camera(
        name="front",
        enabled=True,
        source_backend="local_folder",
        source_config={"watch_dir": "/tmp/front"},
    )

    # Then config and backup are both restricted to owner read/write
    config_mode = config_path.stat().st_mode & 0o777
    backup_mode = config_path.with_suffix(".yaml.bak").stat().st_mode & 0o777
    assert config_mode == 0o600
    assert backup_mode == 0o600


@pytest.mark.asyncio
async def test_config_manager_first_save_creates_restrictive_mode_file(tmp_path: Path) -> None:
    """First-time save path should create config file with 0600 mode."""
    if os.name != "posix":
        pytest.skip("File mode enforcement semantics are POSIX-specific")

    # Given a manager pointing at a config path that does not yet exist
    config_path = tmp_path / "config.yaml"
    manager = ConfigManager(config_path)
    config = load_config_from_dict(
        {
            "version": 1,
            "cameras": [
                {
                    "name": "front_door",
                    "source": {
                        "backend": "local_folder",
                        "config": {"watch_dir": "recordings", "poll_interval": 1.0},
                    },
                }
            ],
            "storage": {"backend": "dropbox", "config": {"root": "/homecam"}},
            "state_store": {"dsn": "postgresql://user:pass@localhost/db"},
            "notifiers": [{"backend": "mqtt", "config": {"host": "localhost"}}],
            "filter": {"backend": "yolo", "config": {}},
            "vlm": {
                "backend": "openai",
                "config": {"api_key_env": "OPENAI_API_KEY", "model": "gpt-4o"},
            },
            "alert_policy": {"backend": "default", "config": {}},
        }
    )

    # When saving config for the first time
    await manager._save_config(config)

    # Then the created file exists and is owner read/write only
    assert config_path.exists()
    config_mode = config_path.stat().st_mode & 0o777
    assert config_mode == 0o600


@pytest.mark.asyncio
async def test_config_manager_replace_config_replaces_full_document(tmp_path: Path) -> None:
    """replace_config should atomically replace the full config after validation."""
    # Given a manager with an existing config file and a new validated config document
    config_path = tmp_path / "config.yaml"
    manager = _write_config(
        config_path,
        cameras=[
            {
                "name": "old",
                "enabled": True,
                "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp/old"}},
            }
        ],
    )
    new_config = load_config_from_dict(
        {
            "version": 1,
            "cameras": [
                {
                    "name": "new",
                    "enabled": True,
                    "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp/new"}},
                }
            ],
            "storage": {"backend": "local", "config": {"root": "./storage"}},
            "state_store": {"dsn_env": "DB_DSN"},
            "notifiers": [{"backend": "mqtt", "config": {"host": "localhost", "port": 1883}}],
            "filter": {"backend": "yolo", "config": {"classes": ["person"]}},
            "vlm": {
                "backend": "openai",
                "run_mode": "never",
                "config": {"api_key_env": "OPENAI_API_KEY", "model": "gpt-4o"},
            },
            "alert_policy": {"backend": "default", "config": {"min_risk_level": "high"}},
            "server": {"enabled": True, "host": "0.0.0.0", "port": 8081},
        }
    )

    # When replacing the full config document
    await manager.replace_config(new_config)

    # Then persisted config exactly reflects the replacement document
    persisted = manager.get_config()
    assert [camera.name for camera in persisted.cameras] == ["new"]
    assert persisted.storage.backend == "local"
