"""Delta sync: apply incremental changes between databases.

Reads the _sync_log on the source since a given version, fetches
the changed rows, and applies them to the target. Supports
bi-directional sync by running in both directions.

Can optionally use the outbox for crash-safe delivery — outbox entries
are marked delivered only after the target confirms the write.
"""
from __future__ import annotations

import logging

from greymatter_core.db import now_sql
from greymatter_core.sync.conflict import (
    flush_audit_log,
    persist_audit_log,
    resolve_conflict,
)
from greymatter_core.sync.models import SyncLogEntry, SyncOperation, SyncResult
from greymatter_core.sync import outbox as outbox_mod
from greymatter_core.sync.tracking import (
    _get_pk_col,
    get_changes_since,
    get_sync_version,
    stamp_hlc,
)

log = logging.getLogger(__name__)

DEFAULT_BATCH_SIZE = 500


def delta_sync(
    source,
    target,
    *,
    since_version: int | None = None,
    batch_size: int = DEFAULT_BATCH_SIZE,
) -> SyncResult:
    """Apply changes from source to target since a given version.

    Args:
        source: Database with changes to read.
        target: Database to apply changes to.
        since_version: Version to sync from. If None, uses target's current version.
        batch_size: Max changes to process per batch.

    Returns:
        SyncResult with counts of rows pushed and conflicts.
    """
    if since_version is None:
        since_version = get_sync_version(target)

    result = SyncResult()
    raw_changes = get_changes_since(source, since_version)

    if not raw_changes:
        return result

    # Deduplicate: keep only the latest change per (table, row_id)
    deduped = _deduplicate_changes(raw_changes)

    for entry in deduped:
        try:
            conflict = _apply_change(source, target, entry)
            result.rows_pushed += 1
            if conflict:
                result.conflicts_resolved += 1
        except Exception as e:
            result.errors.append(f"{entry.table_name}/{entry.row_id}: {e}")

    if deduped:
        result.tables_synced = len({e.table_name for e in deduped})

    # Update target's sync version to source's current version
    source_version = get_sync_version(source)
    try:
        target.execute(
            f"UPDATE _sync_state SET value = ?, updated_at = {now_sql(target)} "
            "WHERE key = ?",
            (str(source_version), "sync_version"),
        )
    except Exception as e:
        result.errors.append(f"version update: {e}")

    # Persist conflict audit log
    try:
        audit_entries = flush_audit_log()
        if audit_entries:
            persist_audit_log(target, audit_entries)
    except Exception as e:
        log.warning("Failed to persist audit log: %s", e)

    return result


def bidirectional_sync(
    db_a,
    db_b,
    *,
    a_since: int | None = None,
    b_since: int | None = None,
) -> tuple[SyncResult, SyncResult]:
    """Run delta sync in both directions.

    Args:
        db_a: First database.
        db_b: Second database.
        a_since: Version to sync A's changes from (default: 0 for first sync).
        b_since: Version to sync B's changes from (default: 0 for first sync).

    Returns (a_to_b_result, b_to_a_result).
    """
    if a_since is None:
        a_since = 0
    if b_since is None:
        b_since = 0

    # A → B: push A's changes since b_since
    a_to_b = delta_sync(db_a, db_b, since_version=a_since)

    # B → A: push B's changes since a_since
    b_to_a = delta_sync(db_b, db_a, since_version=b_since)

    return a_to_b, b_to_a


def _deduplicate_changes(raw_changes: list[dict]) -> list[SyncLogEntry]:
    """Keep only the latest change per (table_name, row_id)."""
    seen: dict[tuple[str, str], SyncLogEntry] = {}
    for row in raw_changes:
        entry = SyncLogEntry.from_row(row)
        key = (entry.table_name, entry.row_id)
        # Later entries overwrite earlier ones (higher sync_version wins)
        seen[key] = entry
    return list(seen.values())


