/**
 * Motor de sincronización offline-first.
 *
 * Orden de un ciclo completo (ver brief §8):
 *   1. PUSH   — vacía el outbox de operaciones contra POST /sync.
 *   2. MEDIA  — sube los ficheros pendientes a POST /media.
 *   3. PULL   — baja el delta del proyecto (bundle?since=cursor) y lo aplica.
 *
 * Idempotente: cada operación lleva su uuid, así reenviar la cola es seguro.
 * Conflictos: last-write-wins en servidor; si responde `conflict`, aplicamos
 * el valor del servidor a la BD local y dejamos la op marcada para revisión.
 *
 * Creaciones offline: las filas nacen con un id temporal negativo. Al recibir
 * `applied` con `server_id` reconciliamos el id real y remapeamos las FKs de
 * las operaciones hijas que aún referencian el id temporal (p. ej. una tarea
 * creada sobre una incidencia que aún no existía en el servidor).
 */
import { getBundle, sync as syncApi, uploadMedia } from '../api/endpoints';
import { Operation, OperationResult, SyncEntity } from '../api/types';
import {
  applyBundle,
  applyServerValue,
  getCursor,
  reconcileCreate,
} from '../db/repositories';
import {
  applyOperationResult,
  getPendingMedia,
  getPendingOutbox,
  markMediaError,
  markMediaSent,
  PendingOp,
  updateOutboxData,
} from '../db/outbox';

const SYNC_BATCH = 100;

/** Entidades editables → tabla local (para volcar el valor del servidor en conflicto). */
const ENTITY_TABLE: Partial<Record<SyncEntity, string>> = {
  feature: 'features',
  issue: 'issues',
  issue_task: 'issue_tasks',
  inspection: 'inspections',
  issue_comment: 'issue_comments',
};

/** Campos que pueden contener un id temporal (negativo) a remapear. */
const FK_FIELDS = ['id', 'issue_id', 'feature_id', 'parent_id', 'layer_id', 'phase_id'] as const;

export interface SyncReport {
  pushed: number;
  applied: number;
  conflicts: number;
  errors: number;
  mediaUploaded: number;
  mediaErrors: number;
  pulled: boolean;
}

function negativeRefs(data: Record<string, unknown>): number[] {
  const out: number[] = [];
  for (const f of FK_FIELDS) {
    const v = data[f];
    if (typeof v === 'number' && v < 0) out.push(v);
  }
  return out;
}

/** Reescribe en `data` los ids temporales ya resueltos. Devuelve si cambió algo. */
function rewriteRefs(data: Record<string, unknown>, idMap: Map<number, number>): boolean {
  let changed = false;
  for (const f of FK_FIELDS) {
    const v = data[f];
    if (typeof v === 'number' && v < 0 && idMap.has(v)) {
      data[f] = idMap.get(v)!;
      changed = true;
    }
  }
  return changed;
}

function toOperation(op: PendingOp): Operation {
  return {
    entity: op.entity,
    op: op.op,
    uuid: op.uuid,
    client_updated_at: op.client_updated_at,
    data: op.data,
  };
}

/** Persiste el remapeo de un id temporal en las operaciones pendientes (durabilidad). */
async function remapPendingOps(tempId: number, serverId: number): Promise<void> {
  const pending = await getPendingOutbox(SYNC_BATCH * 4);
  const single = new Map([[tempId, serverId]]);
  for (const op of pending) {
    if (rewriteRefs(op.data, single)) {
      await updateOutboxData(op.uuid, op.data);
    }
  }
}

