import { prismaMock as mockPrisma } from '../../setup';

jest.mock('@prisma/client', () => ({
  ...jest.requireActual('@prisma/client'),
  PrismaClient: jest.fn(() => mockPrisma),
}));

jest.mock('../../../utils/logger', () => ({
  logger: {
    info: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
    debug: jest.fn(),
  },
}));

jest.mock('../../../queue', () => ({
  translationQueue: { add: jest.fn(), process: jest.fn(), on: jest.fn(), isReady: jest.fn(), close: jest.fn() },
}));

jest.mock('../../../queue/bulkQueueDispatcher', () => ({
  drainBulkQueue: jest.fn(),
}));

import {
  bulkContentJobSchema,
  BulkContentSubmissionError,
  jobsListQuerySchema,
  submitBulkContentJob,
} from '../../../routes/jobs';
import { drainBulkQueue } from '../../../queue/bulkQueueDispatcher';
import type { BulkContentQueuePayload, BulkContentTranslationJobData } from '../../../types';

const prismaMock = mockPrisma;
const SUBMISSION_ID = '00000000-0000-4000-8000-000000000041';
const CALLBACK_SECRET = 'c'.repeat(24);

function transactionWithMockClient(): void {
  (prismaMock.$transaction as jest.Mock).mockImplementation(
    async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
      const tx = {
        $executeRaw: jest.fn().mockResolvedValue(1),
        translationJob: prismaMock.translationJob,
        creditTransaction: prismaMock.creditTransaction,
      } as unknown as typeof prismaMock;
      return callback(tx);
    }
  );
}

type BulkContentSubmission = Parameters<typeof submitBulkContentJob>[0];

function reconstructBullBulkContentPayload(
  persisted: BulkContentQueuePayload,
  callbackSecret: string
): BulkContentTranslationJobData {
  return {
    ...persisted,
    items: persisted.items.map(({ ref, targetLang, fields }) => ({ ref, targetLang, fields })),
    callbackSecret,
  };
}

function makeRequest(overrides: Partial<BulkContentSubmission> = {}): BulkContentSubmission {
  return {
    userId: 'user-1',
    plugin: 'international',
    siteUrl: 'https://site.example.test',
    submissionId: SUBMISSION_ID,
    clientJobId: 'wp_bulk_41',
    sourceLang: 'en',
    items: [
      {
        ref: 'content:1:es',
        targetLang: 'es',
        title: 'Title',
        content: 'Body',
        fields: {
          'acf.subtitle': 'Subheading',
          'seo.description': 'Description',
        },
      },
      {
        ref: 'content:1:fr',
        targetLang: 'fr',
        fields: { 'acf.subtitle': 'Sous-titre' },
      },
    ],
    callbackUrl: 'https://site.example.test/callback',
    callbackSecret: CALLBACK_SECRET,
    tone: 'neutral' as const,
    ...overrides,
  };
}

