"""Comprehensive tests for helper / pure-logic functions used by research_routes.

Covers:
- calculate_duration (timestamp parsing, duration calculation)
- Error type classification logic from get_research_status
- find_blocked_keys (security key detection in TOML configs)
- Date placeholder replacement logic
- globals.py thread-safe state helpers
"""

import threading
from datetime import datetime, UTC, timedelta

import pytest

# ---------------------------------------------------------------------------
# calculate_duration
# ---------------------------------------------------------------------------
from local_deep_research.web.models.database import calculate_duration


class TestCalculateDuration:
    """Tests for calculate_duration helper."""

    def test_iso_format_both_timestamps(self):
        created = "2025-01-01T10:00:00+00:00"
        completed = "2025-01-01T10:05:00+00:00"
        assert calculate_duration(created, completed) == 300

    def test_iso_format_without_timezone_returns_none(self):
        """Naive ISO timestamps cause a TypeError in subtraction because
        end_time becomes aware via .astimezone(UTC) but start_time stays
        naive. The function catches this and returns None."""
        created = "2025-06-01T12:00:00"
        completed = "2025-06-01T12:10:00"
        result = calculate_duration(created, completed)
        assert result is None

    def test_space_separated_format_with_microseconds(self):
        """Naive timestamps parsed via strptime: end_time becomes aware via
        .astimezone(UTC), start_time stays naive, subtraction fails."""
        created = "2025-01-01 10:00:00.000000"
        completed = "2025-01-01 11:00:00.000000"
        assert calculate_duration(created, completed) is None

    def test_space_separated_format_without_microseconds(self):
        """Same naive/aware mismatch for space-separated without microseconds."""
        created = "2025-01-01 10:00:00"
        completed = "2025-01-01 10:30:00"
        assert calculate_duration(created, completed) is None

    def test_none_created_at_returns_none(self):
        assert calculate_duration(None) is None
        assert calculate_duration("") is None

    def test_completed_at_none_uses_current_time(self):
        """When completed_at is omitted, duration should be relative to now."""
        one_minute_ago = (datetime.now(UTC) - timedelta(minutes=1)).isoformat()
        result = calculate_duration(one_minute_ago)
        # Should be roughly 60 seconds (allow +-5s for test execution jitter)
        assert result is not None
        assert 55 <= result <= 65

    def test_negative_duration(self):
        """If completed_at is before created_at the function still returns an int."""
        created = "2025-01-01T12:00:00+00:00"
        completed = "2025-01-01T11:00:00+00:00"
        result = calculate_duration(created, completed)
        assert result == -3600

    def test_zero_duration(self):
        ts = "2025-03-14T08:00:00+00:00"
        assert calculate_duration(ts, ts) == 0

    def test_mixed_formats_aware_created_naive_completed(self):
        """Aware created_at + naive completed_at: completed gets .astimezone(UTC)
        converting naive to local-tz-aware, then subtraction from aware created
        works. Result depends on system tz offset from UTC."""
        created = "2025-01-01T10:00:00+00:00"
        completed = "2025-01-01 10:02:00"
        result = calculate_duration(created, completed)
        # completed_at is naive, .astimezone(UTC) makes it aware using local tz.
        # The result is an int (may differ from 120 depending on system tz).
        assert result is not None
        assert isinstance(result, int)

    def test_space_format_with_fractional_seconds(self):
        """Space-separated format with fractional seconds: both are naive,
        end_time becomes aware via .astimezone(UTC) but start_time stays
        naive, causing subtraction to fail. Returns None."""
        created = "2025-01-01 10:00:00.123"
        completed = "2025-01-01 10:00:10.123"
        result = calculate_duration(created, completed)
        # Same naive-vs-aware mismatch as test_iso_format_without_timezone
        assert result is None


# ---------------------------------------------------------------------------
# Error type classification (extracted logic from get_research_status)
# ---------------------------------------------------------------------------


