"""Observation deduplication for GreyMatter.

Merges near-duplicate observations that share the same title within a
configurable time window.  Keeps the earliest occurrence, annotates its
content with a repeat count, and deletes the later duplicates.

Usage (one-shot cleanup)::

    import sqlite3, os
    from greymatter_core.dedup import dedup_observations

    conn = sqlite3.connect(os.path.expanduser("~/.greymatter/greymatter.db"))
    merged = dedup_observations(conn, window_minutes=5)
    print(f"Merged {merged} duplicate observations")
    conn.close()

The companion function ``should_deduplicate()`` is used by the
mid_session_observer hook to prevent duplicates at the source.
"""

from __future__ import annotations

import json
import logging
import sqlite3
from datetime import datetime, timedelta, timezone

logger = logging.getLogger(__name__)


def dedup_observations(
    conn: sqlite3.Connection,
    window_minutes: int = 5,
    *,
    dry_run: bool = False,
) -> int:
    """Merge duplicate observations that share the same title within *window_minutes*.

    For each group of duplicates:
    - The **earliest** row (by ``created_at``) is kept.
    - Its ``content`` is updated with a ``"dedup_count"`` field so the
      merge is auditable.
    - Its ``updated_at`` is set to the latest timestamp in the group.
    - All other rows in the group are deleted (along with their FTS
      entries).

    Args:
        conn: An open ``sqlite3.Connection`` to the GreyMatter database.
        window_minutes: Maximum gap (in minutes) between two observations
            with the same title for them to be considered duplicates.
        dry_run: If ``True``, compute the merge count but do not modify
            the database.

    Returns:
        The number of duplicate rows that were (or would be) deleted.
    """
    window_seconds = window_minutes * 60

    # Fetch all non-pruned observations ordered by title then time.
    rows = conn.execute(
        """
        SELECT id, title, content, created_at, updated_at
        FROM observations
        WHERE pruned = 0
        ORDER BY title, created_at
        """
    ).fetchall()

    if not rows:
        return 0

    # ---- Group consecutive same-title rows within the time window ----

    groups: list[list[tuple]] = []
    current_group: list[tuple] = [rows[0]]

    for row in rows[1:]:
        prev = current_group[-1]
        same_title = row[1] == prev[1]

        within_window = False
        if same_title:
            try:
                t_prev = datetime.fromisoformat(prev[3])
                t_curr = datetime.fromisoformat(row[3])
                within_window = abs((t_curr - t_prev).total_seconds()) <= window_seconds
            except (ValueError, TypeError):
                within_window = False

        if same_title and within_window:
            current_group.append(row)
        else:
            if len(current_group) > 1:
                groups.append(current_group)
            current_group = [row]

    # Don't forget the last group.
    if len(current_group) > 1:
        groups.append(current_group)

    if not groups:
        return 0

    total_deleted = 0

    if not dry_run:
        conn.execute("BEGIN")

    try:
        for group in groups:
            keeper = group[0]  # earliest
            duplicates = group[1:]
            count = len(group)

            # Annotate the keeper's content with the repeat count.
            try:
                content_obj = json.loads(keeper[2])
            except (json.JSONDecodeError, TypeError):
                content_obj = {"original": keeper[2]}

            content_obj["dedup_count"] = count
            content_obj["dedup_merged_ids"] = [r[0] for r in duplicates]

            # Use the latest updated_at from the group.
            latest_updated = max(
                r[4] for r in group if r[4] is not None
            )

            if not dry_run:
                conn.execute(
                    """
                    UPDATE observations
                    SET content = ?, updated_at = ?
                    WHERE id = ?
                    """,
                    (json.dumps(content_obj, separators=(",", ":")), latest_updated, keeper[0]),
                )

                dup_ids = [r[0] for r in duplicates]
                placeholders = ",".join("?" for _ in dup_ids)

                # Delete FTS entries first (rowid-based; look up rowids).
                conn.execute(
                    f"""
                    DELETE FROM observations_fts
                    WHERE rowid IN (
                        SELECT rowid FROM observations WHERE id IN ({placeholders})
                    )
                    """,
                    dup_ids,
                )

                # Delete the duplicate rows.
                conn.execute(
                    f"DELETE FROM observations WHERE id IN ({placeholders})",
                    dup_ids,
                )

            total_deleted += len(duplicates)

        if not dry_run:
            conn.execute("COMMIT")

    except Exception:
        if not dry_run:
            conn.execute("ROLLBACK")
        raise

    logger.info(
        "dedup_observations: %d groups merged, %d rows deleted%s",
        len(groups),
        total_deleted,
        " (dry run)" if dry_run else "",
    )

    return total_deleted


def should_deduplicate(
    conn: sqlite3.Connection,
    title: str,
    window_minutes: int = 5,
) -> str | None:
    """Check whether an observation with *title* already exists within the time window.

    This is called by the mid_session_observer hook **before** inserting a
    new row.  If a match is found, the existing row's ``id`` is returned
    so the caller can increment its count instead of creating a duplicate.

    Args:
        conn: An open ``sqlite3.Connection`` to the GreyMatter database.
        title: The observation title to check.
        window_minutes: Look-back window in minutes.

    Returns:
        The ``id`` of the existing observation to update, or ``None`` if
        no recent duplicate exists.
    """
    cutoff = (datetime.now(timezone.utc) - timedelta(minutes=window_minutes)).isoformat()

    row = conn.execute(
        """
        SELECT id FROM observations
        WHERE title = ? AND created_at >= ? AND pruned = 0
        ORDER BY created_at DESC
        LIMIT 1
        """,
        (title, cutoff),
    ).fetchone()

    return row[0] if row else None


def increment_observation(
    conn: sqlite3.Connection,
    observation_id: str,
    new_content: str,
) -> None:
    """Increment the dedup count on an existing observation.

    Called by the mid_session_observer when ``should_deduplicate()``
    returns a match.

    Args:
        conn: An open ``sqlite3.Connection``.
        observation_id: The ``id`` of the observation to update.
        new_content: The JSON content from the latest occurrence (used
            to keep the content fresh).
    """
    row = conn.execute(
        "SELECT content FROM observations WHERE id = ?",
        (observation_id,),
    ).fetchone()

    if not row:
        return

    try:
        content_obj = json.loads(row[0])
    except (json.JSONDecodeError, TypeError):
        content_obj = {"original": row[0]}

    current_count = content_obj.get("dedup_count", 1)
    content_obj["dedup_count"] = current_count + 1

    # Keep the latest raw payload so content stays current.
    try:
        latest = json.loads(new_content)
        content_obj.update({k: v for k, v in latest.items() if k != "dedup_count"})
    except (json.JSONDecodeError, TypeError):
        pass

    conn.execute(
        """
        UPDATE observations
        SET content = ?, updated_at = datetime('now')
        WHERE id = ?
        """,
        (json.dumps(content_obj, separators=(",", ":")), observation_id),
    )
    conn.commit()
