"""Change tracking for sync engine.

Installs triggers on syncable tables that write to _sync_log on every
INSERT, UPDATE, and DELETE. Uses SQLite triggers for Solo mode.

The _sync_log table records:
    - table_name: which table changed
    - row_id: primary key of the changed row
    - operation: 'upsert' or 'delete'
    - sync_version: monotonically increasing counter
    - node_id: local node identifier
"""
from __future__ import annotations

from greymatter_core.db import now_sql
from greymatter_core.sync import SYNC_TABLES
from greymatter_core.sync.hlc import HLCWriter


def get_node_id(db) -> str:
    """Read the local node ID from _sync_state."""
    row = db.execute_one(
        "SELECT value FROM _sync_state WHERE key = ?", ("node_id",)
    )
    return row["value"] if row else "unknown"


def get_sync_version(db) -> int:
    """Read the current sync version counter."""
    row = db.execute_one(
        "SELECT value FROM _sync_state WHERE key = ?", ("sync_version",)
    )
    return int(row["value"]) if row else 0


def increment_sync_version(db) -> int:
    """Increment and return the new sync version."""
    current = get_sync_version(db)
    new_version = current + 1
    db.execute(
        f"UPDATE _sync_state SET value = ?, updated_at = {now_sql(db)} "
        "WHERE key = ?",
        (str(new_version), "sync_version"),
    )
    return new_version


def _get_hlc_writer(db) -> HLCWriter:
    """Get (or create) the HLCWriter for the current node.

    Caches the writer on the db object to avoid repeated lookups.
    """
    writer = getattr(db, "_hlc_writer", None)
    if writer is None:
        node_id = get_node_id(db)
        writer = HLCWriter.get_instance(node_id)
        try:
            db._hlc_writer = writer  # type: ignore[attr-defined]
        except AttributeError:
            pass  # Some DB wrappers don't allow attribute assignment
    return writer


def hlc_write(db, table: str, columns: dict, *, pk_col: str = "id") -> str:
    """Insert a row into a synced table with HLC timestamp.

    Generates an HLC timestamp and includes it in the INSERT.
    The _sync_version and _node_id are still set by the trigger.

    Args:
        db: Database connection.
        table: Table name (must be in SYNC_TABLES).
        columns: Dict of column_name -> value to insert.
        pk_col: Primary key column name (default: "id").

    Returns:
        The HLC timestamp string that was stored.
    """
    writer = _get_hlc_writer(db)
    hlc_ts = writer.generate()

    # Inject _hlc_timestamp into the row
    columns = dict(columns)  # Don't mutate caller's dict
    columns["_hlc_timestamp"] = hlc_ts

    col_names = list(columns.keys())
    placeholders = ", ".join("?" for _ in col_names)
    col_list = ", ".join(col_names)

    db.execute(
        f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
        tuple(columns[c] for c in col_names),
    )
    return hlc_ts


def hlc_update(db, table: str, updates: dict, pk_col: str, pk_val) -> str:
    """Update a row in a synced table with a new HLC timestamp.

    Generates an HLC timestamp and includes it in the UPDATE.
    The _sync_version is bumped by the trigger.

    Args:
        db: Database connection.
        table: Table name (must be in SYNC_TABLES).
        updates: Dict of column_name -> new value.
        pk_col: Primary key column name.
        pk_val: Primary key value to identify the row.

    Returns:
        The HLC timestamp string that was stored.
    """
    writer = _get_hlc_writer(db)
    hlc_ts = writer.generate()

    # Inject _hlc_timestamp into the update
    updates = dict(updates)
    updates["_hlc_timestamp"] = hlc_ts

    set_parts = [f"{col} = ?" for col in updates.keys()]
    set_clause = ", ".join(set_parts)
    values = list(updates.values()) + [pk_val]

    db.execute(
        f"UPDATE {table} SET {set_clause} WHERE {pk_col} = ?",
        tuple(values),
    )
    return hlc_ts


def hlc_upsert(db, table: str, columns: dict, *, pk_col: str = "id") -> str:
    """Insert or update a row with HLC timestamp (ON CONFLICT DO UPDATE).

    Args:
        db: Database connection.
        table: Table name (must be in SYNC_TABLES).
        columns: Dict of column_name -> value.
        pk_col: Primary key column name.

    Returns:
        The HLC timestamp string that was stored.
    """
    writer = _get_hlc_writer(db)
    hlc_ts = writer.generate()

    columns = dict(columns)
    columns["_hlc_timestamp"] = hlc_ts

    col_names = list(columns.keys())
    placeholders = ", ".join("?" for _ in col_names)
    col_list = ", ".join(col_names)
    update_cols = [c for c in col_names if c != pk_col]

    if update_cols:
        set_clause = ", ".join(f"{c} = excluded.{c}" for c in update_cols)
        sql = (
            f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) "
            f"ON CONFLICT({pk_col}) DO UPDATE SET {set_clause}"
        )
    else:
        sql = (
            f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) "
            f"ON CONFLICT({pk_col}) DO NOTHING"
        )

    db.execute(sql, tuple(columns[c] for c in col_names))
    return hlc_ts