def _classify_error(error_msg, metadata=None):
    """
    Reimplementation of the error classification logic from
    get_research_status to test it in isolation.
    """
    if metadata is None:
        metadata = {}

    error_type = "unknown"
    error_info = {}

    if "timeout" in error_msg.lower():
        error_type = "timeout"
        error_info = {
            "type": "timeout",
            "message": "LLM service timed out during synthesis. This may be due to high server load or connectivity issues.",
            "suggestion": "Try again later or use a smaller query scope.",
        }
    elif (
        "token limit" in error_msg.lower()
        or "context length" in error_msg.lower()
    ):
        error_type = "token_limit"
        error_info = {
            "type": "token_limit",
            "message": "The research query exceeded the AI model's token limit during synthesis.",
            "suggestion": "Try using a more specific query or reduce the research scope.",
        }
    elif (
        "final answer synthesis fail" in error_msg.lower()
        or "llm error" in error_msg.lower()
    ):
        error_type = "llm_error"
        error_info = {
            "type": "llm_error",
            "message": "The AI model encountered an error during final answer synthesis.",
            "suggestion": "Check that your LLM service is running correctly or try a different model.",
        }
    elif "ollama" in error_msg.lower():
        error_type = "ollama_error"
        error_info = {
            "type": "ollama_error",
            "message": "The Ollama service is not responding properly.",
            "suggestion": "Make sure Ollama is running with 'ollama serve' and the model is downloaded.",
        }
    elif "connection" in error_msg.lower():
        error_type = "connection"
        error_info = {
            "type": "connection",
            "message": "Connection error with the AI service.",
            "suggestion": "Check your internet connection and AI service status.",
        }
    elif metadata.get("solution"):
        error_info = {
            "type": error_type,
            "message": error_msg,
            "suggestion": metadata.get("solution"),
        }
    else:
        error_info = {
            "type": error_type,
            "message": error_msg,
            "suggestion": "Try again with a different query or check the application logs.",
        }

    return error_info


class TestErrorClassification:
    """Tests for the error-type classification logic."""

    def test_timeout_error(self):
        info = _classify_error("Request Timeout after 120s")
        assert info["type"] == "timeout"

    def test_timeout_case_insensitive(self):
        info = _classify_error("TIMEOUT occurred during processing")
        assert info["type"] == "timeout"

    def test_token_limit_error(self):
        info = _classify_error("Exceeded token limit for model gpt-4")
        assert info["type"] == "token_limit"

    def test_context_length_error(self):
        info = _classify_error("context length exceeded: 128000 tokens")
        assert info["type"] == "token_limit"

    def test_llm_error(self):
        info = _classify_error("LLM error: model returned empty response")
        assert info["type"] == "llm_error"

    def test_synthesis_failure(self):
        info = _classify_error(
            "Final answer synthesis failed due to parsing error"
        )
        assert info["type"] == "llm_error"

    def test_ollama_error(self):
        info = _classify_error("Ollama connection refused at localhost:11434")
        assert info["type"] == "ollama_error"

    def test_connection_error(self):
        info = _classify_error("Connection refused by remote host")
        assert info["type"] == "connection"

    def test_unknown_error_with_solution_in_metadata(self):
        meta = {"solution": "Restart the service and try again."}
        info = _classify_error("Some unusual failure", metadata=meta)
        assert info["type"] == "unknown"
        assert info["suggestion"] == "Restart the service and try again."
        assert info["message"] == "Some unusual failure"

    def test_generic_unknown_error(self):
        info = _classify_error("Something went wrong")
        assert info["type"] == "unknown"
        assert "Try again" in info["suggestion"]

    def test_all_fields_present(self):
        """Every classification result should have type, message, suggestion."""
        messages = [
            "timeout",
            "token limit",
            "context length exceeded",
            "LLM error",
            "ollama",
            "connection error",
            "random error",
        ]
        for msg in messages:
            info = _classify_error(msg)
            assert "type" in info
            assert "message" in info
            assert "suggestion" in info

    def test_priority_timeout_over_connection(self):
        """'timeout' should match before 'connection' even if both present."""
        info = _classify_error("connection timeout occurred")
        assert info["type"] == "timeout"

    def test_priority_token_limit_over_llm_error(self):
        """'token limit' matches before 'llm error'."""
        info = _classify_error("LLM error: token limit exceeded")
        # token limit check comes after timeout and before llm_error
        assert info["type"] == "token_limit"


