"""Tests for sync conflict detection (sync_engine.py).

Scenario: client modifies a party offline; meanwhile the server's version of
that party is also updated. When the client pushes its change, sync_engine
should detect the version mismatch, log a conflict_log row, still apply the
client's change (LWW default), and let the admin resolve via
/api/sync/conflicts/{id}/resolve.
"""
import os
import uuid
import pytest

BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://offline-billing-pro-2.preview.emergentagent.com").rstrip("/")


def _fetch_party(session, company_id, pid):
    """No GET /api/parties/{id} exists — list and filter."""
    r = session.get(f"{BASE_URL}/api/parties?company_id={company_id}", timeout=15)
    assert r.status_code == 200, r.text
    for p in r.json():
        if p.get("id") == pid:
            return p
    return None


class TestSyncConflicts:
    @pytest.fixture(scope="class")
    def party_id(self, admin_session, default_company_id):
        """Create a throwaway party and yield its id."""
        body = {
            "name": f"Conflict Test {uuid.uuid4().hex[:8]}",
            "phone": "9999900000",
            "type": "customer",
        }
        r = admin_session.post(
            f"{BASE_URL}/api/parties?company_id={default_company_id}",
            json=body, timeout=30,
        )
        assert r.status_code in (200, 201), r.text
        pid = r.json().get("id") or r.json().get("_id")
        assert pid, f"No id in response: {r.text}"
        yield pid
        # Cleanup
        admin_session.delete(f"{BASE_URL}/api/parties/{pid}", timeout=30)

    def test_status_includes_pending_conflicts(self, admin_session):
        r = admin_session.get(f"{BASE_URL}/api/sync/status", timeout=15)
        assert r.status_code == 200, r.text
        data = r.json()
        assert "pending_conflicts" in data
        assert isinstance(data["pending_conflicts"], int)

    def test_push_without_version_hint_no_conflict(self, admin_session, party_id, default_company_id):
        """Old clients that don't send client_base_version are exempt → no conflict
        logged (back-compat). The op is still applied."""
        op = {
            "op_id": f"test-noversion-{uuid.uuid4().hex}",
            "kind": "party",
            "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {

                "name": "Conflict Test — no version hint",
                "phone": "9999900001",
                "type": "customer",
            },
        }
        r = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45)
        assert r.status_code == 200, r.text
        data = r.json()
        assert data["ok"] == 1, data
        assert data["conflicts"] == 0, "No conflict should be logged when client_base_version is omitted"
        assert data["results"][0]["status"] == "ok"
        assert "conflict_id" not in data["results"][0]

    def test_push_with_stale_version_creates_conflict(self, admin_session, party_id, default_company_id):
        """Client claims version=1, but server is now at version>=2 (after the
        previous PUT). Sync should record a conflict but still apply the write."""
        op = {
            "op_id": f"test-stale-{uuid.uuid4().hex}",
            "kind": "party",
            "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {

                "name": "Conflict Test — stale write",
                "phone": "9999900002",
                "type": "customer",
            },
            "client_base_version": 0,    # stale — server is ahead
        }
        r = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45)
        assert r.status_code == 200, r.text
        data = r.json()
        assert data["ok"] == 1, data
        assert data["conflicts"] == 1, f"Expected 1 conflict, got {data}"
        cid = data["results"][0].get("conflict_id")
        assert cid, "conflict_id should be returned in the result"

        # Verify it shows up in the listing
        r = admin_session.get(f"{BASE_URL}/api/sync/conflicts?resolved=false", timeout=15)
        assert r.status_code == 200, r.text
        conflicts = r.json()
        ids = [c["id"] for c in conflicts]
        assert cid in ids, f"Newly-logged conflict {cid} not in listing: {ids[:5]}"

        # Summary endpoint
        r = admin_session.get(f"{BASE_URL}/api/sync/conflicts/summary", timeout=15)
        assert r.status_code == 200, r.text
        s = r.json()
        assert s["pending"] >= 1
        assert "party" in s["by_kind"]

        # Verify the LWW behavior — server now has the client's new name
        body = _fetch_party(admin_session, default_company_id, party_id)
        name = (body or {}).get("name")
        assert name == "Conflict Test — stale write"

    def test_resolve_keep_local_is_noop(self, admin_session, party_id, default_company_id):
        # Trigger a fresh conflict
        op = {
            "op_id": f"test-keeplocal-{uuid.uuid4().hex}",
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {

                "name": "After keep_local",
                "phone": "9999900003", "type": "customer",
            },
            "client_base_version": 0,
        }
        push = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        cid = push["results"][0]["conflict_id"]

        r = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "keep_local"},
            timeout=15,
        )
        assert r.status_code == 200, r.text
        assert r.json()["action"] == "keep_local"

        # Name should still be the client's value (no rollback)
        body = _fetch_party(admin_session, default_company_id, party_id)
        name = body.get("name")
        assert name == "After keep_local", f"keep_local must NOT rollback — got {name}"

        # And we can't resolve twice
        r2 = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "keep_local"}, timeout=15,
        )
        assert r2.status_code == 409, r2.text

    def test_resolve_keep_server_restores_snapshot(self, admin_session, party_id, default_company_id):
        # Set a known good server state
        admin_session.put(
            f"{BASE_URL}/api/parties/{party_id}",
            json={

                "name": "Server Wins Name",
                "phone": "9999900004", "type": "customer",
            }, timeout=15,
        )
        # Client pushes a stale write
        op = {
            "op_id": f"test-keepserver-{uuid.uuid4().hex}",
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {

                "name": "Client Overwrite",
                "phone": "9999900005", "type": "customer",
            },
            "client_base_version": 0,
        }
        push = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        cid = push["results"][0]["conflict_id"]

        # After push, name = "Client Overwrite" (LWW applied)
        before_resolve = _fetch_party(admin_session, default_company_id, party_id)
        assert before_resolve["name"] == "Client Overwrite"

        # Resolve with keep_server → should restore to "Server Wins Name"
        r = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "keep_server", "notes": "rollback test"},
            timeout=15,
        )
        assert r.status_code == 200, r.text
        after = _fetch_party(admin_session, default_company_id, party_id)
        assert after["name"] == "Server Wins Name", f"keep_server should rollback — got {after['name']}"

    def test_resolve_manual_merge(self, admin_session, party_id, default_company_id):
        # Trigger a conflict
        op = {
            "op_id": f"test-merge-{uuid.uuid4().hex}",
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {

                "name": "Either Side",
                "phone": "9999900006", "type": "customer",
            },
            "client_base_version": 0,
        }
        push = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        cid = push["results"][0]["conflict_id"]

        merged = {

            "name": "Manually Merged Name",
            "phone": "9999900099",
            "type": "customer",
            "address": "merged-address",
        }
        r = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "manual_merge", "merged_data": merged, "notes": "merge happened"},
            timeout=15,
        )
        assert r.status_code == 200, r.text
        after = _fetch_party(admin_session, default_company_id, party_id)
        assert after["name"] == "Manually Merged Name"
        assert after["phone"] == "9999900099"

    def test_manual_merge_requires_data(self, admin_session, party_id, default_company_id):
        op = {
            "op_id": f"test-no-merge-data-{uuid.uuid4().hex}",
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {
                 "name": "X",
                "phone": "9999900100", "type": "customer",
            },
            "client_base_version": 0,
        }
        push = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        cid = push["results"][0]["conflict_id"]
        r = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "manual_merge"},
            timeout=15,
        )
        assert r.status_code == 422, r.text

    def test_resolve_invalid_action(self, admin_session, party_id, default_company_id):
        op = {
            "op_id": f"test-bad-action-{uuid.uuid4().hex}",
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {
                 "name": "Z",
                "phone": "9999900101", "type": "customer",
            },
            "client_base_version": 0,
        }
        push = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        cid = push["results"][0]["conflict_id"]
        r = admin_session.post(
            f"{BASE_URL}/api/sync/conflicts/{cid}/resolve",
            json={"action": "nuke_everything"},
            timeout=15,
        )
        assert r.status_code == 422, r.text

    def test_idempotent_op_does_not_log_duplicate_conflict(self, admin_session, party_id, default_company_id):
        """Replaying the same op_id should be skipped — no extra conflict row."""
        op_id = f"test-idem-{uuid.uuid4().hex}"
        op = {
            "op_id": op_id,
            "kind": "party", "method": "PUT",
            "url": f"/api/parties/{party_id}",
            "body": {
                 "name": "Idem 1",
                "phone": "9999900102", "type": "customer",
            },
            "client_base_version": 0,
        }
        first = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        assert first["conflicts"] == 1
        # Replay the same op_id
        second = admin_session.post(f"{BASE_URL}/api/sync/push", json={"ops": [op]}, timeout=45).json()
        assert second["skipped"] == 1
        assert second["conflicts"] == 0    # replay must not log a 2nd conflict
