"""Tests for the settings audit log, rollback, per-option save, and worker sync features."""

import asyncio
from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.database import Base
from app.models import SettingsAuditLog

# ---------------------------------------------------------------------------
# Shared DB fixture
# ---------------------------------------------------------------------------


@pytest.fixture()
def db_session():
    engine = create_engine(
        "sqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    session = Session()
    try:
        yield session
    finally:
        session.close()
        Base.metadata.drop_all(bind=engine)


# ===========================================================================
# A) Audit log written on save
# ===========================================================================


@pytest.mark.unit
class TestAuditLogOnSave:
    """Audit log entries are created when settings are saved or deleted."""

    def test_save_creates_audit_entry(self, db_session):
        from app.utils.settings_service import save_setting_to_db

        result = save_setting_to_db(db_session, "workdir", "/new/path", changed_by="alice")

        assert result is True
        entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
        assert entry is not None
        assert entry.action == "update"
        assert entry.new_value == "/new/path"
        assert entry.changed_by == "alice"
        assert entry.old_value is None  # was not previously set

    def test_update_records_old_value(self, db_session):
        from app.utils.settings_service import save_setting_to_db

        # Set initial value
        save_setting_to_db(db_session, "workdir", "/old/path", changed_by="admin")
        # Update
        save_setting_to_db(db_session, "workdir", "/new/path", changed_by="bob")

        entries = db_session.query(SettingsAuditLog).filter_by(key="workdir").all()
        assert len(entries) == 2
        # Second entry should have old_value from first write
        update_entry = entries[1]
        assert update_entry.old_value == "/old/path"
        assert update_entry.new_value == "/new/path"

    def test_delete_creates_audit_entry(self, db_session):
        from app.utils.settings_service import delete_setting_from_db, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/some/path", changed_by="admin")
        result = delete_setting_from_db(db_session, "workdir", changed_by="carol")

        assert result is True
        delete_entry = db_session.query(SettingsAuditLog).filter_by(key="workdir", action="delete").first()
        assert delete_entry is not None
        assert delete_entry.old_value == "/some/path"
        assert delete_entry.new_value is None
        assert delete_entry.changed_by == "carol"

    def test_delete_nonexistent_returns_false_no_entry(self, db_session):
        from app.utils.settings_service import delete_setting_from_db

        result = delete_setting_from_db(db_session, "nonexistent_key", changed_by="admin")

        assert result is False
        assert db_session.query(SettingsAuditLog).count() == 0

    def test_default_changed_by_is_system(self, db_session):
        from app.utils.settings_service import save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/tmp")

        entry = db_session.query(SettingsAuditLog).first()
        assert entry.changed_by == "system"


# ===========================================================================
# C) Audit log retrieval
# ===========================================================================


@pytest.mark.unit
class TestGetAuditLog:
    """get_audit_log returns entries, masks sensitive values."""

    def test_returns_all_entries_most_recent_first(self, db_session):
        from app.utils.settings_service import get_audit_log, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/first", changed_by="u1")
        save_setting_to_db(db_session, "workdir", "/second", changed_by="u2")

        log = get_audit_log(db_session, limit=100)

        assert len(log) == 2
        # Most recent first
        assert log[0]["new_value"] == "/second"
        assert log[1]["new_value"] == "/first"

    def test_sensitive_values_are_masked(self, db_session):
        from app.utils.settings_service import get_audit_log, save_setting_to_db

        save_setting_to_db(db_session, "openai_api_key", "sk-secret123", changed_by="admin")

        log = get_audit_log(db_session)

        entry = next(e for e in log if e["key"] == "openai_api_key")
        assert entry["new_value"] == "[REDACTED]"

    def test_required_fields_present(self, db_session):
        from app.utils.settings_service import get_audit_log, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/path", changed_by="alice")

        log = get_audit_log(db_session)

        assert len(log) == 1
        entry = log[0]
        for field in (
            "id",
            "key",
            "old_value",
            "new_value",
            "changed_by",
            "changed_at",
            "action",
        ):
            assert field in entry

    def test_limit_and_offset(self, db_session):
        from app.utils.settings_service import get_audit_log, save_setting_to_db

        for i in range(5):
            save_setting_to_db(db_session, "workdir", f"/path{i}", changed_by="admin")

        first_page = get_audit_log(db_session, limit=3, offset=0)
        second_page = get_audit_log(db_session, limit=3, offset=3)

        assert len(first_page) == 3
        assert len(second_page) == 2


# ===========================================================================
# C) Per-key history
# ===========================================================================


@pytest.mark.unit
class TestGetSettingHistory:
    """get_setting_history returns only entries for the requested key."""

    def test_returns_only_matching_key(self, db_session):
        from app.utils.settings_service import get_setting_history, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/wdir", changed_by="admin")
        save_setting_to_db(db_session, "debug", "true", changed_by="admin")

        history = get_setting_history(db_session, "workdir")

        assert len(history) == 1
        assert history[0]["key"] == "workdir"

    def test_returns_empty_list_for_unknown_key(self, db_session):
        from app.utils.settings_service import get_setting_history

        history = get_setting_history(db_session, "totally_unknown_key")

        assert history == []


# ===========================================================================
# D) Rollback
# ===========================================================================


@pytest.mark.unit
class TestRollbackSetting:
    """rollback_setting reinstates the value from a given audit log entry."""

    def test_rollback_to_previous_value(self, db_session):
        """Rolling back an entry restores the old_value (the value *before* that change)."""
        from app.utils.settings_service import get_setting_from_db, rollback_setting, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")  # entry 1: old=None, new=/v1
        save_setting_to_db(db_session, "workdir", "/v2", changed_by="admin")  # entry 2: old=/v1, new=/v2

        # Rolling back entry 2 should undo the /v1→/v2 change and restore /v1
        second_entry = (
            db_session.query(SettingsAuditLog).filter_by(key="workdir").order_by(SettingsAuditLog.id.desc()).first()
        )
        success = rollback_setting(db_session, "workdir", second_entry.id, changed_by="rollbacker")

        assert success is True
        current = get_setting_from_db(db_session, "workdir")
        assert current == "/v1"

    def test_rollback_deletes_setting_when_old_value_is_none(self, db_session):
        """Rolling back the first-ever entry (old_value=None) deletes the setting from DB."""
        from app.utils.settings_service import get_setting_from_db, rollback_setting, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")  # old=None, new=/v1

        first_entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
        success = rollback_setting(db_session, "workdir", first_entry.id, changed_by="rollbacker")

        assert success is True
        # Setting should be removed from DB (fallback to ENV/default)
        current = get_setting_from_db(db_session, "workdir")
        assert current is None

    def test_rollback_creates_new_audit_entry(self, db_session):
        from app.utils.settings_service import rollback_setting, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")
        entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()

        initial_count = db_session.query(SettingsAuditLog).count()
        rollback_setting(db_session, "workdir", entry.id, changed_by="rollbacker")

        assert db_session.query(SettingsAuditLog).count() == initial_count + 1

    def test_rollback_wrong_history_id_returns_false(self, db_session):
        from app.utils.settings_service import rollback_setting, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")

        result = rollback_setting(db_session, "workdir", 9999, changed_by="admin")

        assert result is False

    def test_rollback_wrong_key_returns_false(self, db_session):
        from app.utils.settings_service import rollback_setting, save_setting_to_db

        save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")
        entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()

        # Pass wrong key for the history ID
        result = rollback_setting(db_session, "debug", entry.id, changed_by="admin")

        assert result is False


# ===========================================================================
# B) Worker sync – settings_sync module
# ===========================================================================


@pytest.mark.unit
class TestNotifySettingsUpdated:
    """notify_settings_updated publishes the settings version key to Redis."""

    def test_sets_redis_key(self):
        from app.utils.settings_sync import SETTINGS_VERSION_KEY, notify_settings_updated

        mock_redis = MagicMock()
        mock_redis_instance = MagicMock()
        mock_redis.return_value = mock_redis_instance

        with patch("app.utils.settings_sync.redis") as mock_redis_module:
            mock_redis_module.from_url.return_value = mock_redis_instance
            notify_settings_updated()

        mock_redis_instance.set.assert_called_once()
        call_args = mock_redis_instance.set.call_args[0]
        assert call_args[0] == SETTINGS_VERSION_KEY

    @patch("app.utils.settings_sync.logger")
    def test_does_not_raise_on_redis_failure(self, mock_logger):
        """notify_settings_updated must not propagate Redis errors."""
        from app.utils.settings_sync import notify_settings_updated

        with patch("app.utils.settings_sync.redis") as mock_redis_module:
            mock_redis_module.from_url.side_effect = Exception("Redis down")
            # Should not raise
            notify_settings_updated()
            mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")

    @patch("app.utils.settings_sync.logger")
    def test_does_not_raise_on_reload_failure(self, mock_logger):
        """notify_settings_updated must not propagate settings reload errors."""
        from app.utils.settings_sync import notify_settings_updated

        with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
            mock_reload.side_effect = Exception("Reload error")
            # We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
            with patch("app.utils.settings_sync.redis"):
                with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
                    # Should not raise
                    notify_settings_updated()
            mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")

    @patch("app.utils.settings_sync.logger")
    def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
        """notify_settings_updated must not propagate OCR language check errors."""
        from app.utils.settings_sync import notify_settings_updated

        with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
            mock_ensure.side_effect = Exception("OCR error")
            # We mock redis and reload_settings_from_db so we only test the OCR block failure.
            with patch("app.utils.settings_sync.redis"):
                with patch("app.utils.config_loader.reload_settings_from_db"):
                    # Should not raise
                    notify_settings_updated()
            mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")


@pytest.mark.unit
class TestRegisterSettingsReloadSignal:
    """register_settings_reload_signal installs a task_prerun handler."""

    def test_registers_without_error(self):
        from app.utils.settings_sync import register_settings_reload_signal

        with patch("app.utils.settings_sync.task_prerun") as mock_signal:
            mock_signal.connect = MagicMock()
            # Call it – the decorator calls task_prerun.connect(weak=False)
            register_settings_reload_signal()
        # If no exception is raised the registration succeeded


# ===========================================================================
# API endpoint – audit log
# ===========================================================================


@pytest.mark.integration
class TestAuditLogEndpoint:
    """GET /api/settings/audit-log requires admin access."""

    def test_requires_admin(self, client):
        response = client.get("/api/settings/audit-log")
        assert response.status_code in [302, 401, 403]

    @patch("app.api.settings.get_audit_log")
    def test_returns_entries_for_admin(self, mock_get_log):
        from app.api.settings import list_audit_log

        mock_get_log.return_value = [
            {
                "id": 1,
                "key": "workdir",
                "old_value": None,
                "new_value": "/tmp",
                "changed_by": "admin",
                "changed_at": "2024-01-01T00:00:00",
                "action": "update",
            }
        ]

        mock_request = MagicMock()
        mock_db = MagicMock()
        mock_admin = {"is_admin": True}

        result = asyncio.run(list_audit_log(mock_request, mock_db, mock_admin))

        assert "entries" in result
        assert len(result["entries"]) == 1
        assert result["entries"][0]["key"] == "workdir"


@pytest.mark.integration
class TestHistoryEndpoint:
    """GET /api/settings/{key}/history requires admin access."""

    def test_requires_admin(self, client):
        response = client.get("/api/settings/workdir/history")
        assert response.status_code in [302, 401, 403]

    @patch("app.api.settings.get_setting_history")
    def test_returns_history_for_admin(self, mock_get_history):
        from app.api.settings import get_key_history

        mock_get_history.return_value = [
            {
                "id": 1,
                "key": "workdir",
                "old_value": None,
                "new_value": "/tmp",
                "changed_by": "admin",
                "changed_at": "2024-01-01T00:00:00",
                "action": "update",
            }
        ]

        mock_request = MagicMock()
        mock_db = MagicMock()
        mock_admin = {"is_admin": True}

        result = asyncio.run(get_key_history("workdir", mock_request, mock_db, mock_admin))

        assert result["key"] == "workdir"
        assert len(result["history"]) == 1


@pytest.mark.integration
class TestRollbackEndpoint:
    """POST /api/settings/{key}/rollback/{history_id} requires admin access."""

    def test_requires_admin(self, client):
        response = client.post("/api/settings/workdir/rollback/1")
        assert response.status_code in [302, 401, 403]

    @patch("app.api.settings.notify_settings_updated")
    @patch("app.api.settings.rollback_setting")
    def test_rollback_success(self, mock_rollback, mock_notify):
        from app.api.settings import rollback_setting_to_history

        mock_rollback.return_value = True
        mock_request = MagicMock()
        mock_request.session = {"user": {"preferred_username": "admin"}}
        mock_db = MagicMock()
        mock_admin = {"is_admin": True}

        result = asyncio.run(rollback_setting_to_history("workdir", 1, mock_request, mock_db, mock_admin))

        assert result["success"] is True
        mock_notify.assert_called_once()

    @patch("app.api.settings.rollback_setting")
    def test_rollback_not_found_raises_404(self, mock_rollback):
        import asyncio

        from fastapi import HTTPException

        from app.api.settings import rollback_setting_to_history

        mock_rollback.return_value = False
        mock_request = MagicMock()
        mock_request.session = {"user": {"preferred_username": "admin"}}
        mock_db = MagicMock()
        mock_admin = {"is_admin": True}

        with pytest.raises(HTTPException) as exc_info:
            asyncio.run(rollback_setting_to_history("workdir", 9999, mock_request, mock_db, mock_admin))

        assert exc_info.value.status_code == 404