describe('bulk-content job request validation', () => {
  const base = {
    submissionId: SUBMISSION_ID,
    clientJobId: 'wp_bulk_41',
    sourceLang: 'en',
    callbackUrl: 'https://site.example.test/callback',
  };

  it('accepts a valid multi-item request with arbitrary structured fields', () => {
    const parsed = bulkContentJobSchema.parse({
      ...base,
      items: [
        {
          ref: 'content:1:es',
          targetLang: 'es',
          title: 'Title',
          content: 'Body',
          fields: { 'acf.subtitle': 'Subheading', 'seo.description': 'Description' },
        },
        { ref: 'content:1:fr', targetLang: 'fr', fields: { 'acf.subtitle': 'Sous-titre' } },
      ],
    });

    expect(parsed.items).toHaveLength(2);
    expect(parsed.items[0].fields).toEqual({
      'acf.subtitle': 'Subheading',
      'seo.description': 'Description',
    });
  });

  it('requires a UUID submission identity and client job identity', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      submissionId: undefined,
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).toThrow('submissionId');

    expect(() => bulkContentJobSchema.parse({
      ...base,
      clientJobId: undefined,
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).toThrow('clientJobId');
  });

  it.each([
    ['ASCII', 'a'.repeat(512), 'a'.repeat(513)],
    ['emoji', '😀'.repeat(128), '😀'.repeat(129)],
    ['normalization expansion', '̈́'.repeat(128), '̈́'.repeat(129)],
  ])('normalizes first, accepting exactly 512 UTF-8 bytes and rejecting larger %s identities', (
    _label,
    accepted,
    rejected
  ) => {
    const item = { ref: 'content:1:es', targetLang: 'es', content: 'Body' };
    const parsed = bulkContentJobSchema.parse({
      ...base,
      clientJobId: accepted,
      items: [item],
    });

    expect(Buffer.byteLength(parsed.clientJobId, 'utf8')).toBe(512);
    expect(parsed.clientJobId).toBe(accepted.normalize('NFC'));
    expect(() => bulkContentJobSchema.parse({
      ...base,
      clientJobId: rejected,
      items: [item],
    })).toThrow('512 UTF-8 bytes');
  });

  it('uses the same canonical client job identity contract for job lookup', () => {
    const accepted = '̈́'.repeat(128);
    const parsed = jobsListQuerySchema.parse({ clientJobId: accepted });

    expect(parsed.clientJobId).toBe(accepted.normalize('NFC'));
    expect(Buffer.byteLength(parsed.clientJobId!, 'utf8')).toBe(512);
    expect(() => jobsListQuerySchema.parse({ clientJobId: '̈́'.repeat(129) }))
      .toThrow('512 UTF-8 bytes');
  });

  it('rejects callback secrets in the request body', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      callbackSecret: CALLBACK_SECRET,
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).toThrow();
  });

  it('rejects duplicate core keys supplied through fields', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [{
        ref: 'content:1:es',
        targetLang: 'es',
        title: 'Title',
        fields: { title: 'Duplicate title' },
      }],
    })).toThrow('reserved');
  });

  it('rejects an item naming the offending ref when it has no translatable field', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [{ ref: 'content:1:es', targetLang: 'es' }],
    })).toThrow('content:1:es');
  });

  it('rejects an item naming the offending ref when it exceeds the character cap', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [{ ref: 'content:2:es', targetLang: 'es', content: 'x'.repeat(60_000) }],
    })).toThrow('content:2:es');
  });

  it('rejects more than the max item count', () => {
    const items = Array.from({ length: 21 }, (_, i) => ({
      ref: `content:${i}:es`,
      targetLang: 'es',
      content: 'Body',
    }));
    expect(() => bulkContentJobSchema.parse({ ...base, items })).toThrow('at most 20 items');
  });

  it('rejects duplicate refs after canonical normalization', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [
        { ref: 'content:1:es', targetLang: 'es', content: 'Body' },
        { ref: 'content:1:es', targetLang: 'fr', content: 'Body' },
      ],
    })).toThrow('Duplicate bulk content ref');

    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [
        { ref: 'content:1:é', targetLang: 'es', content: 'Body' },
        { ref: 'content:1:é', targetLang: 'fr', content: 'Body' },
      ],
    })).toThrow('Duplicate bulk content ref');
  });

  it('accepts refs at 100 characters and rejects refs at 101 characters', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [{ ref: 'r'.repeat(100), targetLang: 'es', content: 'Body' }],
    })).not.toThrow();

    expect(() => bulkContentJobSchema.parse({
      ...base,
      items: [{ ref: 'r'.repeat(101), targetLang: 'es', content: 'Body' }],
    })).toThrow('must not exceed 100 characters');
  });

  it('accepts callbackUrl at 1000 characters and rejects callbackUrl at 1001 characters', () => {
    const callbackUrlPrefix = 'https://site.example.test/callback/';
    const callbackUrlAtLimit = callbackUrlPrefix + 'a'.repeat(1000 - callbackUrlPrefix.length);
    const callbackUrlOverLimit = callbackUrlAtLimit + 'a';

    expect(callbackUrlAtLimit).toHaveLength(1000);
    expect(() => bulkContentJobSchema.parse({
      ...base,
      callbackUrl: callbackUrlAtLimit,
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).not.toThrow();

    expect(callbackUrlOverLimit).toHaveLength(1001);
    expect(() => bulkContentJobSchema.parse({
      ...base,
      callbackUrl: callbackUrlOverLimit,
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).toThrow('callbackUrl must not exceed 1000 characters');
  });

  it('continues to reject invalid callback URLs within the length bound', () => {
    expect(() => bulkContentJobSchema.parse({
      ...base,
      callbackUrl: 'not-a-url',
      items: [{ ref: 'content:1:es', targetLang: 'es', content: 'Body' }],
    })).toThrow();
  });
});

describe('submitBulkContentJob', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    transactionWithMockClient();
    prismaMock.creditTransaction.findFirst.mockResolvedValue({ balance_after: 1_000_000 } as never);
    prismaMock.translationJob.aggregate.mockResolvedValue({ _sum: { reserved_characters: 0 } } as never);
  });

  it('creates a durable structured job and excludes the callback secret from canonical data', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      id: 'job-1',
      status: 'pending',
    }));

    const job = await submitBulkContentJob(makeRequest());
    const createData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;

    expect(job.id).toBe('job-1');
    expect(job.created).toBe(true);
    expect(createData.submission_id).toBe(SUBMISSION_ID);
    expect(createData.submission_fingerprint).toMatch(/^[a-f0-9]{64}$/);
    expect(createData.callback_secret).toBe(CALLBACK_SECRET);
    expect(createData.queue_payload).toEqual(expect.objectContaining({
      type: 'bulk-content',
      submissionId: SUBMISSION_ID,
      items: expect.arrayContaining([
        {
          ref: 'content:1:es',
          targetLang: 'es',
          fields: {
            'acf.subtitle': 'Subheading',
            content: 'Body',
            'seo.description': 'Description',
            title: 'Title',
          },
        },
      ]),
    }));
    expect(JSON.stringify(createData.queue_payload)).not.toContain(CALLBACK_SECRET);
    expect(JSON.stringify(createData.content)).not.toContain(CALLBACK_SECRET);
    expect(Object.prototype.hasOwnProperty.call(createData.queue_payload, 'callbackSecret')).toBe(false);
    expect(drainBulkQueue).toHaveBeenCalledWith('job-1');
  });

  it('returns the durable admission before queue drain settles', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      id: 'job-admitted',
      status: 'pending',
    }));

    let releaseDrain: (() => void) | undefined;
    const pendingDrain = new Promise<void>((resolve) => {
      releaseDrain = resolve;
    });
    (drainBulkQueue as jest.Mock).mockReturnValueOnce(pendingDrain);

    const admission = submitBulkContentJob(makeRequest());
    let settled = false;
    void admission.then(
      () => { settled = true; },
      () => { settled = true; }
    );

    try {
      await new Promise<void>((resolve) => setImmediate(resolve));
      expect(settled).toBe(true);
      expect(drainBulkQueue).toHaveBeenCalledWith('job-admitted');
      await expect(admission).resolves.toMatchObject({ id: 'job-admitted', created: true });
    } finally {
      releaseDrain?.();
    }
  });

  it('preserves differing field-key sets when the stored payload is reconstructed for Bull', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      id: 'job-reconstruct',
      status: 'pending',
    }));

    await submitBulkContentJob(makeRequest());
    const createData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;
    const bullPayload = reconstructBullBulkContentPayload(
      createData.queue_payload as BulkContentQueuePayload,
      CALLBACK_SECRET
    );

    expect(bullPayload.items).toEqual([
      {
        ref: 'content:1:es',
        targetLang: 'es',
        fields: {
          'acf.subtitle': 'Subheading',
          content: 'Body',
          'seo.description': 'Description',
          title: 'Title',
        },
      },
      {
        ref: 'content:1:fr',
        targetLang: 'fr',
        fields: { 'acf.subtitle': 'Sous-titre' },
      },
    ]);
    expect(bullPayload.callbackSecret).toBe(CALLBACK_SECRET);
  });

  it('normalizes legacy aliases and arbitrary fields into exact canonical field keys', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      id: 'job-1',
      status: 'pending',
    }));

    await submitBulkContentJob(makeRequest());
    const createData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;
    const firstItem = createData.queue_payload.items[0];

    expect(firstItem.fields).toEqual({
      'acf.subtitle': 'Subheading',
      content: 'Body',
      'seo.description': 'Description',
      title: 'Title',
    });
    expect(createData.queue_payload.items[1]).toEqual({
      ref: 'content:1:fr',
      targetLang: 'fr',
      fields: { 'acf.subtitle': 'Sous-titre' },
    });
  });

  it('includes differing field-key sets in the submission fingerprint', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      id: 'job-fields',
      status: 'pending',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
      ...data,
    }));

    await submitBulkContentJob(makeRequest());
    const firstCreateData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;
    prismaMock.translationJob.findUnique.mockResolvedValue({
      id: 'job-fields',
      status: 'pending',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
      ...firstCreateData,
    } as never);

    await expect(submitBulkContentJob(makeRequest({
      items: [
        makeRequest().items[0],
        {
          ref: 'content:1:fr',
          targetLang: 'fr',
          fields: { 'seo.description': 'Sous-description' },
        },
      ],
    }))).rejects.toMatchObject({
      statusCode: 409,
      code: 'SUBMISSION_ID_CONFLICT',
    });
  });

  it('returns the existing job on canonically exact replay without replacing its callback secret', async () => {
    const decomposedClientJobId = 'site-é';
    const canonicalClientJobId = decomposedClientJobId.normalize('NFC');
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    const createdJob = {
      id: 'job-existing',
      status: 'pending',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
    };
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      ...createdJob,
    }));

    await submitBulkContentJob(makeRequest({ clientJobId: decomposedClientJobId }));
    const firstCreateData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;
    expect(firstCreateData.client_job_id).toBe(canonicalClientJobId);
    expect(firstCreateData.queue_payload.clientJobId).toBe(canonicalClientJobId);
    expect(JSON.parse(firstCreateData.content).client_job_id).toBe(canonicalClientJobId);
    const stored = { ...firstCreateData, ...createdJob };
    prismaMock.translationJob.findUnique.mockResolvedValue(stored as never);

    const replay = await submitBulkContentJob(makeRequest({
      callbackSecret: 'd'.repeat(24),
      clientJobId: canonicalClientJobId,
    }));

    expect(replay.id).toBe('job-existing');
    expect(replay.created).toBe(false);
    expect(prismaMock.translationJob.create).toHaveBeenCalledTimes(1);
    expect(stored.callback_secret).toBe(CALLBACK_SECRET);
    expect(drainBulkQueue).toHaveBeenCalledTimes(2);
  });

  it('returns the existing identity even after the original job reaches a terminal state', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockImplementation(async ({ data }: any) => ({
      ...data,
      id: 'job-completed',
      status: 'pending',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
    }));
    await submitBulkContentJob(makeRequest());
    const firstCreateData = (prismaMock.translationJob.create as unknown as jest.Mock).mock.calls[0][0].data;

    prismaMock.translationJob.findUnique.mockResolvedValue({
      ...firstCreateData,
      id: 'job-completed',
      status: 'completed',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
    } as never);

    const replay = await submitBulkContentJob(makeRequest({ callbackSecret: 'd'.repeat(24) }));
    expect(replay.id).toBe('job-completed');
    expect(replay.status).toBe('completed');
    expect(replay.created).toBe(false);
  });

  it('returns 409 for a same-principal fingerprint conflict', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue({
      id: 'job-conflict',
      status: 'pending',
      user_id: 'user-1',
      plugin: 'international',
      site_url: 'https://site.example.test',
      submission_fingerprint: 'f'.repeat(64),
    } as never);

    await expect(submitBulkContentJob(makeRequest())).rejects.toMatchObject({
      statusCode: 409,
      code: 'SUBMISSION_ID_CONFLICT',
    });
    expect(prismaMock.translationJob.create).not.toHaveBeenCalled();
    expect(drainBulkQueue).not.toHaveBeenCalled();
  });

  it('returns indistinguishable 404 for a different principal', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue({
      id: 'job-private',
      status: 'pending',
      user_id: 'other-user',
      plugin: 'international',
      site_url: 'https://site.example.test',
      submission_fingerprint: 'f'.repeat(64),
    } as never);

    await expect(submitBulkContentJob(makeRequest())).rejects.toMatchObject({
      statusCode: 404,
      code: 'JOB_NOT_FOUND',
    });
  });

  it('runs the credit gate only after confirming this submission is new', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    (prismaMock.translationJob.create as unknown as jest.Mock).mockResolvedValue({
      id: 'job-credits',
      status: 'pending',
    } as never);
    const ensureCredits = jest.fn().mockResolvedValue(undefined);

    await submitBulkContentJob(makeRequest(), { ensureCredits });

    expect(ensureCredits).toHaveBeenCalledWith(expect.any(Number));
    expect(ensureCredits.mock.calls[0][0]).toBeGreaterThan(0);
  });

  it('preserves a committed job when immediate queue dispatch fails', async () => {
    prismaMock.translationJob.findUnique.mockResolvedValue(null);
    prismaMock.translationJob.create.mockResolvedValue({ id: 'job-1', status: 'pending' } as never);
    (drainBulkQueue as jest.Mock).mockRejectedValueOnce(new Error('Redis unavailable'));

    await expect(submitBulkContentJob(makeRequest())).resolves.toMatchObject({
      id: 'job-1',
      created: true,
    });
  });

  it('exposes the conflict error as a typed route error', () => {
    const error = new BulkContentSubmissionError(409, 'SUBMISSION_ID_CONFLICT', 'conflict');
    expect(error).toBeInstanceOf(Error);
    expect(error.statusCode).toBe(409);
    expect(error.code).toBe('SUBMISSION_ID_CONFLICT');
  });
});
