/**
 * Sync Registry
 *
 * Manages synchronized state stores with JSON patch-based updates.
 * Ported from backend.old/src/sync/registry.py.
 *
 * Key features:
 * - Sequence-numbered patches for reliable sync
 * - History buffer for catchup patches
 * - Conflict resolution (frontend wins)
 * - Optimistic updates with rollback on conflict
 */

import type { Operation as JsonPatchOp } from 'fast-json-patch';
import fastJsonPatch from 'fast-json-patch';
const { applyPatch, compare: computePatch, deepClone } = fastJsonPatch;
import type { FastifyBaseLogger } from 'fastify';
import type { SnapshotMessage, PatchMessage, StoreConfig } from './types.js';

/**
 * History entry: sequence number and the patch that produced it.
 */
interface HistoryEntry {
  seq: number;
  patch: JsonPatchOp[];
}

/**
 * Entry for a single synchronized store.
 */
class SyncEntry {
  readonly storeName: string;
  private state: unknown;
  private seq: number = 0;
  private lastSnapshot: unknown;
  private history: HistoryEntry[] = [];
  private readonly historySize: number;

  constructor(storeName: string, initialState: unknown, historySize: number = 50) {
    this.storeName = storeName;
    this.state = deepClone(initialState);
    this.lastSnapshot = deepClone(initialState);
    this.historySize = historySize;
  }

  /**
   * Get current state (deep clone to prevent mutation).
   */
  getState(): unknown {
    return deepClone(this.state);
  }

  /**
   * Get current sequence number.
   */
  getSeq(): number {
    return this.seq;
  }

  /**
   * Set state directly (used for loading from container).
   * Sets sequence to 1 so clients at seq 0 (empty state) receive a full snapshot.
   */
  setState(newState: unknown): void {
    this.state = deepClone(newState);
    this.lastSnapshot = deepClone(newState);
    this.seq = 1;
    this.history = [];
  }

  /**
   * Compute patch from last snapshot to current state.
   * Returns null if no changes.
   */
  computePatch(): JsonPatchOp[] | null {
    const currentState = deepClone(this.state);
    const patch = computePatch(this.lastSnapshot as any, currentState as any);
    return patch.length > 0 ? patch : null;
  }

  /**
   * Commit a patch to history and update snapshot.
   */
  commitPatch(patch: JsonPatchOp[]): void {
    this.seq += 1;
    this.history.push({ seq: this.seq, patch });

    // Trim history if needed
    while (this.history.length > this.historySize) {
      this.history.shift();
    }

    this.lastSnapshot = deepClone(this.state);
  }

  /**
   * Get catchup patches since a given sequence.
   * Returns null if catchup not possible (need full snapshot).
   */
  getCatchupPatches(sinceSeq: number): HistoryEntry[] | null {
    if (sinceSeq === this.seq) {
      return [];
    }

    // Check if we have all patches needed
    if (this.history.length === 0 || this.history[0].seq > sinceSeq + 1) {
      return null; // Need full snapshot
    }

    return this.history.filter((entry) => entry.seq > sinceSeq);
  }

  /**
   * Ensure intermediate objects exist for all patch add/replace operations.
   * Called before applying patches to gracefully handle paths into previously-absent sub-objects.
   */
  private ensureIntermediatePaths(doc: any, patch: JsonPatchOp[]): void {
    for (const op of patch) {
      if (op.op !== 'add' && op.op !== 'replace') continue;
      const parts = op.path.split('/').filter(Boolean);
      if (parts.length <= 1) continue;
      let current = doc;
      for (let i = 0; i < parts.length - 1; i++) {
        const part = parts[i];
        if (current[part] === undefined || current[part] === null) {
          current[part] = {};
        }
        current = current[part];
      }
    }
  }

  /**
   * Apply a patch to state (used when applying local changes).
   */
  applyPatch(patch: JsonPatchOp[]): void {
    const doc = deepClone(this.state) as any;
    this.ensureIntermediatePaths(doc, patch);
    const result = applyPatch(doc, patch, false, false);
    this.state = result.newDocument;
  }

