"""Tests for HTTP-based sync (http_sync module).

Tests cover:
- _get_local_columns: schema introspection for SQLite tables
- _safe_commit: commit handling for different db interfaces
- _upsert_row: insert-or-update with schema filtering and JSON serialization
- _pull_from_hub: pull changes from coordinator with echo suppression
- _push_to_hub: push local changes to coordinator
- http_delta_sync: top-level bidirectional sync
"""
import json
import sqlite3
from unittest.mock import MagicMock, patch

import pytest

from greymatter_core.sync.http_sync import (
    _get_local_columns,
    _safe_commit,
    _upsert_row,
    _pull_from_hub,
    _push_to_hub,
    http_delta_sync,
)


# ---------------------------------------------------------------------------
# Helper: in-memory SQLite database with a test table
# ---------------------------------------------------------------------------

@pytest.fixture
def test_db(tmp_path):
    """Create an in-memory SQLite database with a test table and sync state."""
    db_path = tmp_path / "test.db"
    conn = sqlite3.connect(str(db_path))
    conn.row_factory = sqlite3.Row
    conn.execute("""
        CREATE TABLE knowledge_entries (
            id TEXT PRIMARY KEY,
            title TEXT,
            content TEXT,
            category TEXT,
            updated_at TEXT
        )
    """)
    conn.execute("""
        CREATE TABLE _sync_state (
            key TEXT PRIMARY KEY,
            value TEXT,
            updated_at TEXT
        )
    """)
    conn.execute("""
        CREATE TABLE _sync_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            table_name TEXT,
            row_id TEXT,
            operation TEXT,
            sync_version INTEGER,
            node_id TEXT,
            created_at TEXT DEFAULT (datetime('now'))
        )
    """)
    conn.execute(
        "INSERT INTO _sync_state (key, value) VALUES ('sync_version', '0')"
    )
    conn.execute(
        "INSERT INTO _sync_state (key, value) VALUES ('node_id', 'solo')"
    )
    conn.commit()
    return conn


# ---------------------------------------------------------------------------
# _get_local_columns
# ---------------------------------------------------------------------------

class TestGetLocalColumns:
    def test_returns_column_names(self, test_db):
        cols = _get_local_columns(test_db, "knowledge_entries")
        assert "id" in cols
        assert "title" in cols
        assert "content" in cols
        assert "category" in cols
        assert "updated_at" in cols

    def test_returns_empty_for_missing_table(self, test_db):
        cols = _get_local_columns(test_db, "nonexistent_table")
        assert cols == set()

    def test_works_with_dict_rows(self):
        mock_db = MagicMock()
        mock_db.execute.return_value = [
            {"name": "id"},
            {"name": "title"},
        ]
        cols = _get_local_columns(mock_db, "test")
        assert cols == {"id", "title"}

    def test_works_with_tuple_rows(self):
        mock_db = MagicMock()
        mock_db.execute.return_value = [
            (0, "id", "TEXT", 0, None, 1),
            (1, "title", "TEXT", 0, None, 0),
        ]
        cols = _get_local_columns(mock_db, "test")
        assert cols == {"id", "title"}


# ---------------------------------------------------------------------------
# _safe_commit
# ---------------------------------------------------------------------------

class TestSafeCommit:
    def test_calls_commit_on_connection(self, test_db):
        _safe_commit(test_db)  # Should not raise

    def test_calls_commit_via_conn(self):
        mock_db = MagicMock(spec=[])
        mock_db._conn = MagicMock()
        _safe_commit(mock_db)
        mock_db._conn.commit.assert_called_once()

    def test_no_commit_method(self):
        mock_db = MagicMock(spec=[])
        _safe_commit(mock_db)  # Should not raise


# ---------------------------------------------------------------------------
# _upsert_row
# ---------------------------------------------------------------------------