# ---------------------------------------------------------------------------
# find_blocked_keys (extracted from save_raw_config route)
# ---------------------------------------------------------------------------

BLOCKED_KEY_PATTERNS = ["module_path", "class_name", "module", "class"]


def find_blocked_keys(obj, path=""):
    """Reimplementation of the nested function from save_raw_config."""
    blocked = []
    if isinstance(obj, dict):
        for key, value in obj.items():
            current_path = f"{path}.{key}" if path else key
            key_lower = key.lower()
            for pattern in BLOCKED_KEY_PATTERNS:
                if pattern in key_lower:
                    blocked.append(current_path)
                    break
            blocked.extend(find_blocked_keys(value, current_path))
    elif isinstance(obj, list):
        for i, item in enumerate(obj):
            blocked.extend(find_blocked_keys(item, f"{path}[{i}]"))
    return blocked


class TestFindBlockedKeys:
    """Tests for the TOML config security key scanner."""

    def test_empty_config(self):
        assert find_blocked_keys({}) == []

    def test_safe_config(self):
        config = {"search": {"tool": "google", "iterations": 5}}
        assert find_blocked_keys(config) == []

    def test_module_path_at_top_level(self):
        config = {"module_path": "evil.module"}
        result = find_blocked_keys(config)
        assert result == ["module_path"]

    def test_class_name_at_top_level(self):
        config = {"class_name": "EvilClass"}
        result = find_blocked_keys(config)
        assert result == ["class_name"]

    def test_nested_blocked_key(self):
        config = {"custom": {"engines": {"module_path": "evil"}}}
        result = find_blocked_keys(config)
        assert result == ["custom.engines.module_path"]

    def test_module_key_blocked(self):
        """'module' is a blocked pattern, so any key containing it is caught."""
        config = {"my_module": "something"}
        result = find_blocked_keys(config)
        # "module" is in "my_module"
        assert len(result) == 1

    def test_class_key_blocked(self):
        config = {"subclass": "something"}
        result = find_blocked_keys(config)
        # "class" is in "subclass"
        assert len(result) == 1

    def test_case_insensitive(self):
        config = {"MODULE_PATH": "evil", "Class_Name": "bad"}
        result = find_blocked_keys(config)
        assert len(result) == 2

    def test_blocked_key_in_list(self):
        config = {"items": [{"module_path": "evil"}, {"safe_key": "ok"}]}
        result = find_blocked_keys(config)
        assert result == ["items[0].module_path"]

    def test_deeply_nested(self):
        config = {"a": {"b": {"c": {"d": {"class_name": "Bad"}}}}}
        result = find_blocked_keys(config)
        assert result == ["a.b.c.d.class_name"]

    def test_multiple_blocked_keys(self):
        config = {
            "module_path": "a",
            "nested": {"class_name": "b"},
            "list_field": [{"module": "c"}],
        }
        result = find_blocked_keys(config)
        assert len(result) == 3

    def test_scalar_values_not_checked(self):
        """Only keys are checked, not values."""
        config = {"safe_key": "module_path"}
        assert find_blocked_keys(config) == []

    def test_empty_nested_dict(self):
        config = {"a": {}}
        assert find_blocked_keys(config) == []

    def test_empty_list(self):
        config = {"a": []}
        assert find_blocked_keys(config) == []