def _apply_change(source, target, entry: SyncLogEntry) -> bool:
    """Apply a single change to the target database.

    Uses the conflict resolver to decide the winning row when both
    local and remote versions exist. Returns True if a conflict was
    detected and resolved.
    """
    pk_col = _get_pk_col(entry.table_name)

    if entry.operation == SyncOperation.DELETE:
        # DELETE in the sync log is an explicit action — always propagate
        target.execute(
            f"DELETE FROM {entry.table_name} WHERE {pk_col} = ?",
            (entry.row_id,),
        )
        return False

    # UPSERT: fetch remote row from source
    remote_row = source.execute_one(
        f"SELECT * FROM {entry.table_name} WHERE {pk_col} = ?",
        (entry.row_id,),
    )
    if remote_row is None:
        # Row was deleted between log entry and now — skip
        return False

    remote_dict = dict(remote_row)

    # Skip cognitive-level knowledge entries — they never sync
    if (
        entry.table_name == "knowledge_entries"
        and remote_dict.get("classification_level", 3) >= 4
    ):
        return False

    # Fetch local row to check for conflict
    local_row = target.execute_one(
        f"SELECT * FROM {entry.table_name} WHERE {pk_col} = ?",
        (entry.row_id,),
    )
    had_conflict = local_row is not None
    local_dict = dict(local_row) if local_row else None

    # Resolve conflict (or just accept remote if no local row)
    resolved = resolve_conflict(entry.table_name, local_dict, remote_dict)

    if resolved is None:
        # Resolver says delete
        if local_row is not None:
            target.execute(
                f"DELETE FROM {entry.table_name} WHERE {pk_col} = ?",
                (entry.row_id,),
            )
        return had_conflict

    # Write the resolved row — strip sync tracking columns (managed by triggers)
    # Keep _hlc_timestamp for now — we'll stamp a causally-later one after write
    source_hlc = resolved.get("_hlc_timestamp", "")
    for sync_col in ("_sync_version", "_node_id"):
        resolved.pop(sync_col, None)
    columns = list(resolved.keys())
    placeholders = ", ".join("?" for _ in columns)
    col_list = ", ".join(columns)
    update_cols = [c for c in columns if c != pk_col]

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

    target.execute(sql, tuple(resolved[c] for c in columns))

    # Stamp a causally-later HLC on the target so its clock advances
    # past the source's timestamp. This ensures future local writes
    # on the target will have HLC > the synced row's HLC.
    if source_hlc:
        try:
            stamp_hlc(target, entry.table_name, pk_col, entry.row_id,
                       remote_hlc=source_hlc)
        except Exception as e:
            log.debug("HLC stamp failed for %s/%s: %s",
                      entry.table_name, entry.row_id, e)

    return had_conflict


def outbox_sync(
    source,
    target,
    *,
    batch_size: int = DEFAULT_BATCH_SIZE,
) -> SyncResult:
    """Sync using the outbox for crash-safe delivery.

    Instead of scanning _sync_log (which can miss rows if the node
    crashes between data write and next sync cycle), this reads from
    _sync_outbox — entries written atomically with data.

    Flow:
      1. Read pending outbox entries from source
      2. For each entry, fetch the current row and apply to target
      3. On success, mark the outbox entry as delivered
      4. On failure, increment attempts and record error

    Returns:
        SyncResult with counts.
    """
    result = SyncResult()

    pending = outbox_mod.get_pending(source, limit=batch_size)
    if not pending:
        return result

    delivered_ids: list[int] = []

    for entry in pending:
        outbox_id = entry["id"]
        table = entry["table_name"]
        row_id = entry["row_id"]
        operation = entry["operation"]

        try:
            if operation == "delete":
                pk_col = _get_pk_col(table)
                target.execute(
                    f"DELETE FROM {table} WHERE {pk_col} = ?",
                    (row_id,),
                )
            else:
                # Fetch current row from source
                pk_col = _get_pk_col(table)
                source_row = source.execute_one(
                    f"SELECT * FROM {table} WHERE {pk_col} = ?",
                    (row_id,),
                )
                if source_row is None:
                    # Row was deleted after outbox entry — skip
                    delivered_ids.append(outbox_id)
                    continue

                # Skip cognitive-level knowledge entries — they never sync
                source_dict = dict(source_row)
                if (
                    table == "knowledge_entries"
                    and source_dict.get("classification_level", 3) >= 4
                ):
                    delivered_ids.append(outbox_id)
                    continue

                # Check for conflict on target
                target_row = target.execute_one(
                    f"SELECT * FROM {table} WHERE {pk_col} = ?",
                    (row_id,),
                )

                if target_row is not None:
                    resolved = resolve_conflict(
                        table, dict(target_row), dict(source_row)
                    )
                    result.conflicts_resolved += 1
                else:
                    resolved = dict(source_row)

                if resolved is None:
                    target.execute(
                        f"DELETE FROM {table} WHERE {pk_col} = ?",
                        (row_id,),
                    )
                else:
                    # Strip sync tracking columns
                    for sync_col in ("_sync_version", "_node_id"):
                        resolved.pop(sync_col, None)
                    columns = list(resolved.keys())
                    placeholders = ", ".join("?" for _ in columns)
                    col_list = ", ".join(columns)
                    update_cols = [c for c in columns 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}) "
                            f"VALUES ({placeholders}) "
                            f"ON CONFLICT({pk_col}) DO UPDATE SET {set_clause}"
                        )
                    else:
                        sql = (
                            f"INSERT INTO {table} ({col_list}) "
                            f"VALUES ({placeholders}) "
                            f"ON CONFLICT({pk_col}) DO NOTHING"
                        )
                    target.execute(sql, tuple(resolved[c] for c in columns))

            delivered_ids.append(outbox_id)
            result.rows_pushed += 1

        except Exception as e:
            outbox_mod.mark_failed(source, outbox_id, str(e))
            result.errors.append(f"{table}/{row_id}: {e}")

    # Mark all successful entries as delivered
    if delivered_ids:
        outbox_mod.mark_delivered(source, delivered_ids)

    if pending:
        result.tables_synced = len({e["table_name"] for e in pending})

    # Persist conflict audit log
    try:
        audit_entries = flush_audit_log()
        if audit_entries:
            persist_audit_log(target, audit_entries)
    except Exception as e:
        log.warning("Failed to persist audit log: %s", e)

    return result