/** Vacía el outbox de operaciones contra /sync, respetando dependencias. */
async function pushOperations(report: SyncReport): Promise<void> {
  const idMap = new Map<number, number>(); // tempId -> serverId

  // eslint-disable-next-line no-constant-condition
  while (true) {
    const pending = await getPendingOutbox(SYNC_BATCH * 4);
    if (pending.length === 0) break;

    // Construir un lote: no incluir una op que dependa de un id temporal
    // producido por un create de este mismo lote (aún sin id de servidor).
    const producedInBatch = new Set<number>();
    const batch: PendingOp[] = [];
    for (const op of pending) {
      rewriteRefs(op.data, idMap);
      if (negativeRefs(op.data).some((r) => producedInBatch.has(r))) break;
      batch.push(op);
      if (op.op === 'create' && op.localId != null) producedInBatch.add(op.localId);
      if (batch.length >= SYNC_BATCH) break;
    }
    if (batch.length === 0) break;

    const { results } = await syncApi(batch.map(toOperation));
    report.pushed += batch.length;

    const byUuid = new Map(batch.map((o) => [o.uuid, o]));
    let progressed = false;
    for (const result of results) {
      progressed = (await processResult(result, byUuid.get(result.uuid), report, idMap)) || progressed;
    }

    // Si ningún ítem avanzó (todo conflicto/error), evitamos un bucle infinito.
    if (!progressed) break;
  }
}

/** Procesa un resultado; devuelve true si "avanzó" (applied/duplicate). */
async function processResult(
  result: OperationResult,
  op: PendingOp | undefined,
  report: SyncReport,
  idMap: Map<number, number>,
): Promise<boolean> {
  await applyOperationResult(result);

  if (result.status === 'applied' || result.status === 'duplicate') {
    report.applied += 1;
    if (op && op.op === 'create' && op.localId != null && result.server_id != null) {
      const tempId = await reconcileCreate(op.entity, op.uuid, result.server_id);
      if (tempId != null && tempId !== result.server_id) {
        idMap.set(tempId, result.server_id);
        await remapPendingOps(tempId, result.server_id);
      }
    }
    return true;
  }

  if (result.status === 'conflict') {
    report.conflicts += 1;
    if (op && result.server) {
      const table = ENTITY_TABLE[op.entity];
      const id = (op.data as { id?: number }).id;
      if (table && id != null && id > 0) {
        await applyServerValue(table, id, result.server);
      }
    }
    return false;
  }

  report.errors += 1;
  return false;
}

/** Sube los ficheros pendientes a /media (solo los de padres ya sincronizados). */
async function pushMedia(report: SyncReport): Promise<void> {
  const pending = await getPendingMedia();
  for (const m of pending) {
    try {
      const res = await uploadMedia({
        uuid: m.uuid,
        parentEntity: m.parent_entity as never,
        parentId: m.parent_id,
        fileUri: m.local_uri,
        fileName: m.file_name ?? undefined,
        mimeType: m.mime_type ?? undefined,
        category: m.category ?? undefined,
        description: m.description ?? undefined,
      });
      await markMediaSent(m.uuid, res.media?.id ?? null);
      report.mediaUploaded += 1;
    } catch (e) {
      await markMediaError(m.uuid, e instanceof Error ? e.message : String(e));
      report.mediaErrors += 1;
    }
  }
}

/** Baja el delta del proyecto y lo aplica. */
async function pull(projectId: number, report: SyncReport): Promise<void> {
  const since = await getCursor(projectId);
  const bundle = await getBundle(projectId, since ?? undefined);
  await applyBundle(bundle);
  report.pulled = true;
}

/** Ciclo completo de sincronización para un proyecto. */
export async function runSync(projectId: number): Promise<SyncReport> {
  const report: SyncReport = {
    pushed: 0,
    applied: 0,
    conflicts: 0,
    errors: 0,
    mediaUploaded: 0,
    mediaErrors: 0,
    pulled: false,
  };

  await pushOperations(report);
  await pushMedia(report);
  await pull(projectId, report);

  return report;
}

/** Primera sincronización: snapshot completo (sin cursor). */
export async function initialPull(projectId: number): Promise<void> {
  const bundle = await getBundle(projectId);
  await applyBundle(bundle);
}