# ---------------------------------------------------------------------------
# Date placeholder replacement logic
# ---------------------------------------------------------------------------


class TestDatePlaceholderReplacement:
    """Tests for the YYYY-MM-DD date placeholder logic used in start_research."""

    @staticmethod
    def _replace_placeholder(query, metadata=None):
        """Simulate the date replacement logic from start_research."""
        if metadata is None:
            metadata = {}

        if query and "YYYY-MM-DD" in query:
            current_date = datetime.now(UTC).strftime("%Y-%m-%d")
            original_query = query
            query = query.replace("YYYY-MM-DD", current_date)
            if not metadata:
                metadata = {}
            metadata["original_query"] = original_query
            metadata["processed_query"] = query
            metadata["date_replaced"] = current_date
        return query, metadata

    def test_no_placeholder(self):
        query, meta = self._replace_placeholder("regular search query")
        assert query == "regular search query"
        assert "date_replaced" not in meta

    def test_placeholder_replaced(self):
        query, meta = self._replace_placeholder("news after YYYY-MM-DD")
        today = datetime.now(UTC).strftime("%Y-%m-%d")
        assert today in query
        assert "YYYY-MM-DD" not in query
        assert meta["date_replaced"] == today

    def test_multiple_placeholders(self):
        query, meta = self._replace_placeholder(
            "between YYYY-MM-DD and YYYY-MM-DD"
        )
        today = datetime.now(UTC).strftime("%Y-%m-%d")
        assert query.count(today) == 2

    def test_metadata_preserved(self):
        existing_meta = {"is_news_search": True, "triggered_by": "user"}
        query, meta = self._replace_placeholder(
            "news YYYY-MM-DD", metadata=existing_meta
        )
        assert meta["is_news_search"] is True
        assert meta["triggered_by"] == "user"
        assert "date_replaced" in meta

    def test_original_query_saved(self):
        original = "events after YYYY-MM-DD"
        query, meta = self._replace_placeholder(original)
        assert meta["original_query"] == original
        assert meta["processed_query"] == query

    def test_empty_query(self):
        query, meta = self._replace_placeholder("")
        assert query == ""

    def test_none_query(self):
        query, meta = self._replace_placeholder(None)
        assert query is None


# ---------------------------------------------------------------------------
# globals.py – thread-safe state helpers
# ---------------------------------------------------------------------------
from local_deep_research.web.routes.globals import (  # noqa: E402
    _active_research,
    _termination_flags,
    _lock,
    cleanup_research,
    get_active_research_count,
    get_active_research_ids,
    get_active_research_snapshot,
    get_research_field,
    is_research_active,
    is_termination_requested,
    set_active_research,
    set_termination_flag,
    clear_termination_flag,
    remove_active_research,
    update_active_research,
    update_progress_if_higher,
    update_progress_and_check_active,
    append_research_log,
    iter_active_research,
    get_usernames_with_active_research,
    is_research_thread_alive,
)


@pytest.fixture(autouse=True)
def _clean_globals():
    """Ensure globals are clean before and after every test."""
    with _lock:
        _active_research.clear()
        _termination_flags.clear()
    yield
    with _lock:
        _active_research.clear()
        _termination_flags.clear()


