"""HTTP-based sync for Solo→Cluster communication.

Instead of direct DB-to-DB delta_sync, this module syncs via the
coordinator's REST API (/api/sync/pull and /api/sync/push).

Used when a Solo node is in SPOKE mode and needs to sync with
the distributed cluster over the network.
"""
from __future__ import annotations

import logging
from typing import Any

import httpx

from greymatter_core.http_client import RetryClient, RetryConfig
from greymatter_core.sync.models import SyncResult
from greymatter_core.sync import SYNC_TABLES
from greymatter_core.sync.tracking import (
    _get_pk_col,
    get_sync_version,
)

logger = logging.getLogger(__name__)

_HTTP_TIMEOUT = 30.0


def http_delta_sync(
    local_db: Any,
    hub_url: str,
    node_id: str = "solo",
    *,
    batch_size: int = 1000,
) -> SyncResult:
    """Bidirectional sync between local SQLite and cluster coordinator.

    1. Pull: GET changes from hub since our last sync version
    2. Apply: Write hub changes to local DB
    3. Push: Send our local changes to hub

    Args:
        local_db: Local SQLite database.
        hub_url: Coordinator URL (e.g., http://100.81.247.14:8000).
        node_id: This node's ID for tracking.
        batch_size: Max changes per request.

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

    try:
        # Phase 1: Pull from hub
        pull_result = _pull_from_hub(local_db, hub_url, node_id, batch_size)
        result.rows_pushed += pull_result.get("applied", 0)
        result.tables_synced = pull_result.get("tables", 0)

        # Phase 2: Push local changes to hub
        push_result = _push_to_hub(local_db, hub_url, node_id, batch_size)
        result.rows_pushed += push_result.get("applied", 0)
        if push_result.get("errors"):
            result.errors.extend(push_result["errors"])

    except Exception as e:
        result.errors.append(f"HTTP sync failed: {e}")
        logger.error("HTTP sync failed: %s", e)

    return result


def _pull_from_hub(
    local_db: Any,
    hub_url: str,
    node_id: str,
    batch_size: int,
) -> dict:
    """Pull changes from hub and apply to local DB."""
    local_version = get_sync_version(local_db)
    total_applied = 0
    tables_seen = set()
    since_log_id = 0

    with RetryClient(timeout=_HTTP_TIMEOUT) as client:
        while True:
            resp = client.post(
                f"{hub_url}/api/sync/pull",
                json={
                    "since_version": local_version if since_log_id == 0 else 0,
                    "since_log_id": since_log_id,
                    "node_id": node_id,
                    "limit": batch_size,
                },
            )
            if resp.status_code != 200:
                logger.error("Pull failed: %s %s", resp.status_code, resp.text[:200])
                break

            data = resp.json()
            changes = data.get("changes", [])
            has_more = data.get("has_more", False)
            since_log_id = data.get("next_log_id", 0)

            for change in changes:
                table = change.get("table", "")
                row_id = change.get("row_id", "")
                operation = change.get("operation", "")
                row_data = change.get("data")
                change_node = change.get("node_id", "")

                # Skip our own changes (echo suppression)
                if change_node == node_id:
                    continue

                if not table or not row_id:
                    continue

                tables_seen.add(table)
                try:
                    if operation == "delete":
                        pk_col = _get_pk_col(table)
                        local_db.execute(
                            f"DELETE FROM {table} WHERE {pk_col} = ?", (row_id,)
                        )
                    elif row_data:
                        _upsert_row(local_db, table, row_data)
                    total_applied += 1
                except Exception as e:
                    logger.warning("Pull apply error %s/%s: %s", table, row_id, e)

            _safe_commit(local_db)

            if not has_more or not changes:
                break

    # Update local sync version to hub's version
    if total_applied > 0:
        server_version = data.get("server_version", local_version)
        try:
            local_db.execute(
                "UPDATE _sync_state SET value = ?, updated_at = datetime('now') "
                "WHERE key = 'sync_version'",
                (str(server_version),),
            )
            _safe_commit(local_db)
        except Exception:
            pass

    return {"applied": total_applied, "tables": len(tables_seen)}


def _push_to_hub(
    local_db: Any,
    hub_url: str,
    node_id: str,
    batch_size: int,
) -> dict:
    """Push local changes to hub."""
    # Find local changes that haven't been synced
    # Look for rows in _sync_log where node_id matches us
    try:
        rows = local_db.execute(
            "SELECT table_name, row_id, operation, sync_version "
            "FROM _sync_log WHERE node_id = ? ORDER BY id DESC LIMIT ?",
            (node_id, batch_size),
        ).fetchall()
    except Exception:
        # No _sync_log or no local changes
        return {"applied": 0, "errors": []}

    if not rows:
        return {"applied": 0, "errors": []}

    changes = []
    for row in rows:
        table = row["table_name"] if isinstance(row, dict) else row[0]
        row_id = row["row_id"] if isinstance(row, dict) else row[1]
        operation = row["operation"] if isinstance(row, dict) else row[2]

        change = {
            "table": table,
            "row_id": row_id,
            "operation": operation,
            "node_id": node_id,
        }

        if operation != "delete":
            # Fetch current row data
            try:
                pk_col = _get_pk_col(table)
                data_rows = local_db.execute(
                    f"SELECT * FROM {table} WHERE {pk_col} = ?", (row_id,)
                ).fetchall()
                if data_rows:
                    row_dict = dict(data_rows[0]) if hasattr(data_rows[0], "keys") else {}
                    change["data"] = row_dict
            except Exception:
                continue

        changes.append(change)

    if not changes:
        return {"applied": 0, "errors": []}

    with RetryClient(timeout=_HTTP_TIMEOUT) as client:
        resp = client.post(
            f"{hub_url}/api/sync/push",
            json={
                "node_id": node_id,
                "changes": changes,
            },
        )
        if resp.status_code == 200:
            data = resp.json()
            return {
                "applied": data.get("applied", 0),
                "errors": data.get("errors", []),
            }
        else:
            return {"applied": 0, "errors": [f"Push failed: {resp.status_code}"]}


def _get_local_columns(db: Any, table: str) -> set[str]:
    """Get the set of column names for a local SQLite table."""
    try:
        # Use the db wrapper's execute which returns dicts/Rows consistently
        rows = db.execute(f"PRAGMA table_info({table})")
        if not rows:
            return set()
        # PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
        # Index 1 is always 'name' — works for tuples, lists, sqlite3.Row, and dicts
        result = set()
        for r in rows:
            if isinstance(r, dict):
                result.add(r["name"])
            else:
                result.add(r[1])
        return result
    except Exception:
        return set()


def _safe_commit(db: Any) -> None:
    """Commit if the database supports it."""
    if hasattr(db, 'commit'):
        db.commit()
    elif hasattr(db, '_conn'):
        db._conn.commit()


def _upsert_row(db: Any, table: str, data: dict) -> None:
    """Insert or update a row in the local database.

    Filters data to only include columns that exist in the local schema,
    handling schema differences between CRDB and SQLite gracefully.
    """
    local_cols = _get_local_columns(db, table)
    if not local_cols:
        return

    # Filter to columns that exist locally and aren't internal
    cols = [k for k in data.keys() if not k.startswith("_") and k in local_cols]
    if not cols:
        return

    placeholders = ", ".join("?" for _ in cols)
    col_names = ", ".join(cols)
    conflict_updates = ", ".join(f"{c} = excluded.{c}" for c in cols if c != "id")

    vals = []
    for c in cols:
        v = data[c]
        if isinstance(v, (list, dict)):
            import json
            v = json.dumps(v)
        vals.append(v)

    if conflict_updates:
        sql = (
            f"INSERT INTO {table} ({col_names}) VALUES ({placeholders}) "
            f"ON CONFLICT(id) DO UPDATE SET {conflict_updates}"
        )
    else:
        sql = f"INSERT OR IGNORE INTO {table} ({col_names}) VALUES ({placeholders})"

    db.execute(sql, tuple(vals))