  /**
   * Apply client patch with conflict resolution.
   * Returns the resolved state and any patches to send back.
   */
  applyClientPatch(
    clientBaseSeq: number,
    patch: JsonPatchOp[],
    logger?: FastifyBaseLogger
  ): { needsSnapshot: boolean; resolvedState?: unknown } {
    try {
      if (clientBaseSeq === this.seq) {
        // No conflict - apply directly
        const currentState = deepClone(this.state) as any;
        this.ensureIntermediatePaths(currentState, patch);
        const result = applyPatch(currentState, patch, false, false);
        this.state = result.newDocument;
        this.commitPatch(patch);
        logger?.debug(
          { store: this.storeName, seq: this.seq },
          'Applied client patch without conflict'
        );
        return { needsSnapshot: false };
      }

      if (clientBaseSeq < this.seq) {
        // Conflict! Frontend wins.
        logger?.debug(
          { store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
          'Conflict detected, frontend wins'
        );

        // Get backend patches since client's base
        const backendPatches: JsonPatchOp[][] = [];
        for (const entry of this.history) {
          if (entry.seq > clientBaseSeq) {
            backendPatches.push(entry.patch);
          }
        }

        // Get paths modified by frontend
        const frontendPaths = new Set(patch.map((op) => op.path));

        // Apply frontend patch first
        const currentState = deepClone(this.state) as any;
        this.ensureIntermediatePaths(currentState, patch);
        let newState: unknown;
        try {
          const result = applyPatch(currentState, patch, false, false);
          newState = result.newDocument;
        } catch (e) {
          logger?.warn(
            { store: this.storeName, err: e },
            'Failed to apply client patch during conflict resolution'
          );
          return { needsSnapshot: true, resolvedState: this.state };
        }

        // Re-apply backend patches that don't overlap with frontend
        for (const bPatch of backendPatches) {
          const filteredPatch = bPatch.filter((op) => !frontendPaths.has(op.path));
          if (filteredPatch.length > 0) {
            try {
              const result = applyPatch(deepClone(newState), filteredPatch, false, false);
              newState = result.newDocument;
            } catch (e) {
              logger?.debug(
                { store: this.storeName, err: e },
                'Skipping backend patch during conflict resolution'
              );
            }
          }
        }

        this.state = newState;

        // Compute final patch from last snapshot
        const finalPatch = computePatch(this.lastSnapshot as any, newState as any);
        if (finalPatch.length > 0) {
          this.commitPatch(finalPatch);
        }

        // Send snapshot to converge
        return { needsSnapshot: true, resolvedState: this.state };
      }

      // clientBaseSeq > this.seq - client is ahead, shouldn't happen
      logger?.warn(
        { store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
        'Client ahead of server, sending snapshot'
      );
      return { needsSnapshot: true, resolvedState: this.state };
    } catch (e) {
      logger?.error(
        { store: this.storeName, err: e },
        'Unexpected error applying client patch'
      );
      return { needsSnapshot: true, resolvedState: this.state };
    }
  }
}

/**
 * Registry managing multiple synchronized stores.
 */
export class SyncRegistry {
  private entries = new Map<string, SyncEntry>();
  private logger?: FastifyBaseLogger;

  constructor(logger?: FastifyBaseLogger) {
    this.logger = logger?.child({ component: 'SyncRegistry' });
  }

  /**
   * Register a store with initial state.
   */
  register(config: StoreConfig): void {
    const entry = new SyncEntry(config.name, config.initialState());
    this.entries.set(config.name, entry);
    this.logger?.debug({ store: config.name }, 'Registered store');
  }

  /**
   * Check if a store is registered.
   */
  has(storeName: string): boolean {
    return this.entries.has(storeName);
  }

  /**
   * Get current state of a store.
   */
  getState<T = unknown>(storeName: string): T | undefined {
    const entry = this.entries.get(storeName);
    return entry?.getState() as T | undefined;
  }

  /**
   * Get current sequence number for a store.
   */
  getSeq(storeName: string): number {
    const entry = this.entries.get(storeName);
    return entry?.getSeq() ?? 0;
  }

  /**
   * Set state directly (used for loading from container).
   */
  setState(storeName: string, state: unknown): void {
    const entry = this.entries.get(storeName);
    if (entry) {
      entry.setState(state);
      this.logger?.debug({ store: storeName }, 'Set store state');
    }
  }

  /**
   * Update state locally and compute patch.
   * Returns the patch if state changed, null otherwise.
   */
  updateState(storeName: string, updater: (state: unknown) => unknown): JsonPatchOp[] | null {
    const entry = this.entries.get(storeName);
    if (!entry) {
      return null;
    }

    const currentState = entry.getState();
    const newState = updater(currentState);

    // Compute patch
    const patch = computePatch(currentState as any, newState as any);
    if (patch.length === 0) {
      return null;
    }

    // Apply and commit
    entry.applyPatch(patch);
    entry.commitPatch(patch);

    this.logger?.debug(
      { store: storeName, seq: entry.getSeq(), patchOps: patch.length },
      'Updated store state'
    );

    return patch;
  }

  /**
   * Sync client based on their reported sequences.
   * Returns messages to send (snapshots or patches).
   */
  syncClient(clientSeqs: Record<string, number>): (SnapshotMessage | PatchMessage)[] {
    const messages: (SnapshotMessage | PatchMessage)[] = [];

    for (const [storeName, entry] of this.entries) {
      const clientSeq = clientSeqs[storeName] ?? -1;
      const catchupPatches = entry.getCatchupPatches(clientSeq);

      if (catchupPatches === null) {
        // Need full snapshot
        messages.push({
          type: 'snapshot',
          store: storeName,
          seq: entry.getSeq(),
          state: entry.getState(),
        });
        this.logger?.debug(
          { store: storeName, clientSeq, serverSeq: entry.getSeq() },
          'Sending snapshot'
        );
      } else {
        // Send catchup patches
        for (const { seq, patch } of catchupPatches) {
          messages.push({
            type: 'patch',
            store: storeName,
            seq,
            patch,
          });
        }
        if (catchupPatches.length > 0) {
          this.logger?.debug(
            { store: storeName, patchCount: catchupPatches.length },
            'Sending catchup patches'
          );
        }
      }
    }

    return messages;
  }

  /**
   * Apply a patch from the client.
   * Returns message to send back (snapshot if conflict, null otherwise).
   */
  applyClientPatch(
    storeName: string,
    clientBaseSeq: number,
    patch: JsonPatchOp[]
  ): SnapshotMessage | null {
    const entry = this.entries.get(storeName);
    if (!entry) {
      this.logger?.warn({ store: storeName }, 'Store not found');
      return null;
    }

    const result = entry.applyClientPatch(clientBaseSeq, patch, this.logger);

    if (result.needsSnapshot) {
      return {
        type: 'snapshot',
        store: storeName,
        seq: entry.getSeq(),
        state: result.resolvedState ?? entry.getState(),
      };
    }

    return null;
  }

  /**
   * Get all registered store names.
   */
  getStoreNames(): string[] {
    return Array.from(this.entries.keys());
  }

  /**
   * Get all current sequences (for persistence).
   */
  getAllSeqs(): Record<string, number> {
    const seqs: Record<string, number> = {};
    for (const [name, entry] of this.entries) {
      seqs[name] = entry.getSeq();
    }
    return seqs;
  }

  /**
   * Get all current states (for persistence).
   */
  getAllStates(): Record<string, unknown> {
    const states: Record<string, unknown> = {};
    for (const [name, entry] of this.entries) {
      states[name] = entry.getState();
    }
    return states;
  }

  /**
   * Get patches since a given sequence number for a store.
   * Returns empty array if no patches available since that sequence.
   */
  getPatchesSince(storeName: string, sinceSeq: number): JsonPatchOp[] | null {
    const entry = this.entries.get(storeName);
    if (!entry) {
      return null;
    }

    const catchupPatches = entry.getCatchupPatches(sinceSeq);
    if (catchupPatches === null) {
      return null; // History not available
    }

    // Flatten all patches into a single array
    const allPatches: JsonPatchOp[] = [];
    for (const { patch } of catchupPatches) {
      allPatches.push(...patch);
    }

    return allPatches;
  }
}