class TestGlobalsActiveResearch:
    """Tests for active research state management."""

    def test_set_and_check_active(self):
        set_active_research("r1", {"progress": 0, "status": "in_progress"})
        assert is_research_active("r1") is True
        assert is_research_active("nonexistent") is False

    def test_get_active_research_ids(self):
        set_active_research("a", {})
        set_active_research("b", {})
        ids = get_active_research_ids()
        assert sorted(ids) == ["a", "b"]

    def test_get_active_research_ids_empty(self):
        assert get_active_research_ids() == []

    def test_remove_active_research(self):
        set_active_research("r1", {})
        remove_active_research("r1")
        assert is_research_active("r1") is False

    def test_remove_nonexistent_no_error(self):
        remove_active_research("nonexistent")

    def test_get_active_research_count(self):
        assert get_active_research_count() == 0
        set_active_research("r1", {})
        set_active_research("r2", {})
        assert get_active_research_count() == 2

    def test_get_research_field_scalar(self):
        set_active_research("r1", {"progress": 42, "status": "in_progress"})
        assert get_research_field("r1", "progress") == 42
        assert get_research_field("r1", "status") == "in_progress"

    def test_get_research_field_default(self):
        set_active_research("r1", {})
        assert get_research_field("r1", "missing", "default") == "default"

    def test_get_research_field_nonexistent_research(self):
        assert get_research_field("nope", "progress", 0) == 0

    def test_get_research_field_returns_copy_for_list(self):
        set_active_research("r1", {"log": [1, 2, 3]})
        log = get_research_field("r1", "log")
        log.append(4)
        # Original should not be mutated
        assert get_research_field("r1", "log") == [1, 2, 3]

    def test_get_research_field_returns_copy_for_dict(self):
        set_active_research("r1", {"settings": {"a": 1}})
        settings = get_research_field("r1", "settings")
        settings["b"] = 2
        assert "b" not in get_research_field("r1", "settings")

    def test_update_active_research(self):
        set_active_research("r1", {"progress": 0})
        update_active_research("r1", progress=50, status="running")
        assert get_research_field("r1", "progress") == 50
        assert get_research_field("r1", "status") == "running"

    def test_update_nonexistent_no_error(self):
        update_active_research("nope", progress=10)

    def test_get_active_research_snapshot(self):
        set_active_research(
            "r1",
            {
                "progress": 75,
                "status": "in_progress",
                "log": [{"msg": "started"}],
                "settings": {"key": "val"},
                "thread": threading.current_thread(),
            },
        )
        snap = get_active_research_snapshot("r1")
        assert snap is not None
        assert snap["progress"] == 75
        assert snap["status"] == "in_progress"
        assert snap["log"] == [{"msg": "started"}]
        assert snap["settings"] == {"key": "val"}
        # thread should not be in snapshot
        assert "thread" not in snap

    def test_get_active_research_snapshot_none(self):
        assert get_active_research_snapshot("nonexistent") is None

    def test_get_active_research_snapshot_no_settings(self):
        set_active_research("r1", {"progress": 0, "status": "x"})
        snap = get_active_research_snapshot("r1")
        assert snap["settings"] is None


class TestGlobalsProgressUpdates:
    """Tests for progress update helpers."""

    def test_update_progress_if_higher(self):
        set_active_research("r1", {"progress": 10})
        result = update_progress_if_higher("r1", 50)
        assert result == 50
        assert get_research_field("r1", "progress") == 50

    def test_update_progress_if_not_higher(self):
        set_active_research("r1", {"progress": 80})
        result = update_progress_if_higher("r1", 50)
        assert result == 80
        assert get_research_field("r1", "progress") == 80

    def test_update_progress_none_value(self):
        set_active_research("r1", {"progress": 30})
        result = update_progress_if_higher("r1", None)
        assert result == 30

    def test_update_progress_nonexistent(self):
        result = update_progress_if_higher("nope", 50)
        assert result is None

    def test_update_progress_and_check_active(self):
        set_active_research("r1", {"progress": 10})
        progress, active = update_progress_and_check_active("r1", 60)
        assert progress == 60
        assert active is True

    def test_update_progress_and_check_active_not_higher(self):
        set_active_research("r1", {"progress": 90})
        progress, active = update_progress_and_check_active("r1", 50)
        assert progress == 90
        assert active is True

    def test_update_progress_and_check_active_nonexistent(self):
        progress, active = update_progress_and_check_active("nope", 50)
        assert progress is None
        assert active is False