class TestUpsertRow:
    def test_inserts_new_row(self, test_db):
        _upsert_row(test_db, "knowledge_entries", {
            "id": "ke-1",
            "title": "Test Entry",
            "content": "Some content",
        })
        test_db.commit()

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-1",)
        ).fetchone()
        assert row is not None
        assert row["title"] == "Test Entry"

    def test_updates_existing_row(self, test_db):
        test_db.execute(
            "INSERT INTO knowledge_entries (id, title) VALUES (?, ?)",
            ("ke-1", "Old Title"),
        )
        test_db.commit()

        _upsert_row(test_db, "knowledge_entries", {
            "id": "ke-1",
            "title": "New Title",
            "content": "Updated content",
        })
        test_db.commit()

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-1",)
        ).fetchone()
        assert row["title"] == "New Title"
        assert row["content"] == "Updated content"

    def test_filters_unknown_columns(self, test_db):
        _upsert_row(test_db, "knowledge_entries", {
            "id": "ke-2",
            "title": "Entry",
            "nonexistent_col": "should be ignored",
        })
        test_db.commit()

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-2",)
        ).fetchone()
        assert row is not None
        assert row["title"] == "Entry"

    def test_filters_internal_columns(self, test_db):
        _upsert_row(test_db, "knowledge_entries", {
            "id": "ke-3",
            "title": "Entry",
            "_sync_version": 5,
            "_node_id": "remote",
        })
        test_db.commit()

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-3",)
        ).fetchone()
        assert row is not None

    def test_serializes_dict_values(self, test_db):
        _upsert_row(test_db, "knowledge_entries", {
            "id": "ke-5",
            "title": "Meta Test",
            "content": {"key": "value"},
        })
        test_db.commit()

        row = test_db.execute(
            "SELECT content FROM knowledge_entries WHERE id = ?", ("ke-5",)
        ).fetchone()
        parsed = json.loads(row["content"])
        assert parsed == {"key": "value"}

    def test_skips_empty_table(self):
        mock_db = MagicMock()
        mock_db.execute.return_value = []
        _upsert_row(mock_db, "missing_table", {"id": "x", "title": "y"})
        assert mock_db.execute.call_count == 1

    def test_skips_when_only_internal_cols(self, test_db):
        _upsert_row(test_db, "knowledge_entries", {
            "_sync_version": 5,
            "_node_id": "remote",
        })
        count = test_db.execute("SELECT COUNT(*) FROM knowledge_entries").fetchone()[0]
        assert count == 0


# ---------------------------------------------------------------------------
# _pull_from_hub (mocked HTTP)
# ---------------------------------------------------------------------------

@patch("greymatter_core.sync.http_sync.get_sync_version", return_value=0)
class TestPullFromHub:
    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_pull_applies_changes(self, MockClient, mock_ver, test_db):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "changes": [
                {
                    "table": "knowledge_entries",
                    "row_id": "ke-remote-1",
                    "operation": "upsert",
                    "data": {"id": "ke-remote-1", "title": "Remote Entry"},
                    "node_id": "hub",
                },
            ],
            "has_more": False,
            "server_version": 5,
        }

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _pull_from_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 1
        assert result["tables"] == 1

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-remote-1",)
        ).fetchone()
        assert row is not None
        assert row["title"] == "Remote Entry"

    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_pull_echo_suppression(self, MockClient, mock_ver, test_db):
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "changes": [
                {
                    "table": "knowledge_entries",
                    "row_id": "ke-own-1",
                    "operation": "upsert",
                    "data": {"id": "ke-own-1", "title": "Own Entry"},
                    "node_id": "solo",
                },
            ],
            "has_more": False,
        }

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _pull_from_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 0

    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_pull_delete_operation(self, MockClient, mock_ver, test_db):
        test_db.execute(
            "INSERT INTO knowledge_entries (id, title) VALUES (?, ?)",
            ("ke-del-1", "To Delete"),
        )
        test_db.commit()

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "changes": [
                {
                    "table": "knowledge_entries",
                    "row_id": "ke-del-1",
                    "operation": "delete",
                    "node_id": "hub",
                },
            ],
            "has_more": False,
        }

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _pull_from_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 1

        row = test_db.execute(
            "SELECT * FROM knowledge_entries WHERE id = ?", ("ke-del-1",)
        ).fetchone()
        assert row is None

    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_pull_handles_http_error(self, MockClient, mock_ver, test_db):
        mock_resp = MagicMock()
        mock_resp.status_code = 500
        mock_resp.text = "Internal Server Error"

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _pull_from_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 0

    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_pull_pagination(self, MockClient, mock_ver, test_db):
        resp1 = MagicMock()
        resp1.status_code = 200
        resp1.json.return_value = {
            "changes": [
                {
                    "table": "knowledge_entries",
                    "row_id": "ke-p1",
                    "operation": "upsert",
                    "data": {"id": "ke-p1", "title": "Page 1"},
                    "node_id": "hub",
                },
            ],
            "has_more": True,
            "next_log_id": 100,
        }

        resp2 = MagicMock()
        resp2.status_code = 200
        resp2.json.return_value = {
            "changes": [
                {
                    "table": "knowledge_entries",
                    "row_id": "ke-p2",
                    "operation": "upsert",
                    "data": {"id": "ke-p2", "title": "Page 2"},
                    "node_id": "hub",
                },
            ],
            "has_more": False,
            "server_version": 10,
        }

        mock_client = MagicMock()
        mock_client.post.side_effect = [resp1, resp2]
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _pull_from_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 2
        assert mock_client.post.call_count == 2


