"""Tests for observation deduplication.

Covers:
- dedup_observations() — batch merge of duplicate observations within time window
- should_deduplicate() — pre-insert duplicate check
- increment_observation() — in-place count increment on existing observation
"""
import json
import sqlite3
from datetime import datetime, timedelta, timezone

import pytest

from greymatter_core.dedup import (
    dedup_observations,
    increment_observation,
    should_deduplicate,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

OBSERVATIONS_SCHEMA = """
CREATE TABLE observations (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now')),
    pruned INTEGER DEFAULT 0
);

CREATE VIRTUAL TABLE observations_fts USING fts5(
    title, content, content='observations', content_rowid='rowid'
);

CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
    INSERT INTO observations_fts(rowid, title, content)
    VALUES (new.rowid, new.title, new.content);
END;
"""


@pytest.fixture
def conn():
    """Create an in-memory database with the observations schema."""
    c = sqlite3.connect(":memory:")
    c.executescript(OBSERVATIONS_SCHEMA)
    yield c
    c.close()


def _insert(conn: sqlite3.Connection, obs_id: str, title: str,
            content: str, created_at: str, updated_at: str | None = None) -> None:
    """Helper to insert an observation row."""
    if updated_at is None:
        updated_at = created_at
    conn.execute(
        "INSERT INTO observations (id, title, content, created_at, updated_at, pruned) "
        "VALUES (?, ?, ?, ?, ?, 0)",
        (obs_id, title, content, created_at, updated_at),
    )
    conn.commit()


def _ts(minutes_ago: int) -> str:
    """Return an ISO timestamp *minutes_ago* minutes in the past."""
    return (datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)).isoformat()


# ---------------------------------------------------------------------------
# dedup_observations() tests
# ---------------------------------------------------------------------------

class TestDedupObservations:
    """Batch deduplication of observations."""

    def test_no_rows_returns_zero(self, conn):
        assert dedup_observations(conn) == 0

    def test_single_row_no_dedup(self, conn):
        _insert(conn, "obs-1", "Title A", "{}", _ts(1))
        assert dedup_observations(conn) == 0

    def test_different_titles_no_dedup(self, conn):
        ts = _ts(1)
        _insert(conn, "obs-1", "Title A", "{}", ts)
        _insert(conn, "obs-2", "Title B", "{}", ts)
        assert dedup_observations(conn) == 0

    def test_same_title_within_window_merges(self, conn):
        base = _ts(3)
        later = _ts(1)
        _insert(conn, "obs-1", "Dup Title", '{"key":"v1"}', base)
        _insert(conn, "obs-2", "Dup Title", '{"key":"v2"}', later, later)

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 1

        # Keeper should be the earliest (obs-1)
        rows = conn.execute("SELECT id, content, updated_at FROM observations").fetchall()
        assert len(rows) == 1
        assert rows[0][0] == "obs-1"

        content = json.loads(rows[0][1])
        assert content["dedup_count"] == 2
        assert "obs-2" in content["dedup_merged_ids"]

        # updated_at should be the latest in the group
        assert rows[0][2] == later

    def test_same_title_outside_window_not_merged(self, conn):
        _insert(conn, "obs-1", "Title", "{}", _ts(20))
        _insert(conn, "obs-2", "Title", "{}", _ts(1))

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 0
        assert conn.execute("SELECT count(*) FROM observations").fetchone()[0] == 2

    def test_three_duplicates_merged(self, conn):
        t1 = _ts(4)
        t2 = _ts(3)
        t3 = _ts(2)
        _insert(conn, "obs-1", "Triple", "{}", t1)
        _insert(conn, "obs-2", "Triple", "{}", t2)
        _insert(conn, "obs-3", "Triple", "{}", t3, t3)

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 2

        rows = conn.execute("SELECT id FROM observations").fetchall()
        assert len(rows) == 1
        assert rows[0][0] == "obs-1"

    def test_dry_run_does_not_modify(self, conn):
        _insert(conn, "obs-1", "Dup", "{}", _ts(2))
        _insert(conn, "obs-2", "Dup", "{}", _ts(1))

        deleted = dedup_observations(conn, window_minutes=5, dry_run=True)
        assert deleted == 1
        # Both rows should still exist
        assert conn.execute("SELECT count(*) FROM observations").fetchone()[0] == 2

    def test_pruned_rows_excluded(self, conn):
        _insert(conn, "obs-1", "Title", "{}", _ts(2))
        _insert(conn, "obs-2", "Title", "{}", _ts(1))
        conn.execute("UPDATE observations SET pruned = 1 WHERE id = 'obs-2'")
        conn.commit()

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 0

    def test_fts_entries_cleaned(self, conn):
        _insert(conn, "obs-1", "FTS Test", "{}", _ts(2))
        _insert(conn, "obs-2", "FTS Test", "{}", _ts(1))

        # Verify FTS has entries before dedup
        fts_before = conn.execute("SELECT count(*) FROM observations_fts").fetchone()[0]
        assert fts_before == 2

        dedup_observations(conn, window_minutes=5)

        fts_after = conn.execute("SELECT count(*) FROM observations_fts").fetchone()[0]
        assert fts_after == 1

    def test_non_json_content_handled(self, conn):
        _insert(conn, "obs-1", "Plain", "not json", _ts(2))
        _insert(conn, "obs-2", "Plain", "also not json", _ts(1))

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 1

        row = conn.execute("SELECT content FROM observations").fetchone()
        content = json.loads(row[0])
        assert content["original"] == "not json"
        assert content["dedup_count"] == 2

    def test_multiple_groups_merged_independently(self, conn):
        ts = _ts(1)
        _insert(conn, "a1", "Group A", "{}", ts)
        _insert(conn, "a2", "Group A", "{}", ts)
        _insert(conn, "b1", "Group B", "{}", ts)
        _insert(conn, "b2", "Group B", "{}", ts)
        _insert(conn, "b3", "Group B", "{}", ts)

        deleted = dedup_observations(conn, window_minutes=5)
        assert deleted == 3  # 1 from A + 2 from B
        assert conn.execute("SELECT count(*) FROM observations").fetchone()[0] == 2


