import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { IdempotencyService } from './idempotency.service';
import type { PrismaService } from '../prisma/prisma.service';

const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;

const svc = () => new IdempotencyService(asService);
const CMD = 'test.command';

beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });

describe('IdempotencyService (P9 — command ledger)', () => {
  it('runs fn once, returns its result, and records a COMPLETED row with the response', async () => {
    let calls = 0;
    const result = await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => {
      calls++;
      return { id: 'r1', ok: true };
    });
    expect(result).toEqual({ id: 'r1', ok: true });
    expect(calls).toBe(1);
    const row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
    expect(row.status).toBe('COMPLETED');
    expect(row.response).toEqual({ id: 'r1', ok: true });
    expect(row.completedAt).toBeTruthy();
  });

  it('replays the cached response on a duplicate (same request) without re-running fn', async () => {
    let calls = 0;
    const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
    const first = await svc().run(params, async () => { calls++; return { id: 'r1' }; });
    const second = await svc().run(params, async () => { calls++; return { id: 'r2-should-not-run' }; });
    expect(second).toEqual(first);
    expect(calls).toBe(1); // fn never ran the second time
    expect(await prisma.iiosIdempotencyCommand.count()).toBe(1);
  });

  it('rejects the same key reused with a different request (fn not called)', async () => {
    let calls = 0;
    await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => { calls++; return { id: 'r1' }; });
    await expect(
      svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 2 } }, async () => { calls++; return { id: 'r2' }; }),
    ).rejects.toBeInstanceOf(ConflictException);
    expect(calls).toBe(1);
  });

  it('marks FAILED on throw + propagates, and a later run with the same key retries to COMPLETED', async () => {
    let calls = 0;
    const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
    await expect(
      svc().run(params, async () => { calls++; throw new Error('boom'); }),
    ).rejects.toThrow('boom');
    let row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
    expect(row.status).toBe('FAILED');

    const retry = await svc().run(params, async () => { calls++; return { id: 'r-ok' }; });
    expect(retry).toEqual({ id: 'r-ok' });
    expect(calls).toBe(2); // failed attempt + successful retry
    row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
    expect(row.status).toBe('COMPLETED');
  });

  it('a concurrent IN_FLIGHT row → conflict (command in progress)', async () => {
    await prisma.iiosIdempotencyCommand.create({
      data: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1', requestHash: 'irrelevant', status: 'IN_FLIGHT' },
    });
    // Same requestHash so we reach the IN_FLIGHT branch, not the mismatch branch.
    const { createHash } = await import('node:crypto');
    const rh = createHash('sha256').update('{"a":1}').digest('hex');
    await prisma.iiosIdempotencyCommand.updateMany({ where: { idempotencyKey: 'k1' }, data: { requestHash: rh } });
    await expect(
      svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'r' })),
    ).rejects.toThrow(/in progress/);
  });

  it('the same command+key under two different scopes are independent (tenant isolation)', async () => {
    const a = await svc().run({ scopeId: 'scope-A', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'A' }));
    const b = await svc().run({ scopeId: 'scope-B', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'B' }));
    expect(a).toEqual({ id: 'A' });
    expect(b).toEqual({ id: 'B' }); // B did not replay A's response
    expect(await prisma.iiosIdempotencyCommand.count()).toBe(2);
  });
});
