"""Test the SQLite store."""

import datetime
import sqlite3
import uuid
from pathlib import Path
from typing import Generator, cast

import pytest

from acoupi import components, data
from acoupi.components.stores.sqlite import store as sqlite_store_module
from acoupi.system.exceptions import MetadataStoreError


@pytest.fixture(scope="function")
def sqlite_store(
    tmp_path: Path,
) -> Generator[components.SqliteStore, None, None]:
    """Create a store."""
    db_path = tmp_path / "test.db"
    yield components.SqliteStore(db_path)
    db_path.unlink()


def test_sqlite_store_creates_a_database_file(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that the store creates a database file."""
    db_path = sqlite_store.db_path
    assert db_path.exists()


def test_database_has_correct_tables(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that the database has the correct tables."""
    expected_tables = {
        "recording",
        "deployment",
        "detection",
    }
    db_path = sqlite_store.db_path

    # Check that the tables are there
    with sqlite3.connect(str(db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
        actual_tables = set(row[0] for row in cursor.fetchall())
        assert expected_tables.issubset(actual_tables)


def test_recording_table_has_correct_columns(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that the recording table has the correct columns."""
    expected_columns = {
        "id",
        "path",
        "duration_s",
        "samplerate_hz",
        "audio_channels",
        "datetime",
        "deployment_id",
    }
    db_path = sqlite_store.db_path

    with sqlite3.connect(str(db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("PRAGMA table_info(recording);")
        actual_columns = set(row[1] for row in cursor.fetchall())
        assert expected_columns == actual_columns


def test_deployment_table_has_correct_columns(sqlite_store) -> None:
    """Test that the deployment table has the correct columns."""
    expected_columns = {
        "id",
        "started_on",
        "ended_on",
        "latitude",
        "longitude",
        "name",
    }
    db_path = sqlite_store.db_path

    with sqlite3.connect(str(db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("PRAGMA table_info(deployment);")
        actual_columns = set(row[1] for row in cursor.fetchall())
        assert expected_columns == actual_columns


def test_detection_table_has_correct_columns(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that the detection table has the correct columns."""
    expected_columns = {
        "id",
        "start_time_s",
        "end_time_s",
        "low_freq_hz",
        "high_freq_hz",
        "detection_score",
        "model_output_id",
    }
    db_path = sqlite_store.db_path

    with sqlite3.connect(str(db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("PRAGMA table_info(detection);")
        actual_columns = set(row[1] for row in cursor.fetchall())
        assert expected_columns == actual_columns


def test_can_instantiate_multiple_stores_with_the_same_sqlite_file(
    tmp_path: Path,
) -> None:
    """Test that we can instantiate multiple stores to the same sqlite file."""
    # Arrange
    db_path = tmp_path / "test.db"

    # Act
    store1 = components.SqliteStore(db_path)
    store2 = components.SqliteStore(db_path)

    # Assert
    assert store1 is not None
    assert store2 is not None


def test_can_store_a_deployment(sqlite_store: components.SqliteStore) -> None:
    """Test that we can store a deployment."""
    # Arrange
    now = datetime.datetime.now()
    deployment = data.Deployment(
        started_on=now,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )

    # Act
    sqlite_store.store_deployment(deployment)

    # Assert
    with sqlite3.connect(str(sqlite_store.db_path)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM deployment;")
        row = cursor.fetchone()
        assert row["started_on"] == now.isoformat(sep=" ")
        assert row["latitude"] == 1.0
        assert row["longitude"] == 2.0


def test_get_current_deployment_returns_new_if_no_deployment_exists(
    sqlite_store: components.SqliteStore,
    patched_now,
) -> None:
    """Test that get_current_deployment fails if no deployment exists."""
    # Arrange
    # Patch the current time
    now = patched_now()

    # Make sure there are no deployments
    with sqlite3.connect(str(sqlite_store.db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("DELETE FROM deployment;")

    # Act
    # Try to get the current deployment, this should create a new one
    deployment = sqlite_store.get_current_deployment()

    # Assert
    assert deployment.latitude is None
    assert deployment.longitude is None
    assert deployment.started_on == now


def test_get_current_deployment_gets_latest_deployment(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that get_current_deployment gets the latest deployment."""
    # Arrange
    start_1 = datetime.datetime.now()
    start_2 = datetime.datetime.now() - datetime.timedelta(days=10)
    deployment1 = data.Deployment(
        started_on=start_1,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )
    deployment2 = data.Deployment(
        started_on=start_2,
        latitude=3.0,
        longitude=4.0,
        name="test_device",
    )
    sqlite_store.store_deployment(deployment1)
    sqlite_store.store_deployment(deployment2)

    # Act
    deployment = sqlite_store.get_current_deployment()

    # Assert
    assert deployment == deployment1


def test_deployment_id_in_db_is_same_as_in_python(
    sqlite_store: components.SqliteStore,
) -> None:
    """Test that the deployment_id in the db is the same as in python."""
    # Arrange
    now = datetime.datetime.now()
    deployment = data.Deployment(
        started_on=now,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )

    # Act
    sqlite_store.store_deployment(deployment)

    # Assert
    with sqlite3.connect(str(sqlite_store.db_path)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM deployment;")
        row = cursor.fetchone()
        assert row["id"] == deployment.id.bytes


def test_recordings_can_be_registered(
    sqlite_store: components.SqliteStore,
    deployment: data.Deployment,
) -> None:
    """Test that recordings can be registered."""
    # Arrange
    now = datetime.datetime.now()
    recording = data.Recording(
        deployment=deployment,
        path=Path("test/path"),
        duration=10.0,
        samplerate=44100,
        created_on=now,
        audio_channels=2,
    )

    # Act
    sqlite_store.store_recording(recording)

    # Assert
    with sqlite3.connect(str(sqlite_store.db_path)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM recording;")
        row = cursor.fetchone()
        assert row["path"] == "test/path"
        assert row["duration_s"] == 10.0
        assert row["audio_channels"] == 2
        assert row["samplerate_hz"] == 44100
        assert row["datetime"] == now.isoformat(sep=" ")
        assert row["id"] == recording.id.bytes


def test_recording_can_be_registered_with_custom_deployment(
    sqlite_store: components.SqliteStore,
):
    """Test that recordings can be registered with a custom deployment."""
    # Arrange
    now = datetime.datetime.now()
    deployment = data.Deployment(
        started_on=now,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )
    sqlite_store.store_deployment(deployment)
    recording = data.Recording(
        path=Path("test/path"),
        duration=10.0,
        samplerate=44100,
        created_on=now,
        deployment=deployment,
    )

    # Act
    sqlite_store.store_recording(recording)

    # Assert
    with sqlite3.connect(str(sqlite_store.db_path)) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM recording;")
        row = cursor.fetchone()
        assert row["path"] == "test/path"
        assert row["duration_s"] == 10.0
        assert row["samplerate_hz"] == 44100
        assert row["datetime"] == now.isoformat(sep=" ")
        assert row["id"] == recording.id.bytes
        assert row["deployment_id"] == deployment.id.bytes


def test_get_all_recordings(
    sqlite_store: components.SqliteStore,
    deployment: data.Deployment,
):
    """Test that all recordings can be retrieved."""
    # Arrange

    # Create two recordings
    now = datetime.datetime.now()
    recording1 = data.Recording(
        deployment=deployment,
        path=Path("test/path1"),
        duration=10.0,
        samplerate=44100,
        created_on=now,
    )
    recording2 = data.Recording(
        deployment=deployment,
        path=Path("test/path2"),
        duration=10.0,
        samplerate=44100,
        created_on=now + datetime.timedelta(seconds=10),
    )

    # Add them to the database
    sqlite_store.store_recording(recording1)
    sqlite_store.store_recording(recording2)

    # Act
    recordings = sqlite_store.get_recordings([recording1.id, recording2.id])

    # Assert
    assert len(recordings) == 2
    assert recordings[1][0] == recording1
    assert recordings[0][0] == recording2


def test_get_some_recordings(
    sqlite_store: components.SqliteStore,
    deployment: data.Deployment,
):
    """Test that recordings can be retrieved with an include filter."""
    # Arrange

    # Create two recordings
    now = datetime.datetime.now()
    recording1 = data.Recording(
        deployment=deployment,
        path=Path("test/path1"),
        duration=10.0,
        samplerate=44100,
        created_on=now,
    )
    recording2 = data.Recording(
        deployment=deployment,
        path=Path("test/path2"),
        duration=10.0,
        samplerate=44100,
        created_on=now + datetime.timedelta(seconds=10),
    )

    # Add them to the database
    sqlite_store.store_recording(recording1)
    sqlite_store.store_recording(recording2)

    # Act
    recordings = sqlite_store.get_recordings([recording1.id])

    # Assert
    assert len(recordings) == 1
    assert recordings[0][0] == recording1


def test_can_store_model_outputs(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_model_output(model_output)
    db_path = sqlite_store.db_path

    retrieved = sqlite_store.get_recordings([model_output.recording.id])
    assert len(retrieved) == 1

    _, model_outputs = retrieved[0]
    assert len(model_outputs) == 1

    retrieved = model_outputs[0]

    assert len(retrieved.detections) == len(model_output.detections)
    assert retrieved == model_output

    # Check that the detections were stored
    with sqlite3.connect(str(db_path)) as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT id FROM model_output;")
        assert len(cursor.fetchall()) == 1

        cursor.execute("SELECT * FROM detection;")
        row = cursor.fetchone()
        assert row is not None
        assert row[0] == model_output.detections[0].id.bytes
        assert row[1] == 1
        assert row[2] == 2
        assert row[3] == 1000
        assert row[4] == 2000
        assert row[5] == model_output.detections[0].detection_score


def test_can_store_multiple_model_outputs(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    second_recording = model_output.recording.model_copy(
        update=dict(
            id=uuid.uuid4(),
            path=Path("test/path-second"),
            created_on=model_output.recording.created_on
            + datetime.timedelta(seconds=5),
        )
    )
    second_output = model_output.model_copy(
        update=dict(
            id=uuid.uuid4(),
            recording=second_recording,
            created_on=model_output.created_on + datetime.timedelta(seconds=5),
            detections=[
                detection.model_copy(update=dict(id=uuid.uuid4()))
                for detection in model_output.detections
            ],
        )
    )

    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_recording(second_recording)
    sqlite_store.store_model_outputs([model_output, second_output])

    retrieved = sqlite_store.get_recordings(
        [model_output.recording.id, second_output.recording.id]
    )
    assert len(retrieved) == 2


def test_get_recordings_by_path_returns_full_model_outputs(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_model_output(model_output)

    retrieved = sqlite_store.get_recordings_by_path(
        [model_output.recording.path]
    )

    assert len(retrieved) == 1
    recording, outputs = retrieved[0]
    assert recording == model_output.recording
    assert outputs == [model_output]


def test_get_recordings_info_by_path_returns_lightweight_model_output_info(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_model_output(model_output)

    retrieved = sqlite_store.get_recordings_info_by_path(
        [model_output.recording.path]
    )

    assert len(retrieved) == 1
    recording, outputs = retrieved[0]
    assert recording == model_output.recording
    assert len(outputs) == 1
    assert outputs[0].id == model_output.id
    assert outputs[0].name_model == model_output.name_model
    assert outputs[0].created_on == model_output.created_on


def test_get_recording_model_outputs_returns_full_outputs(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_model_output(model_output)

    retrieved = sqlite_store.get_recording_model_outputs(
        model_output.recording
    )

    assert retrieved == [model_output]


def test_get_recordings_model_outputs_returns_outputs_grouped_by_recording(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    second_recording = model_output.recording.model_copy(
        update=dict(
            id=uuid.uuid4(),
            path=Path("test/path-second"),
            created_on=model_output.recording.created_on
            + datetime.timedelta(seconds=5),
        )
    )
    second_output = model_output.model_copy(
        update=dict(
            id=uuid.uuid4(),
            recording=second_recording,
            created_on=model_output.created_on + datetime.timedelta(seconds=5),
            detections=[
                detection.model_copy(update=dict(id=uuid.uuid4()))
                for detection in model_output.detections
            ],
        )
    )

    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_recording(second_recording)
    sqlite_store.store_model_outputs([model_output, second_output])

    retrieved = sqlite_store.get_recordings_model_outputs(
        [model_output.recording, second_recording]
    )

    assert retrieved == {
        model_output.recording.id: [model_output],
        second_recording.id: [second_output],
    }


def test_get_recordings_model_outputs_returns_empty_list_for_recording_without_outputs(
    sqlite_store: components.SqliteStore,
    recording: data.Recording,
):
    sqlite_store.store_recording(recording)

    retrieved = sqlite_store.get_recordings_model_outputs([recording])

    assert retrieved == {recording.id: []}


def test_get_recordings_model_outputs_includes_requested_recordings_without_outputs(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    recording_without_outputs = model_output.recording.model_copy(
        update=dict(
            id=uuid.uuid4(),
            path=Path("test/path-without-outputs"),
            created_on=model_output.recording.created_on
            + datetime.timedelta(seconds=5),
        )
    )

    sqlite_store.store_recording(model_output.recording)
    sqlite_store.store_recording(recording_without_outputs)
    sqlite_store.store_model_output(model_output)

    retrieved = sqlite_store.get_recordings_model_outputs(
        [model_output.recording, recording_without_outputs]
    )

    assert retrieved == {
        model_output.recording.id: [model_output],
        recording_without_outputs.id: [],
    }


def test_get_recordings_model_outputs_chunks_large_batches(
    sqlite_store: components.SqliteStore,
    deployment: data.Deployment,
    monkeypatch: pytest.MonkeyPatch,
):
    monkeypatch.setattr(
        sqlite_store_module,
        "SQLITE_MAX_BOUND_VARIABLES",
        2,
    )

    recordings = []
    model_outputs = []
    base_time = datetime.datetime.now()
    for index in range(5):
        recording = data.Recording(
            deployment=deployment,
            path=Path(f"test/chunked-{index}.wav"),
            duration=10.0,
            samplerate=44100,
            created_on=base_time + datetime.timedelta(seconds=index),
        )
        model_output = data.ModelOutput(
            name_model=f"test_model_{index}",
            recording=recording,
            tags=[
                data.PredictedTag(
                    tag=data.Tag(key="test", value=f"value-{index}"),
                    confidence_score=0.8,
                )
            ],
            detections=[
                data.Detection(
                    id=uuid.uuid4(),
                    location=data.BoundingBox(coordinates=(1, 1000, 2, 2000)),
                    detection_score=0.6,
                    tags=[
                        data.PredictedTag(
                            tag=data.Tag(key="species", value=f"tag-{index}"),
                            confidence_score=0.3,
                        )
                    ],
                )
            ],
            created_on=recording.created_on,
        )
        recordings.append(recording)
        model_outputs.append(model_output)

    for recording in recordings:
        sqlite_store.store_recording(recording)
    sqlite_store.store_model_outputs(model_outputs)

    retrieved = sqlite_store.get_recordings_model_outputs(recordings)

    assert retrieved == {
        recording.id: [model_output]
        for recording, model_output in zip(
            recordings,
            model_outputs,
            strict=True,
        )
    }


def test_store_model_outputs_fails_if_recording_is_missing(
    sqlite_store: components.SqliteStore,
    model_output: data.ModelOutput,
):
    with pytest.raises(MetadataStoreError) as excinfo:
        sqlite_store.store_model_outputs([model_output])

    message = str(excinfo.value)
    assert "not present in the metadata store" in message
    assert str(model_output.recording.id) in message
    assert str(model_output.recording.path) in message
    assert "store_recording()" in message


def test_in_memory_store_raises_clear_error_for_batched_model_output_loads(
    deployment: data.Deployment,
):
    sqlite_store = components.SqliteStore(cast(Path, ":memory:"))
    recording = data.Recording(
        deployment=deployment,
        path=Path("test/in-memory.wav"),
        duration=10.0,
        samplerate=44100,
        created_on=datetime.datetime.now(),
    )

    with pytest.raises(MetadataStoreError) as excinfo:
        sqlite_store.get_recordings_model_outputs([recording])

    assert "Direct sqlite batch operations are not supported" in str(
        excinfo.value
    )
    assert "file-backed sqlite store" in str(excinfo.value)


def test_can_update_deployment_info(
    sqlite_store: components.SqliteStore,
):
    start = datetime.datetime.now()
    deployment = data.Deployment(
        started_on=start,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )
    sqlite_store.store_deployment(deployment)

    current_deployment = sqlite_store.get_current_deployment()
    assert deployment == current_deployment

    assert current_deployment.ended_on is None

    updated_deployment = deployment.model_copy(
        update=dict(ended_on=start + datetime.timedelta(days=2))
    )
    assert updated_deployment.ended_on is not None
    sqlite_store.update_deployment(updated_deployment)

    current_deployment = sqlite_store.get_current_deployment()
    assert updated_deployment.model_dump() == current_deployment.model_dump()
    assert current_deployment.ended_on is not None


def test_update_deployment_fails_if_corresponding_deployment_does_not_exist(
    sqlite_store: components.SqliteStore,
):
    start = datetime.datetime.now()
    deployment = data.Deployment(
        started_on=start,
        latitude=1.0,
        longitude=2.0,
        name="test_device",
    )
    with pytest.raises(ValueError):
        sqlite_store.update_deployment(deployment)