class TestGlobalsAppendLog:
    """Tests for append_research_log."""

    def test_append_to_existing_log(self):
        set_active_research("r1", {"log": [{"msg": "first"}]})
        append_research_log("r1", {"msg": "second"})
        log = get_research_field("r1", "log")
        assert len(log) == 2
        assert log[1]["msg"] == "second"

    def test_append_creates_log_list(self):
        set_active_research("r1", {})
        append_research_log("r1", {"msg": "first"})
        log = get_research_field("r1", "log")
        assert log == [{"msg": "first"}]

    def test_append_nonexistent_no_error(self):
        append_research_log("nope", {"msg": "x"})


class TestGlobalsTerminationFlags:
    """Tests for termination flag management."""

    def test_set_and_check_termination(self):
        assert is_termination_requested("r1") is False
        set_termination_flag("r1")
        assert is_termination_requested("r1") is True

    def test_clear_termination_flag(self):
        set_termination_flag("r1")
        clear_termination_flag("r1")
        assert is_termination_requested("r1") is False

    def test_clear_nonexistent_no_error(self):
        clear_termination_flag("nope")


class TestGlobalsCleanup:
    """Tests for cleanup_research."""

    def test_cleanup_removes_both(self):
        set_active_research("r1", {"progress": 50})
        set_termination_flag("r1")
        cleanup_research("r1")
        assert is_research_active("r1") is False
        assert is_termination_requested("r1") is False

    def test_cleanup_nonexistent(self):
        cleanup_research("nope")  # should not raise


class TestGlobalsIterActiveResearch:
    """Tests for iter_active_research."""

    def test_iter_yields_snapshots(self):
        set_active_research("r1", {"progress": 10, "status": "x", "log": []})
        set_active_research("r2", {"progress": 90, "status": "y"})
        items = dict(iter_active_research())
        assert "r1" in items
        assert "r2" in items
        assert items["r1"]["progress"] == 10

    def test_iter_empty(self):
        assert list(iter_active_research()) == []


class TestGlobalsUsernames:
    """Tests for get_usernames_with_active_research."""

    def test_returns_usernames(self):
        set_active_research("r1", {"settings": {"username": "alice"}})
        set_active_research("r2", {"settings": {"username": "bob"}})
        set_active_research("r3", {"settings": {"username": "alice"}})
        names = get_usernames_with_active_research()
        assert names == {"alice", "bob"}

    def test_returns_empty_when_no_research(self):
        assert get_usernames_with_active_research() == set()

    def test_skips_entries_without_username(self):
        set_active_research("r1", {"settings": {}})
        set_active_research("r2", {})
        assert get_usernames_with_active_research() == set()


class TestGlobalsThreadAlive:
    """Tests for is_research_thread_alive."""

    def test_no_thread(self):
        set_active_research("r1", {})
        assert is_research_thread_alive("r1") is False

    def test_nonexistent_research(self):
        assert is_research_thread_alive("nope") is False

    def test_alive_thread(self):
        event = threading.Event()
        t = threading.Thread(target=event.wait, daemon=True)
        t.start()
        try:
            set_active_research("r1", {"thread": t})
            assert is_research_thread_alive("r1") is True
        finally:
            event.set()
            t.join(timeout=2)

    def test_dead_thread(self):
        t = threading.Thread(target=lambda: None)
        t.start()
        t.join()
        set_active_research("r1", {"thread": t})
        assert is_research_thread_alive("r1") is False


class TestGlobalsConcurrency:
    """Basic concurrency safety tests for globals."""

    def test_concurrent_set_and_read(self):
        """Multiple threads setting and reading should not crash."""
        errors = []

        def worker(rid):
            try:
                set_active_research(rid, {"progress": 0})
                for i in range(100):
                    update_progress_if_higher(rid, i)
                    get_research_field(rid, "progress")
                    append_research_log(rid, {"i": i})
                remove_active_research(rid)
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=worker, args=(f"r{i}",)) for i in range(10)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        assert errors == []
        assert get_active_research_count() == 0
