# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

import importlib
import json
import os
import sys
import types
from collections.abc import Sequence
from pathlib import Path
from unittest.mock import Mock

import numpy as np
import pytest

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))


def _install_heavy_module_stubs():
    if "usearch.index" not in sys.modules:
        usearch = types.ModuleType("usearch")
        index_mod = types.ModuleType("usearch.index")

        class DummyIndex:
            def __init__(self, *a, **k):
                pass

            def save(self, *a, **k):
                pass

            @classmethod
            def restore(cls, *a, **k):
                return cls()

            def remove(self, *a, **k):
                pass

            def add(self, *a, **k):
                pass

            def search(self, *a, **k):
                class Res:
                    keys = [1]
                    distances = [0.0]

                return Res()

        index_mod.Index = DummyIndex
        usearch.index = index_mod
        sys.modules["usearch"] = usearch
        sys.modules["usearch.index"] = index_mod
    if "sentence_transformers" not in sys.modules:
        st_mod = types.ModuleType("sentence_transformers")

        class DummyST:
            def __init__(self, *a, **k):
                pass

            def get_sentence_embedding_dimension(self):
                return 384

            def encode(self, texts):
                if isinstance(texts, str):
                    texts = [texts]
                return [([0.0] * 384) for _ in texts]

        st_mod.SentenceTransformer = DummyST
        sys.modules["sentence_transformers"] = st_mod
    # NOTE: do NOT stub sklearn. scikit-learn is a hard, installed dependency
    # (pyproject `scikit-learn>=1.3`) and the speakers discovery code uses
    # the real `sklearn.cluster.HDBSCAN`. A persistent `sys.modules` stub here
    # leaked a DummyHDBSCAN (labels every point as noise) into whichever
    # co-scheduled test imported it first under xdist, silently breaking the
    # real-clustering tests. Only genuinely-absent heavy deps (usearch,
    # sentence_transformers) belong in this stub set.
    if "dotenv" not in sys.modules:
        dotenv_mod = types.ModuleType("dotenv")

        def load_dotenv(*a, **k):
            return True

        def dotenv_values(*a, **k):
            return {}

        dotenv_mod.load_dotenv = load_dotenv
        dotenv_mod.dotenv_values = dotenv_values
        sys.modules["dotenv"] = dotenv_mod


from solstone.convey.chat import stop_all_chat_runtime
from solstone.think.link.runtime import stop_all_link_runtime
from solstone.think.push.runtime import stop_all_push_runtime
from solstone.think.utils import now_ms
from solstone.think.voice import brain as voice_brain
from solstone.think.voice.runtime import stop_all_voice_runtime
from tests._baseline_harness import copytree_tracked


def write_health_approval_artifact(
    journal_root: Path,
    *,
    importers: Sequence[str],
    raw_retention_decision: str = "retain_parsed",
    unparsed_sensitive_modalities_acknowledged: bool | None = None,
) -> Path:
    from solstone.think.importers.pre_save_gate import (
        APPROVAL_SCHEMA,
        CHECKLIST_DESTINATIONS,
        CHECKLIST_VERSION,
        approval_path_for_journal,
    )

    resolved = journal_root.resolve()
    approval_path = approval_path_for_journal(resolved)
    approval_path.parent.mkdir(parents=True, exist_ok=True)
    raw_retention = {
        "decision": raw_retention_decision,
        "notes": "Synthetic test decision.",
    }
    if unparsed_sensitive_modalities_acknowledged is not None:
        raw_retention["unparsed_sensitive_modalities_acknowledged"] = (
            unparsed_sensitive_modalities_acknowledged
        )
    artifact = {
        "schema": APPROVAL_SCHEMA,
        "checklist_version": CHECKLIST_VERSION,
        "approved_by": "Test Owner",
        "approved_at": "2026-07-03T23:22:00-06:00",
        "journal_root": str(resolved),
        "approved_importers": list(importers),
        "replication_destinations": {
            destination: {
                "decision": "approved" if destination == "time_machine" else "excluded",
                "notes": "Synthetic test decision.",
            }
            for destination in CHECKLIST_DESTINATIONS
        },
        "raw_retention": raw_retention,
        "requires_per_run_confirmation": True,
        "no_real_health_data_in_artifact": True,
    }
    approval_path.write_text(json.dumps(artifact), encoding="utf-8")
    return approval_path


