/**
 * Sync Engine Tests — 25+ tests
 *
 * Covers: queue persistence, retry with backoff, conflict resolution (all 4
 * strategies), deduplication, connectivity, onPull callback, telemetry,
 * delete consolidation, multiple entities, status monitoring, destroy.
 *
 * @module @bytelyst/sync/sync.test
 */

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js';
import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js';
import type { ApiClient, ApiResult } from '@bytelyst/api-client';
import type { TelemetryClient } from '@bytelyst/telemetry-client';

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────

interface MockApiClient extends ApiClient {
  getRequests(): { path: string; options?: RequestInit }[];
  setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void;
  setSafeFetchBehavior(fn: (path: string) => unknown): void;
}

function createMockApiClient(): MockApiClient {
  const requests: { path: string; options?: RequestInit }[] = [];
  let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null;
  let safeFetchBehavior: ((path: string) => unknown) | null = null;

  return {
    fetch: async <T>(path: string, options?: RequestInit): Promise<T> => {
      requests.push({ path, options });
      if (fetchBehavior) return fetchBehavior(path, options) as T;
      return {} as T;
    },
    safeFetch: async <T>(path: string, options?: RequestInit): Promise<ApiResult<T>> => {
      requests.push({ path, options });
      if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult<T>;
      return { data: { items: [] } as unknown as T, error: null };
    },
    getRequests: () => requests,
    setFetchBehavior: fn => {
      fetchBehavior = fn;
    },
    setSafeFetchBehavior: fn => {
      safeFetchBehavior = fn;
    },
  };
}

function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } {
  const events: { eventName: string }[] = [];
  return {
    init: vi.fn(),
    trackEvent: (eventType: string, module: string, eventName: string) => {
      events.push({ eventName });
    },
    flush: vi.fn(),
    shutdown: vi.fn(),
    getInstallId: () => 'test-install',
    getSessionId: () => 'test-session',
    events,
  };
}

const TASKS_ENTITY: EntityConfig = {
  endpoint: '/tasks',
  partitionKey: 'userId',
  conflictStrategy: 'server-wins',
};