def stamp_hlc(db, table: str, pk_col: str, pk_val, remote_hlc: str | None = None) -> str:
    """Generate and store an HLC timestamp for an existing row.

    This is useful when applying a remote row during sync — we want to
    record that this node has seen the data at a specific causal point.

    If remote_hlc is provided, the generated timestamp will be causally
    after the remote one (via HLC.receive).

    Args:
        db: Database connection.
        table: Table name.
        pk_col: Primary key column.
        pk_val: Primary key value.
        remote_hlc: Optional remote HLC to merge with.

    Returns:
        The HLC timestamp string stored.
    """
    writer = _get_hlc_writer(db)
    if remote_hlc:
        hlc_ts = writer.receive_and_generate(remote_hlc)
    else:
        hlc_ts = writer.generate()

    db.execute(
        f"UPDATE {table} SET _hlc_timestamp = ? WHERE {pk_col} = ?",
        (hlc_ts, pk_val),
    )
    return hlc_ts


def install_sqlite_triggers(db) -> int:
    """Install change tracking triggers on all syncable tables.

    Returns the number of triggers installed.
    """
    node_id = get_node_id(db)
    count = 0

    for table in SYNC_TABLES:
        pk_col = _get_pk_col(table)

        # INSERT trigger
        db.execute(f"""
            CREATE TRIGGER IF NOT EXISTS _sync_{table}_insert
            AFTER INSERT ON {table}
            BEGIN
                INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id)
                VALUES (
                    '{table}',
                    NEW.{pk_col},
                    'upsert',
                    (SELECT CAST(value AS INTEGER) + 1 FROM _sync_state WHERE key = 'sync_version'),
                    '{node_id}'
                );
                UPDATE _sync_state
                SET value = CAST(value AS INTEGER) + 1, updated_at = datetime('now')
                WHERE key = 'sync_version';
                UPDATE {table}
                SET _sync_version = (SELECT CAST(value AS INTEGER) FROM _sync_state WHERE key = 'sync_version'),
                    _node_id = '{node_id}'
                WHERE {pk_col} = NEW.{pk_col};
            END
        """)
        count += 1

        # UPDATE trigger
        db.execute(f"""
            CREATE TRIGGER IF NOT EXISTS _sync_{table}_update
            AFTER UPDATE ON {table}
            WHEN OLD._sync_version = NEW._sync_version
            BEGIN
                INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id)
                VALUES (
                    '{table}',
                    NEW.{pk_col},
                    'upsert',
                    (SELECT CAST(value AS INTEGER) + 1 FROM _sync_state WHERE key = 'sync_version'),
                    '{node_id}'
                );
                UPDATE _sync_state
                SET value = CAST(value AS INTEGER) + 1, updated_at = datetime('now')
                WHERE key = 'sync_version';
                UPDATE {table}
                SET _sync_version = (SELECT CAST(value AS INTEGER) FROM _sync_state WHERE key = 'sync_version'),
                    _node_id = '{node_id}'
                WHERE {pk_col} = NEW.{pk_col};
            END
        """)
        count += 1

        # DELETE trigger
        db.execute(f"""
            CREATE TRIGGER IF NOT EXISTS _sync_{table}_delete
            AFTER DELETE ON {table}
            BEGIN
                INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id)
                VALUES (
                    '{table}',
                    OLD.{pk_col},
                    'delete',
                    (SELECT CAST(value AS INTEGER) + 1 FROM _sync_state WHERE key = 'sync_version'),
                    '{node_id}'
                );
                UPDATE _sync_state
                SET value = CAST(value AS INTEGER) + 1, updated_at = datetime('now')
                WHERE key = 'sync_version';
            END
        """)
        count += 1

    return count