@pytest.fixture(autouse=True)
def _isolate_os_environ():
    """Restore os.environ after every test so raw env writes can't leak.

    Production code mutates os.environ by raw assignment (not monkeypatch): the
    supervisor sets ``SOL_SUPERVISOR_SPAWNED`` and rewrites ``PATH``
    (``supervisor.py``), talents set ``SOL_SEGMENT`` (``talents.py``), and
    settings write provider keys (``settings/routes.py``). When a test exercises
    one of those paths the change persists into every later test in the same
    worker — the root cause of the ``SOL_SUPERVISOR_SPAWNED`` flake and the
    reason ~20 test files defensively ``delenv`` it. Snapshotting here makes
    every test hermetic against that whole class of env leak, so new tests no
    longer need their own defensive ``delenv``. Defined first so it brackets the
    other autouse fixtures (notably the monkeypatch env setup below).
    """
    saved = dict(os.environ)
    try:
        yield
    finally:
        if dict(os.environ) != saved:
            os.environ.clear()
            os.environ.update(saved)


@pytest.fixture(autouse=True)
def set_test_journal_path(monkeypatch, _isolate_os_environ):
    """Set SOLSTONE_JOURNAL to tests/fixtures/journal for all unit tests.

    This ensures all tests have a valid SOLSTONE_JOURNAL without needing
    to explicitly set it in each test.
    """
    import solstone.think.utils as think_utils

    monkeypatch.setenv(
        "SOLSTONE_JOURNAL",
        str(Path("tests/fixtures/journal").resolve()),
    )
    monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1")
    monkeypatch.setenv("SOLSTONE_DISABLE_CONVEY_SIDE_RUNTIMES", "1")
    think_utils._journal_path_cache = None
    yield
    think_utils._journal_path_cache = None


@pytest.fixture(autouse=True)
def _default_local_backend_vulkan(monkeypatch, request):
    from solstone.think.providers import local_cuda, local_install

    if request.node.get_closest_marker("real_local_backend_probe") is None:
        monkeypatch.setattr(
            local_cuda,
            "probe_nvidia_gpu",
            lambda: local_cuda.NvidiaProbe(
                index=None,
                compute_cap=None,
                driver_cuda_version=None,
                vram_mib=None,
                tiering_memory_mib=None,
                memory_source=local_cuda.MEMORY_SOURCE_UNAVAILABLE,
                detected=False,
            ),
        )
        monkeypatch.setattr(
            local_install,
            "probe_cuda_runtime_artifact_trust",
            lambda _pin, **_kwargs: local_cuda.ArtifactTrust.ABSENT,
        )
        monkeypatch.setattr(
            local_install,
            "has_persisted_installed_cuda_target",
            lambda **_kwargs: False,
        )


@pytest.fixture(autouse=True)
def _cleanup_voice_runtime():
    yield
    stop_all_voice_runtime()
    voice_brain.clear_brain_state()


@pytest.fixture(autouse=True)
def _cleanup_push_runtime():
    yield
    stop_all_push_runtime()


@pytest.fixture(autouse=True)
def _cleanup_link_runtime():
    yield
    stop_all_link_runtime()


def _reset_chat_module_state():
    """Bring solstone.convey.chat to a clean slate between tests.

    stop_all_chat_runtime() cancels watchdog timers and clears the runtime,
    _reserved_use_ids, and the thinking buffers. The remaining module-global
    singletons are test-state only — notably _last_use_id, a monotonic id
    counter production must NOT reset (resetting it live would collide
    use_ids) — so they are cleared here in the fixture rather than in
    production stop_all_chat_runtime().
    """
    import solstone.convey.chat as chat

    stop_all_chat_runtime()
    with chat._state_lock:
        chat._current_chat_use_id = None
        chat._current_chat_state = None
        chat._queued_triggers.clear()
        chat._active_talents.clear()
        chat._last_use_id = 0


@pytest.fixture(autouse=True)
def _cleanup_chat_runtime():
    # Reset before and after every test so a sibling that mutates chat's
    # module-global singletons can't bleed into the next test, regardless of
    # whether the test called its own _reset_chat_state helper.
    _reset_chat_module_state()
    yield
    _reset_chat_module_state()