function makeConfig(
  storage: InMemoryAdapter,
  apiClient: MockApiClient,
  overrides?: Partial<SyncEngineConfig>
): SyncEngineConfig {
  return {
    productId: 'test',
    entities: { tasks: TASKS_ENTITY },
    storage,
    apiClient,
    maxRetries: 3,
    retryBaseDelayMs: 1, // fast for tests
    retryMaxDelayMs: 10,
    ...overrides,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Storage Adapter Tests
// ─────────────────────────────────────────────────────────────────────────────

describe('InMemoryAdapter', () => {
  it('stores and retrieves items', () => {
    const s = new InMemoryAdapter();
    s.setItem('k', { x: 1 });
    expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 });
  });

  it('returns null for missing keys', () => {
    expect(new InMemoryAdapter().getItem('nope')).toBeNull();
  });

  it('lists all keys', () => {
    const s = new InMemoryAdapter();
    s.setItem('a', 1);
    s.setItem('b', 2);
    expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b']));
  });

  it('removes items', () => {
    const s = new InMemoryAdapter();
    s.setItem('a', 1);
    s.removeItem('a');
    expect(s.getItem('a')).toBeNull();
  });

  it('clears all items', () => {
    const s = new InMemoryAdapter();
    s.setItem('a', 1);
    s.setItem('b', 2);
    s.clear();
    expect(s.keys()).toHaveLength(0);
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// computeBackoff
// ─────────────────────────────────────────────────────────────────────────────

describe('computeBackoff', () => {
  it('returns increasing delays', () => {
    const d0 = computeBackoff(0, 1000, 30000);
    const d1 = computeBackoff(1, 1000, 30000);
    const d2 = computeBackoff(2, 1000, 30000);
    // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter)
    expect(d0).toBeLessThan(d1);
    expect(d1).toBeLessThan(d2);
  });

  it('caps at maxMs', () => {
    const d = computeBackoff(20, 1000, 5000);
    expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Sync Engine — Core Operations
// ─────────────────────────────────────────────────────────────────────────────

describe('Sync Engine', () => {
  let storage: InMemoryAdapter;
  let apiClient: MockApiClient;

  beforeEach(() => {
    storage = new InMemoryAdapter();
    apiClient = createMockApiClient();
  });

  // ─── Creation ──────────────────────────────────────────────────────────

  it('creates engine with all interface methods', () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    expect(engine.push).toBeTypeOf('function');
    expect(engine.delete).toBeTypeOf('function');
    expect(engine.pull).toBeTypeOf('function');
    expect(engine.fullSync).toBeTypeOf('function');
    expect(engine.getQueueLength).toBeTypeOf('function');
    expect(engine.getStatus).toBeTypeOf('function');
    expect(engine.onStatusChange).toBeTypeOf('function');
    expect(engine.clearQueue).toBeTypeOf('function');
    expect(engine.reprocessFailed).toBeTypeOf('function');
    expect(engine.flush).toBeTypeOf('function');
    expect(engine.destroy).toBeTypeOf('function');
  });

  // ─── Queue Persistence ─────────────────────────────────────────────────

  it('persists queue across engine instances (simulated restart)', async () => {
    const engine1 = createSyncEngine(makeConfig(storage, apiClient));
    await engine1.push('tasks', { id: 't1', title: 'persist me' });
    engine1.destroy();

    // "Restart" — new engine, same storage
    const engine2 = createSyncEngine(makeConfig(storage, apiClient));
    const result = await engine2.fullSync();
    expect(result.pushed).toBe(1);

    const reqs = apiClient.getRequests();
    const postReq = reqs.find(r => r.options?.method === 'POST');
    expect(postReq).toBeDefined();
    expect(postReq!.path).toBe('/tasks');
  });

  // ─── Push & Deduplication ──────────────────────────────────────────────

  it('deduplicates updates to same entity+id', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { id: '1', title: 'v1' }, 'update');
    await engine.push('tasks', { id: '1', title: 'v2' }, 'update');
    expect(engine.getQueueLength()).toBe(1);

    const result = await engine.fullSync();
    expect(result.pushed).toBe(1);

    // The last value should be sent
    const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH');
    expect(patchReq).toBeDefined();
    expect(patchReq!.path).toBe('/tasks/1');
    const body = JSON.parse(patchReq!.options!.body as string);
    expect(body.title).toBe('v2');
  });

  it('does not deduplicate different operations on same id', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { id: '1', title: 'create' }, 'create');
    await engine.push('tasks', { id: '1', title: 'update' }, 'update');
    expect(engine.getQueueLength()).toBe(2);
  });

  it('does not deduplicate items without id', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { title: 'Task A' });
    await engine.push('tasks', { title: 'Task B' });
    expect(engine.getQueueLength()).toBe(2);
  });

  // ─── Delete ────────────────────────────────────────────────────────────

  it('delete removes pending create/update for same entity+id', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { id: 'x', title: 'created' });
    await engine.push('tasks', { id: 'x', title: 'updated' }, 'update');
    // Now delete should collapse the above
    await engine.delete('tasks', 'x');
    expect(engine.getQueueLength()).toBe(1);

    const result = await engine.fullSync();
    expect(result.pushed).toBe(1);
    const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE');
    expect(delReq).toBeDefined();
    expect(delReq!.path).toBe('/tasks/x');
  });

  // ─── Pull + onPull Callback ────────────────────────────────────────────

  it('invokes onPull with pulled items', async () => {
    const pulled: { entity: string; items: unknown[] }[] = [];
    apiClient.setSafeFetchBehavior(() => ({
      data: { items: [{ id: 'r1', title: 'Remote Task' }] },
      error: null,
    }));

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        onPull: (entity, items) => {
          pulled.push({ entity, items });
        },
      })
    );

    const result = await engine.pull();
    expect(result.pulled).toBe(1);
    expect(pulled).toHaveLength(1);
    expect(pulled[0].entity).toBe('tasks');
    expect(pulled[0].items).toHaveLength(1);
  });

  it('pull appends ?since= parameter after first sync', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));

    await engine.pull(); // first pull — no since
    const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks'));
    expect(firstReq!.path).toBe('/tasks');

    await engine.pull(); // second pull — should have since=
    const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks'));
    const secondReq = allReqs[allReqs.length - 1];
    expect(secondReq.path).toContain('?since=');
  });

  // ─── fullSync ──────────────────────────────────────────────────────────

  it('fullSync pushes then pulls', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { id: 't1', title: 'local' });

    const result = await engine.fullSync();
    expect(result.pushed).toBe(1);
    expect(engine.getQueueLength()).toBe(0);
    expect(engine.getStatus().lastSyncAt).toBeTruthy();
  });

  // ─── Retry with Backoff ────────────────────────────────────────────────

  it('retries on transient errors and keeps item in queue', async () => {
    let callCount = 0;
    apiClient.setFetchBehavior(() => {
      callCount++;
      throw new Error('500 Internal Server Error');
    });

    const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 }));
    await engine.push('tasks', { id: 'fail', title: 'will fail' });

    await engine.flush();

    // Item should still be in queue with incremented retryCount
    expect(engine.getQueueLength()).toBe(1);
    // Multiple fetch attempts were made (backoff retries within pushItemWithRetry)
    expect(callCount).toBeGreaterThan(1);
  });

  it('drops item after exceeding maxRetries', async () => {
    apiClient.setFetchBehavior(() => {
      throw new Error('500');
    });

    const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 }));
    await engine.push('tasks', { id: 'drop', title: 'drop me' });

    // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount
    await engine.flush();
    // Second flush: retryCount >= maxRetries → dropped
    await engine.flush();

    expect(engine.getQueueLength()).toBe(0);
  });

  // ─── Conflict Resolution ───────────────────────────────────────────────

  it('server-wins: accepts remote data on conflict', async () => {
    const pulled: unknown[][] = [];
    apiClient.setFetchBehavior(() => {
      throw new SyncConflictError({ id: 'c1', title: 'Server Version' });
    });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        entities: {
          tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
        },
        onPull: (_entity, items) => {
          pulled.push(items);
        },
      })
    );

    await engine.push('tasks', { id: 'c1', title: 'Client Version' });
    const result = await engine.fullSync();

    expect(result.conflicts).toBe(1);
    // server-wins: onPull should have been called with remote data
    expect(pulled.length).toBeGreaterThanOrEqual(1);
  });

  it('client-wins: re-pushes local data on conflict', async () => {
    let callIdx = 0;
    apiClient.setFetchBehavior(() => {
      callIdx++;
      if (callIdx === 1) {
        throw new SyncConflictError({ id: 'c2', title: 'Server' });
      }
      return {}; // Second call (PUT) succeeds
    });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        entities: {
          tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' },
        },
      })
    );

    await engine.push('tasks', { id: 'c2', title: 'Client' });
    const result = await engine.fullSync();

    expect(result.conflicts).toBe(1);
    expect(result.pushed).toBe(1); // conflict resolved → counted as pushed
    // Should have made a PUT request with client data
    const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT');
    expect(putReq).toBeDefined();
  });

  it('last-write-wins: picks newer timestamp', async () => {
    const pulled: unknown[][] = [];
    const oldDate = '2020-01-01T00:00:00.000Z';
    apiClient.setFetchBehavior(() => {
      throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate });
    });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        entities: {
          tasks: {
            endpoint: '/tasks',
            partitionKey: 'userId',
            conflictStrategy: 'last-write-wins',
          },
        },
        onPull: (_entity, items) => {
          pulled.push(items);
        },
      })
    );

    // Client push will have a newer timestamp than 2020
    await engine.push('tasks', { id: 'c3', title: 'Client Newer' });
    const result = await engine.fullSync();
    expect(result.conflicts).toBe(1);
    // Client is newer → should NOT have called onPull with server data
    // Instead it should have re-pushed (PUT)
    const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT');
    expect(putReq).toBeDefined();
  });

  it('manual: calls onConflict handler', async () => {
    apiClient.setFetchBehavior((_path, options) => {
      const method = options?.method;
      if (method === 'POST') {
        throw new SyncConflictError({ id: 'c4', title: 'Server' });
      }
      return {};
    });

    const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        entities: {
          tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' },
        },
        onConflict,
      })
    );

    await engine.push('tasks', { id: 'c4', title: 'Client' });
    const result = await engine.fullSync();

    expect(result.conflicts).toBe(1);
    expect(onConflict).toHaveBeenCalledTimes(1);
    expect(onConflict.mock.calls[0][0]).toMatchObject({
      entity: 'tasks',
      remoteData: { id: 'c4', title: 'Server' },
    });
  });

  // ─── Multiple Entities ─────────────────────────────────────────────────

  it('handles multiple entity types', async () => {
    const pulled: { entity: string; items: unknown[] }[] = [];
    apiClient.setSafeFetchBehavior(path => {
      if (path.startsWith('/tasks')) {
        return { data: { items: [{ id: 't1' }] }, error: null };
      }
      if (path.startsWith('/notes')) {
        return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null };
      }
      return { data: { items: [] }, error: null };
    });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        entities: {
          tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' },
          notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' },
        },
        onPull: (entity, items) => {
          pulled.push({ entity, items });
        },
      })
    );

    await engine.push('tasks', { id: 't-new', title: 'Task' });
    await engine.push('notes', { id: 'n-new', body: 'Note' });

    const result = await engine.fullSync();
    expect(result.pushed).toBe(2);
    expect(result.pulled).toBe(3); // 1 task + 2 notes
    expect(pulled).toHaveLength(2);
  });

  // ─── Status Monitoring ─────────────────────────────────────────────────

  it('returns correct initial status', () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    const status = engine.getStatus();
    expect(status.status).toBe('idle');
    expect(status.queueLength).toBe(0);
    expect(status.lastSyncAt).toBeUndefined();
  });

  it('notifies listeners on status changes', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    const statuses: SyncStatusInfo[] = [];
    engine.onStatusChange(s => statuses.push({ ...s }));

    await engine.push('tasks', { id: 'x', title: 'X' });
    await engine.fullSync();

    const statusNames = statuses.map(s => s.status);
    expect(statusNames).toContain('syncing');
    expect(statusNames).toContain('idle');
  });

  it('unsubscribe stops notifications', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    const statuses: string[] = [];
    const unsub = engine.onStatusChange(s => statuses.push(s.status));

    await engine.push('tasks', { title: 'A' });
    const countBefore = statuses.length;

    unsub();
    await engine.push('tasks', { title: 'B' });
    expect(statuses.length).toBe(countBefore);
  });

  // ─── clearQueue ────────────────────────────────────────────────────────

  it('clearQueue empties the queue', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { title: 'A' });
    await engine.push('tasks', { title: 'B' });
    expect(engine.getQueueLength()).toBe(2);

    await engine.clearQueue();
    expect(engine.getQueueLength()).toBe(0);

    const result = await engine.fullSync();
    expect(result.pushed).toBe(0);
  });

  // ─── reprocessFailed ───────────────────────────────────────────────────

  it('reprocessFailed resets retry counts and re-flushes', async () => {
    let failCount = 0;
    apiClient.setFetchBehavior(() => {
      failCount++;
      if (failCount <= 2) throw new Error('transient');
      return {};
    });

    const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 }));
    await engine.push('tasks', { id: 'rp', title: 'reprocess' });
    await engine.flush(); // fails, item stays in queue

    expect(engine.getQueueLength()).toBe(1);

    // Now make API succeed and reprocess
    apiClient.setFetchBehavior(() => ({}));
    await engine.reprocessFailed();
    expect(engine.getQueueLength()).toBe(0);
  });

  // ─── Telemetry Integration ─────────────────────────────────────────────

  it('tracks sync events via telemetry client', async () => {
    const telemetry = createMockTelemetry();
    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        telemetryClient: telemetry,
      })
    );

    await engine.push('tasks', { id: 't1', title: 'test' });
    await engine.fullSync();

    const eventNames = telemetry.events.map(e => e.eventName);
    expect(eventNames).toContain('sync_push_success');
    expect(eventNames).toContain('sync_complete');
  });

  it('telemetry tracks push errors', async () => {
    const telemetry = createMockTelemetry();
    apiClient.setFetchBehavior(() => {
      throw new Error('400 Bad Request');
    });

    const engine = createSyncEngine(
      makeConfig(storage, apiClient, {
        telemetryClient: telemetry,
        maxRetries: 1,
      })
    );

    await engine.push('tasks', { id: 'bad', title: 'fail' });
    await engine.flush();
    await engine.flush(); // second flush drops item

    const eventNames = telemetry.events.map(e => e.eventName);
    expect(eventNames).toContain('sync_push_dropped');
  });

  // ─── Destroy ───────────────────────────────────────────────────────────

  it('destroy prevents further flush', async () => {
    const engine = createSyncEngine(makeConfig(storage, apiClient));
    await engine.push('tasks', { title: 'orphan' });
    engine.destroy();

    await engine.flush(); // should be no-op after destroy
    // Item still in queue (flush was no-op)
    expect(engine.getQueueLength()).toBe(1);
  });
});