def install_crdb_triggers(db) -> int:
    """Install CockroachDB change tracking triggers on all syncable tables.

    Uses PostgreSQL-compatible CREATE FUNCTION + CREATE TRIGGER syntax.
    Returns the number of triggers installed.
    """
    node_id = get_node_id(db)
    count = 0

    for table in SYNC_TABLES:
        pk_col = _get_pk_col(table)

        # Trigger function for INSERT/UPDATE
        # CRDB requires (NEW).field and (OLD).field syntax inside plpgsql
        db.execute(f"""
            CREATE OR REPLACE FUNCTION _sync_{table}_upsert_fn()
            RETURNS TRIGGER AS $$
            DECLARE
                new_version INTEGER;
            BEGIN
                UPDATE _sync_state
                SET value = (CAST(value AS INTEGER) + 1)::TEXT, updated_at = now()
                WHERE key = 'sync_version'
                RETURNING CAST(value AS INTEGER) INTO new_version;

                INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id)
                VALUES ('{table}', (NEW).{pk_col}::TEXT, 'upsert', new_version, '{node_id}');

                NEW._sync_version := new_version;
                NEW._node_id := '{node_id}';
                RETURN NEW;
            END;
            $$ LANGUAGE plpgsql
        """)

        # DROP existing triggers before creating (CRDB doesn't support CREATE OR REPLACE TRIGGER)
        for suffix in ("insert", "update", "delete"):
            db.execute(f"DROP TRIGGER IF EXISTS _sync_{table}_{suffix} ON {table}")

        # INSERT trigger
        db.execute(f"""
            CREATE TRIGGER _sync_{table}_insert
            BEFORE INSERT ON {table}
            FOR EACH ROW
            EXECUTE FUNCTION _sync_{table}_upsert_fn()
        """)
        count += 1

        # UPDATE trigger (skip if only _sync_version changed)
        db.execute(f"""
            CREATE TRIGGER _sync_{table}_update
            BEFORE UPDATE ON {table}
            FOR EACH ROW
            WHEN ((OLD)._sync_version IS NOT DISTINCT FROM (NEW)._sync_version)
            EXECUTE FUNCTION _sync_{table}_upsert_fn()
        """)
        count += 1

        # DELETE trigger function
        db.execute(f"""
            CREATE OR REPLACE FUNCTION _sync_{table}_delete_fn()
            RETURNS TRIGGER AS $$
            DECLARE
                new_version INTEGER;
            BEGIN
                UPDATE _sync_state
                SET value = (CAST(value AS INTEGER) + 1)::TEXT, updated_at = now()
                WHERE key = 'sync_version'
                RETURNING CAST(value AS INTEGER) INTO new_version;

                INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id)
                VALUES ('{table}', (OLD).{pk_col}::TEXT, 'delete', new_version, '{node_id}');

                RETURN OLD;
            END;
            $$ LANGUAGE plpgsql
        """)

        db.execute(f"""
            CREATE TRIGGER _sync_{table}_delete
            BEFORE DELETE ON {table}
            FOR EACH ROW
            EXECUTE FUNCTION _sync_{table}_delete_fn()
        """)
        count += 1

    return count


def uninstall_crdb_triggers(db) -> int:
    """Remove all CRDB sync triggers and functions. Returns count removed."""
    count = 0
    for table in SYNC_TABLES:
        for suffix in ("insert", "update", "delete"):
            db.execute(f"DROP TRIGGER IF EXISTS _sync_{table}_{suffix} ON {table}")
            count += 1
        for suffix in ("upsert", "delete"):
            db.execute(f"DROP FUNCTION IF EXISTS _sync_{table}_{suffix}_fn()")
    return count


def uninstall_sqlite_triggers(db) -> int:
    """Remove all sync triggers. Returns count of triggers removed."""
    count = 0
    for table in SYNC_TABLES:
        for suffix in ("insert", "update", "delete"):
            db.execute(f"DROP TRIGGER IF EXISTS _sync_{table}_{suffix}")
            count += 1
    return count


def get_changes_since(
    db,
    since_version: int = 0,
    *,
    since_log_id: int = 0,
    limit: int = 0,
) -> list[dict]:
    """Get sync log entries using cursor-based pagination.

    Prefers ``since_log_id`` (unique autoincrement) over ``since_version``
    (non-unique) to avoid losing entries at page boundaries when multiple
    rows share the same sync_version (e.g. bulk imports).

    Args:
        db: Database connection.
        since_version: Legacy fallback — filter by sync_version > ?.
        since_log_id: Preferred cursor — filter by id > ?.
        limit: Max entries to return (0 = unlimited).

    Returns:
        Entries ordered by id ascending.
    """
    if since_log_id > 0:
        sql = "SELECT * FROM _sync_log WHERE id > ? ORDER BY id ASC"
        params: list = [since_log_id]
    else:
        sql = "SELECT * FROM _sync_log WHERE sync_version > ? ORDER BY id ASC"
        params = [since_version]

    if limit > 0:
        sql += " LIMIT ?"
        params.append(limit)

    return db.execute(sql, tuple(params))


def cleanup_old_entries(db, retain_days: int = 7) -> int:
    """Remove sync log entries older than retain_days.

    Returns number of entries removed.
    """
    # SQLite: datetime('now', '-7 days')
    # CRDB:   now() - INTERVAL '7 days'
    if hasattr(db, "dialect") and db.dialect == "crdb":
        cutoff_expr = f"now() - INTERVAL '{retain_days} days'"
    else:
        cutoff_expr = f"datetime('now', '-{retain_days} days')"

    count_row = db.execute_one(
        f"SELECT COUNT(*) AS cnt FROM _sync_log WHERE created_at < {cutoff_expr}",
    )
    count = count_row["cnt"] if count_row else 0
    if count > 0:
        db.execute(
            f"DELETE FROM _sync_log WHERE created_at < {cutoff_expr}",
        )
    return count


def _get_pk_col(table: str) -> str:
    """Get the primary key column for a given table."""
    pk_map = {
        "patterns": "id",
        "pattern_evidence": "id",
        "knowledge_entries": "id",
        "work_items": "id",
        "sessions": "id",
        "observations": "id",
        "documents": "id",
    }
    return pk_map.get(table, "id")