# ---------------------------------------------------------------------------
# _push_to_hub (mocked HTTP)
# ---------------------------------------------------------------------------

class TestPushToHub:
    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_push_sends_local_changes(self, MockClient, test_db):
        test_db.execute(
            "INSERT INTO knowledge_entries (id, title) VALUES (?, ?)",
            ("ke-local-1", "Local Entry"),
        )
        test_db.execute(
            "INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id) "
            "VALUES (?, ?, ?, ?, ?)",
            ("knowledge_entries", "ke-local-1", "upsert", 1, "solo"),
        )
        test_db.commit()

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"applied": 1, "errors": []}

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _push_to_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 1
        assert result["errors"] == []

    @patch("greymatter_core.sync.http_sync.RetryClient")
    def test_push_handles_http_failure(self, MockClient, test_db):
        test_db.execute(
            "INSERT INTO _sync_log (table_name, row_id, operation, sync_version, node_id) "
            "VALUES (?, ?, ?, ?, ?)",
            ("knowledge_entries", "ke-1", "upsert", 1, "solo"),
        )
        test_db.execute(
            "INSERT INTO knowledge_entries (id, title) VALUES (?, ?)",
            ("ke-1", "Entry"),
        )
        test_db.commit()

        mock_resp = MagicMock()
        mock_resp.status_code = 503

        mock_client = MagicMock()
        mock_client.post.return_value = mock_resp
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        MockClient.return_value = mock_client

        result = _push_to_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["errors"]
        assert "503" in result["errors"][0]

    def test_push_no_sync_log_table(self):
        conn = sqlite3.connect(":memory:")
        conn.row_factory = sqlite3.Row
        result = _push_to_hub(conn, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 0
        conn.close()

    def test_push_no_local_changes(self, test_db):
        result = _push_to_hub(test_db, "http://hub:8000", "solo", 1000)
        assert result["applied"] == 0


# ---------------------------------------------------------------------------
# http_delta_sync (top-level)
# ---------------------------------------------------------------------------

class TestHttpDeltaSync:
    @patch("greymatter_core.sync.http_sync._push_to_hub")
    @patch("greymatter_core.sync.http_sync._pull_from_hub")
    def test_success(self, mock_pull, mock_push, test_db):
        mock_pull.return_value = {"applied": 3, "tables": 2}
        mock_push.return_value = {"applied": 1, "errors": []}

        result = http_delta_sync(test_db, "http://hub:8000", "solo")
        assert result.success
        assert result.rows_pushed == 4
        assert result.tables_synced == 2

    @patch("greymatter_core.sync.http_sync._push_to_hub")
    @patch("greymatter_core.sync.http_sync._pull_from_hub")
    def test_push_errors_propagated(self, mock_pull, mock_push, test_db):
        mock_pull.return_value = {"applied": 0, "tables": 0}
        mock_push.return_value = {"applied": 0, "errors": ["Push failed: 503"]}

        result = http_delta_sync(test_db, "http://hub:8000", "solo")
        assert not result.success
        assert "Push failed: 503" in result.errors

    @patch("greymatter_core.sync.http_sync._push_to_hub")
    @patch("greymatter_core.sync.http_sync._pull_from_hub")
    def test_exception_caught(self, mock_pull, mock_push, test_db):
        mock_pull.side_effect = Exception("connection refused")

        result = http_delta_sync(test_db, "http://hub:8000", "solo")
        assert not result.success
        assert "connection refused" in result.errors[0]
