from __future__ import annotations

import json
from datetime import UTC, datetime
from uuid import uuid4

from app.core.database import session_scope
from app.modules.sync import repository as repo
from app.modules.sync.models import SyncConflict, SyncOutbox
from app.modules.sync.schemas import (
    AckChangesRequest,
    AckChangesResponse,
    ChangeEventIn,
    ChangeEventOut,
    PullChangesResponse,
    PushChangesResponse,
)
from app.modules.zootech.catalog_apply import apply_catalog_change


class SyncEngine:
    ORCHESTRATOR_SITE_ID = "orchestrator"

    def __init__(self, enterprise_id: str, site_id: str) -> None:
        self.enterprise_id = enterprise_id
        self.site_id = site_id

    def process_push(self, events: list[ChangeEventIn], origin: str) -> PushChangesResponse:
        applied: list[str] = []
        held: list[str] = []
        conflict_ids: list[str] = []

        for event in events:
            if repo.event_exists(event.event_id):
                applied.append(event.event_id)
                continue
            if repo.applied_event_exists(self.site_id, event.event_id):
                applied.append(event.event_id)
                continue

            seq = event.seq or repo.next_seq(self.enterprise_id)
            payload_json = json.dumps(event.payload, ensure_ascii=False)
            repo.append_event_log(
                enterprise_id=self.enterprise_id,
                event_id=event.event_id,
                origin=origin,
                origin_site_id=event.origin_site_id or self.site_id,
                seq=seq,
                domain=event.domain,
                table_name=event.table,
                record_id=event.record_id,
                action=event.action,
                version=event.version,
                content_hash=event.content_hash,
                payload_json=payload_json,
            )

            outcome = self._apply_or_hold(event, origin)
            if outcome == "applied":
                repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
                applied.append(event.event_id)
            elif outcome == "held":
                held.append(event.event_id)
            elif isinstance(outcome, str) and outcome.startswith("conflict:"):
                conflict_ids.append(outcome.split(":", 1)[1])

        return PushChangesResponse(
            applied_event_ids=applied,
            held_event_ids=held,
            conflicts=conflict_ids,
        )

    def _apply_or_hold(self, event: ChangeEventIn, origin: str) -> str:
        state = repo.get_record_state(self.enterprise_id, event.table, event.record_id)
        if state and state.agreed_hash and state.agreed_hash != event.content_hash:
            agreed_version = int(state.agreed_version or 0)
            if event.version <= agreed_version:
                pending = self._find_pending_conflict(event.table, event.record_id)
                if pending:
                    self._append_held_event(pending.id, event.event_id)
                    return "held"
                conflict_id = self._create_conflict(event, origin, state)
                self._hold_outbox_for_record(event.table, event.record_id)
                return f"conflict:{conflict_id}"

        if event.domain in ("report", "reports"):
            from app.modules.zootech.report_apply import apply_report_change

            apply_report_change(
                self.enterprise_id,
                event.table,
                event.record_id,
                event.action,
                event.payload,
                event.version,
                event.content_hash,
                farm_hub_id=event.origin_site_id if origin == "hub" else None,
            )
        else:
            apply_catalog_change(
                self.enterprise_id,
                event.table,
                event.record_id,
                event.action,
                event.payload,
                event.version,
                event.content_hash,
            )
        repo.upsert_record_state(
            self.enterprise_id,
            event.table,
            event.record_id,
            event.version,
            event.content_hash,
            event.event_id,
        )
        if origin == "orchestrator" or self.site_id == self.ORCHESTRATOR_SITE_ID:
            self._enqueue_fanout(event)
        return "applied"

    def _append_held_event(self, conflict_id: str, event_id: str) -> None:
        with session_scope() as db:
            row = db.get(SyncConflict, conflict_id)
            if not row:
                return
            held = json.loads(row.held_event_ids_json or "[]")
            if event_id not in held:
                held.append(event_id)
                row.held_event_ids_json = json.dumps(held, ensure_ascii=False)

    def _hold_outbox_for_record(self, table_name: str, record_id: str) -> None:
        with session_scope() as db:
            from sqlalchemy import select

            rows = list(
                db.scalars(
                    select(SyncOutbox).where(
                        SyncOutbox.enterprise_id == self.enterprise_id,
                        SyncOutbox.table_name == table_name,
                        SyncOutbox.record_id == record_id,
                        SyncOutbox.status.in_(("pending", "sent")),
                    )
                )
            )
            for row in rows:
                row.status = "held"

    def _has_pending_orchestrator_change(self, table_name: str, record_id: str, origin: str) -> bool:
        if origin == "hub":
            with session_scope() as db:
                from sqlalchemy import select

                row = db.scalar(
                    select(SyncOutbox).where(
                        SyncOutbox.enterprise_id == self.enterprise_id,
                        SyncOutbox.table_name == table_name,
                        SyncOutbox.record_id == record_id,
                        SyncOutbox.status.in_(("pending", "sent", "held")),
                        SyncOutbox.origin == "orchestrator",
                    )
                )
                return row is not None
        return origin == "orchestrator"

    def _find_pending_conflict(self, table_name: str, record_id: str) -> SyncConflict | None:
        with session_scope() as db:
            from sqlalchemy import select

            row = db.scalar(
                select(SyncConflict).where(
                    SyncConflict.enterprise_id == self.enterprise_id,
                    SyncConflict.table_name == table_name,
                    SyncConflict.record_id == record_id,
                    SyncConflict.status == "pending",
                )
            )
            if row:
                db.refresh(row)
                db.expunge(row)
                return row
        return None

    def _create_conflict(self, event: ChangeEventIn, origin: str, state) -> str:
        from app.modules.zootech.catalog_apply import load_catalog_row

        orchestrator_row = load_catalog_row(self.enterprise_id, event.table, event.record_id) or {}
        if origin == "hub":
            hub_snapshot = event.payload
            orch_snapshot = orchestrator_row
        else:
            hub_snapshot = {}
            orch_snapshot = event.payload or orchestrator_row

        conflict_id = str(uuid4())
        with session_scope() as db:
            db.add(
                SyncConflict(
                    id=conflict_id,
                    enterprise_id=self.enterprise_id,
                    table_name=event.table,
                    record_id=event.record_id,
                    farm_hub_id=event.origin_site_id if origin == "hub" else None,
                    orchestrator_snapshot_json=json.dumps(orch_snapshot, ensure_ascii=False),
                    hub_snapshot_json=json.dumps(hub_snapshot, ensure_ascii=False),
                    held_event_ids_json=json.dumps([event.event_id]),
                    status="pending",
                )
            )
        return conflict_id

    def _enqueue_fanout(self, event: ChangeEventIn) -> None:
        seq = repo.next_seq(self.enterprise_id)
        with session_scope() as db:
            existing = db.scalar(
                __import__("sqlalchemy").select(SyncOutbox).where(
                    SyncOutbox.enterprise_id == self.enterprise_id,
                    SyncOutbox.table_name == event.table,
                    SyncOutbox.record_id == event.record_id,
                    SyncOutbox.status.in_(("pending", "sent")),
                )
            )
            payload_json = json.dumps(event.payload, ensure_ascii=False)
            if existing:
                existing.event_id = event.event_id
                existing.action = event.action
                existing.version = event.version
                existing.content_hash = event.content_hash
                existing.payload_json = payload_json
                existing.seq = seq
                existing.status = "pending"
                existing.emitted_at = event.emitted_at
            else:
                db.add(
                    SyncOutbox(
                        enterprise_id=self.enterprise_id,
                        event_id=event.event_id,
                        origin="orchestrator",
                        origin_site_id=self.ORCHESTRATOR_SITE_ID,
                        seq=seq,
                        domain=event.domain,
                        table_name=event.table,
                        record_id=event.record_id,
                        action=event.action,
                        version=event.version,
                        content_hash=event.content_hash,
                        payload_json=payload_json,
                        status="pending",
                        emitted_at=event.emitted_at,
                    )
                )

    def process_pull(self, farm_hub_id: str, cursor: int, limit: int) -> PullChangesResponse:
        rows = repo.pull_events_since(
            self.enterprise_id,
            cursor,
            limit,
            exclude_origin_site_id=self.site_id,
        )
        events = [
            ChangeEventOut(
                event_id=row.event_id,
                seq=row.seq,
                domain=row.domain,  # type: ignore[arg-type]
                table=row.table_name,
                record_id=row.record_id,
                action=row.action,  # type: ignore[arg-type]
                version=row.version,
                content_hash=row.content_hash,
                payload=json.loads(row.payload_json or "{}"),
                emitted_at=row.received_at,
                origin_site_id=row.origin_site_id,
            )
            for row in rows
        ]
        next_cursor = events[-1].seq if events else cursor
        repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, "outbound")
        return PullChangesResponse(events=events, next_cursor=next_cursor)

    def process_ack(self, farm_hub_id: str, body: AckChangesRequest) -> AckChangesResponse:
        cursor = repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, body.direction)
        max_seq = cursor.last_acked_seq
        for event_id in body.event_ids:
            row = self._event_log_by_id(event_id)
            if row:
                max_seq = max(max_seq, row.seq)
                repo.mark_applied(self.enterprise_id, farm_hub_id, event_id)
        repo.update_cursor_ack(farm_hub_id, body.direction, max_seq)
        return AckChangesResponse(last_acked_seq=max_seq)

    def _event_log_by_id(self, event_id: str):
        with session_scope() as db:
            from sqlalchemy import select

            from app.modules.sync.models import SyncEventLog

            row = db.scalar(select(SyncEventLog).where(SyncEventLog.event_id == event_id))
            if row:
                db.refresh(row)
                db.expunge(row)
            return row

    def resolve_conflict(self, conflict_id: str, user_id: str, resolution: str) -> None:
        conflict = repo.get_conflict(conflict_id)
        if not conflict or conflict.enterprise_id != self.enterprise_id:
            raise ValueError("CONFLICT_NOT_FOUND")
        snapshot = (
            json.loads(conflict.orchestrator_snapshot_json)
            if resolution == "keep_orchestrator"
            else json.loads(conflict.hub_snapshot_json)
        )
        event = ChangeEventIn(
            event_id=str(uuid4()),
            seq=repo.next_seq(self.enterprise_id),
            domain="global",
            table=conflict.table_name,
            record_id=conflict.record_id,
            action="upsert",
            version=int(snapshot.get("version", 1)),
            content_hash=str(snapshot.get("content_hash", "")),
            payload=snapshot,
            emitted_at=datetime.now(UTC),
            origin_site_id=self.ORCHESTRATOR_SITE_ID,
        )
        apply_catalog_change(
            self.enterprise_id,
            event.table,
            event.record_id,
            event.action,
            event.payload,
            event.version,
            event.content_hash,
        )
        repo.upsert_record_state(
            self.enterprise_id,
            event.table,
            event.record_id,
            event.version,
            event.content_hash,
            event.event_id,
        )
        repo.append_event_log(
            enterprise_id=self.enterprise_id,
            event_id=event.event_id,
            origin="orchestrator",
            origin_site_id=self.ORCHESTRATOR_SITE_ID,
            seq=event.seq or repo.next_seq(self.enterprise_id),
            domain=event.domain,
            table_name=event.table,
            record_id=event.record_id,
            action=event.action,
            version=event.version,
            content_hash=event.content_hash,
            payload_json=json.dumps(event.payload, ensure_ascii=False),
        )
        repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id)
        self._enqueue_fanout(event)
        self._release_held_outbox(conflict.table_name, conflict.record_id)
        with session_scope() as db:
            row = db.get(SyncConflict, conflict_id)
            if row:
                row.status = "resolved"
                row.resolution = resolution
                row.resolved_by = user_id
                row.resolved_at = datetime.now(UTC)

    def _release_held_outbox(self, table_name: str, record_id: str) -> None:
        with session_scope() as db:
            from sqlalchemy import select

            rows = list(
                db.scalars(
                    select(SyncOutbox).where(
                        SyncOutbox.enterprise_id == self.enterprise_id,
                        SyncOutbox.table_name == table_name,
                        SyncOutbox.record_id == record_id,
                        SyncOutbox.status == "held",
                    )
                )
            )
            for row in rows:
                row.status = "pending"