# ---------------------------------------------------------------------------
# should_deduplicate() tests
# ---------------------------------------------------------------------------

class TestShouldDeduplicate:
    """Pre-insert duplicate detection."""

    def test_no_match_returns_none(self, conn):
        assert should_deduplicate(conn, "New Title") is None

    def test_match_within_window(self, conn):
        _insert(conn, "obs-1", "Existing", "{}", _ts(2))
        result = should_deduplicate(conn, "Existing", window_minutes=5)
        assert result == "obs-1"

    def test_match_outside_window_returns_none(self, conn):
        _insert(conn, "obs-1", "Old", "{}", _ts(10))
        result = should_deduplicate(conn, "Old", window_minutes=5)
        assert result is None

    def test_pruned_observation_not_matched(self, conn):
        _insert(conn, "obs-1", "Pruned", "{}", _ts(1))
        conn.execute("UPDATE observations SET pruned = 1 WHERE id = 'obs-1'")
        conn.commit()
        assert should_deduplicate(conn, "Pruned", window_minutes=5) is None

    def test_returns_most_recent_match(self, conn):
        _insert(conn, "obs-old", "Multi", "{}", _ts(3))
        _insert(conn, "obs-new", "Multi", "{}", _ts(1))
        result = should_deduplicate(conn, "Multi", window_minutes=5)
        assert result == "obs-new"


# ---------------------------------------------------------------------------
# increment_observation() tests
# ---------------------------------------------------------------------------

class TestIncrementObservation:
    """In-place increment of dedup count."""

    def test_increments_count_from_one(self, conn):
        _insert(conn, "obs-1", "Test", '{"data":"v1"}', _ts(1))
        increment_observation(conn, "obs-1", '{"data":"v2"}')

        row = conn.execute("SELECT content FROM observations WHERE id = 'obs-1'").fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2
        assert content["data"] == "v2"  # latest content merged

    def test_increments_existing_count(self, conn):
        _insert(conn, "obs-1", "Test", '{"dedup_count":3}', _ts(1))
        increment_observation(conn, "obs-1", '{"extra":"field"}')

        row = conn.execute("SELECT content FROM observations WHERE id = 'obs-1'").fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 4
        assert content["extra"] == "field"

    def test_nonexistent_id_no_error(self, conn):
        # Should silently return without error
        increment_observation(conn, "obs-missing", '{"data":"v"}')

    def test_non_json_content_wrapped(self, conn):
        _insert(conn, "obs-1", "Test", "plain text", _ts(1))
        increment_observation(conn, "obs-1", '{"new":"data"}')

        row = conn.execute("SELECT content FROM observations WHERE id = 'obs-1'").fetchone()
        content = json.loads(row[0])
        assert content["original"] == "plain text"
        assert content["dedup_count"] == 2

    def test_non_json_new_content_handled(self, conn):
        _insert(conn, "obs-1", "Test", '{"data":"v1"}', _ts(1))
        increment_observation(conn, "obs-1", "not json")

        row = conn.execute("SELECT content FROM observations WHERE id = 'obs-1'").fetchone()
        content = json.loads(row[0])
        assert content["dedup_count"] == 2
        assert content["data"] == "v1"  # original preserved

    def test_updated_at_refreshed(self, conn):
        old_ts = _ts(60)
        _insert(conn, "obs-1", "Test", '{}', old_ts)
        increment_observation(conn, "obs-1", '{}')

        row = conn.execute("SELECT updated_at FROM observations WHERE id = 'obs-1'").fetchone()
        assert row[0] != old_ts  # Should be updated to now