@pytest.fixture
def journal_copy(tmp_path, monkeypatch):
    """Copy git-tracked fixture files to tmp_path for mutation tests."""
    src = Path(__file__).resolve().parent / "fixtures" / "journal"
    dst = tmp_path / "journal"
    copytree_tracked(src, dst)
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(dst.resolve()))
    import solstone.think.utils as think_utils

    think_utils._journal_path_cache = None
    return dst


@pytest.fixture(autouse=True)
def add_module_stubs(monkeypatch):
    _install_heavy_module_stubs()
    # Import real observe package first to avoid shadowing with stubs
    if "solstone.observe" not in sys.modules:
        importlib.import_module("solstone.observe")
    if "solstone.observe.detect" not in sys.modules:
        detect_mod = types.ModuleType("solstone.observe.detect")

        def input_detect():
            return None, None

        detect_mod.input_detect = input_detect
        sys.modules["solstone.observe.detect"] = detect_mod
        observe_pkg = sys.modules.get("solstone.observe")
        setattr(observe_pkg, "detect", detect_mod)
    if "solstone.observe.hear" not in sys.modules:
        # Import the real module for format_audio and load_transcript
        hear_mod = importlib.import_module("solstone.observe.hear")
        sys.modules["solstone.observe.hear"] = hear_mod
        observe_pkg = sys.modules.get("solstone.observe")
        setattr(observe_pkg, "hear", hear_mod)
    if "solstone.observe.sense" not in sys.modules:
        # Import the real module - it has minimal dependencies
        sense_mod = importlib.import_module("solstone.observe.sense")
        sys.modules["solstone.observe.sense"] = sense_mod
        observe_pkg = sys.modules.get("solstone.observe")
        setattr(observe_pkg, "sense", sense_mod)
    if "solstone.observe.utils" not in sys.modules:
        # Import the real module
        utils_mod = importlib.import_module("solstone.observe.utils")
        sys.modules["solstone.observe.utils"] = utils_mod
        observe_pkg = sys.modules.get("solstone.observe")
        setattr(observe_pkg, "utils", utils_mod)
    if "solstone.observe.screen" not in sys.modules:
        # Import the real module for format_screen
        screen_mod = importlib.import_module("solstone.observe.screen")
        sys.modules["solstone.observe.screen"] = screen_mod
        observe_pkg = sys.modules.get("solstone.observe")
        setattr(observe_pkg, "screen", screen_mod)
    if "gi" not in sys.modules:
        gi_mod = types.ModuleType("gi")
        gi_mod.require_version = lambda *a, **k: None

        class Dummy(types.ModuleType):
            pass

        repo = types.ModuleType("gi.repository")
        repo.Gdk = Dummy("Gdk")
        repo.Gtk = Dummy("Gtk")
        gi_mod.repository = repo
        sys.modules["gi"] = gi_mod
        sys.modules["gi.repository"] = repo
        sys.modules["Gdk"] = repo.Gdk
        sys.modules["Gtk"] = repo.Gtk
    if "cv2" not in sys.modules:
        cv2_mod = types.ModuleType("cv2")
        cv2_mod.__spec__ = importlib.machinery.ModuleSpec("cv2", loader=None)
        cv2_mod.COLOR_RGB2LAB = 0

        def cvtColor(arr, code):
            arr = np.asarray(arr)
            gray = arr.mean(axis=2)
            return np.stack([gray, gray, gray], axis=2)

        cv2_mod.cvtColor = cvtColor
        sys.modules["cv2"] = cv2_mod
    for name in [
        "noisereduce",
    ]:
        if name not in sys.modules:
            sys.modules[name] = types.ModuleType(name)


@pytest.fixture(autouse=True)
def reset_supervisor_state():
    """Reset supervisor module state before/after tests to prevent cross-test pollution."""
    try:
        import solstone.think.supervisor as mod

        # Reset before test
        mod._daily_state["last_day"] = None
        mod._recovery_state = {
            "local": mod.ProviderRecoveryState(),
            "parakeet": mod.ProviderRecoveryState(),
        }
        mod._is_remote_mode = False
        mod._supervisor_callosum = None
        # Create fresh task queue
        mod._task_queue = mod.TaskQueue(on_queue_change=None)
    except ImportError:
        pass  # supervisor not loaded yet
    yield
    try:
        import solstone.think.supervisor as mod

        # Reset after test
        mod._daily_state["last_day"] = None
        mod._recovery_state = {
            "local": mod.ProviderRecoveryState(),
            "parakeet": mod.ProviderRecoveryState(),
        }
        mod._is_remote_mode = False
        mod._supervisor_callosum = None
        mod._observer_health = {}
        mod._enabled_observers = set()
        # Create fresh task queue
        mod._task_queue = mod.TaskQueue(on_queue_change=None)
    except ImportError:
        pass


@pytest.fixture
def mock_callosum(monkeypatch):
    """Mock Callosum connections to capture emitted events without real I/O.

    This fixture provides a MockCallosumConnection class that:
    - Enforces the start-before-emit requirement
    - Broadcasts events to all listeners (like the real Callosum)
    - Works without real socket connections

    Usage:
        def test_example(mock_callosum):
            from solstone.think.callosum import CallosumConnection

            received = []
            listener = CallosumConnection()
            listener.start(callback=lambda msg: received.append(msg))

            # Now emit events and they'll be captured in received
    """
    all_listeners = []

    class MockCallosumConnection:
        def __init__(self, socket_path=None):
            self.socket_path = socket_path
            self.callback = None
            self.thread = None

        def start(self, callback=None):
            """Simulate starting the background thread."""
            self.callback = callback
            self.thread = Mock()
            self.thread.is_alive.return_value = True
            if callback:
                all_listeners.append(self)

        def emit(self, tract, event, **kwargs):
            """Emit event and broadcast to all listeners."""
            # Return False if not started yet (matches real behavior)
            if self.thread is None or not self.thread.is_alive():
                return False

            # Build message
            msg = {"tract": tract, "event": event, **kwargs}
            if "ts" not in msg:
                msg["ts"] = now_ms()

            # Broadcast to all listeners
            for listener in all_listeners:
                if listener.callback:
                    listener.callback(msg)

            return True

        def stop(self):
            """Stop connection and remove from listeners."""
            if self in all_listeners:
                all_listeners.remove(self)
            self.thread = None
            self.callback = None

    # Patch both import locations
    monkeypatch.setattr(
        "solstone.think.runner.CallosumConnection", MockCallosumConnection
    )
    monkeypatch.setattr(
        "solstone.think.callosum.CallosumConnection", MockCallosumConnection
    )
    monkeypatch.setattr(
        "solstone.think.supervisor.CallosumConnection", MockCallosumConnection
    )


# Convey fixtures, folded in from the retired solstone/convey/tests/conftest.py
# (2026-07-24) when convey's dark test modules moved into this directory.
@pytest.fixture
def convey_env(tmp_path, monkeypatch):
    def _create():
        journal = tmp_path / "journal"
        journal.mkdir()

        config_dir = journal / "config"
        config_dir.mkdir(parents=True, exist_ok=True)
        config_file = config_dir / "journal.json"
        config_file.write_text(
            json.dumps(
                {
                    "setup": {"completed_at": 1700000000000},
                },
                indent=2,
            )
        )

        monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))

        from solstone.convey import create_app

        app = create_app(journal=str(journal))
        client = app.test_client()

        class Env:
            def __init__(self):
                self.journal = journal
                self.client = client
                self.app = app

        return Env()

    return _create


@pytest.fixture
def convey_env_setup_pending(tmp_path, monkeypatch):
    def _create():
        journal = tmp_path / "journal"
        journal.mkdir()

        config_dir = journal / "config"
        config_dir.mkdir(parents=True, exist_ok=True)
        config_file = config_dir / "journal.json"
        config_file.write_text(
            json.dumps(
                {
                    "setup": {},
                },
                indent=2,
            )
        )

        monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))

        from solstone.convey import create_app

        app = create_app(journal=str(journal))
        client = app.test_client()

        class Env:
            def __init__(self):
                self.journal = journal
                self.client = client
                self.app = app

        return Env()

    return _create
