import { convexTest } from 'convex-test';
import { beforeAll, describe, expect, test } from 'vitest';

import type { Id } from '../../convex/_generated/dataModel.js';
import { api, internal } from '../../convex/_generated/api.js';
import schema from '../../convex/schema';
import { encryptSecret } from '../../convex/secretCrypto';

const modules = import.meta.glob('../../convex/**/*.*s');

type SeedOptions = {
  profile?:
    | 'valid'
    | 'disabled'
    | 'missing-credentials'
    | 'encrypted-valid'
    | 'encrypted-malformed'
    | 'none';
  settingsEnabled?: boolean;
  runtime?: 'opencode' | 'openai_direct';
  secret?: 'valid' | 'foreign' | 'malformed';
};

const seedQueuedJob = async (
  t: ReturnType<typeof convexTest>,
  options: SeedOptions = {},
) =>
  await t.mutation(async (ctx) => {
    const now = Date.now();
    const ownerId = await ctx.db.insert('users', {
      email: 'owner@example.com',
      name: 'Owner',
    });
    const spoonId = await ctx.db.insert('spoons', {
      ownerId,
      name: 'Editor Spoon',
      provider: 'github',
      upstreamOwner: 'upstream',
      upstreamRepo: 'editor',
      upstreamDefaultBranch: 'main',
      upstreamUrl: 'https://github.com/upstream/editor',
      forkOwner: 'team',
      forkRepo: 'editor-spoon',
      forkUrl: 'https://github.com/team/editor-spoon',
      visibility: 'private',
      maintenanceMode: 'watch',
      syncCadence: 'daily',
      productionRefStrategy: 'default_branch',
      status: 'active',
      createdAt: now,
      updatedAt: now,
    });
    await ctx.db.insert('spoonAgentSettings', {
      spoonId,
      ownerId,
      enabled: options.settingsEnabled ?? true,
      runtime: 'opencode',
      defaultBaseBranch: 'main',
      branchPrefix: 'spoon/',
      agentModel: 'gpt-5.1-codex',
      reasoningEffort: 'medium',
      maxJobDurationMs: 60_000,
      maxOutputBytes: 1_000_000,
      createdAt: now,
      updatedAt: now,
    });
    const profileKind = options.profile ?? 'none';
    const aiProviderProfileId =
      profileKind === 'none'
        ? undefined
        : await ctx.db.insert('aiProviderProfiles', {
            ownerId,
            name: 'Test provider',
            provider: 'openai',
            authType:
              profileKind === 'missing-credentials' ||
              profileKind.startsWith('encrypted-')
                ? 'api_key'
                : 'none',
            encryptedSecret:
              profileKind === 'encrypted-valid'
                ? encryptSecret('provider-secret')
                : profileKind === 'encrypted-malformed'
                  ? 'malformed'
                  : undefined,
            defaultModel: 'gpt-5.1-codex',
            modelOptions: ['gpt-5.1-codex'],
            reasoningEffort: 'medium',
            enabled: profileKind !== 'disabled',
            createdAt: now,
            updatedAt: now,
          });
    const selectedSecretIds: Id<'spoonSecrets'>[] = [];
    if (options.secret) {
      const secretOwnerId =
        options.secret === 'foreign'
          ? await ctx.db.insert('users', {
              email: 'other@example.com',
              name: 'Other',
            })
          : ownerId;
      selectedSecretIds.push(
        await ctx.db.insert('spoonSecrets', {
          spoonId,
          ownerId: secretOwnerId,
          name: 'TOKEN',
          encryptedValue:
            options.secret === 'malformed'
              ? 'malformed'
              : encryptSecret('secret-value'),
          createdAt: now,
          updatedAt: now,
        }),
      );
    }
    const requestId = await ctx.db.insert('agentRequests', {
      spoonId,
      ownerId,
      prompt: 'Fix the editor',
      status: 'queued',
      createdAt: now,
      updatedAt: now,
    });
    const threadId = await ctx.db.insert('threads', {
      ownerId,
      spoonId,
      title: 'Fix the editor',
      source: 'user_request',
      status: 'queued',
      priority: 'normal',
      relatedAgentRequestId: requestId,
      createdAt: now,
      updatedAt: now,
    });
    const jobId = await ctx.db.insert('agentJobs', {
      spoonId,
      ownerId,
      agentRequestId: requestId,
      threadId,
      status: 'queued',
      prompt: 'Fix the editor',
      runtime: options.runtime ?? 'opencode',
      workspaceStatus: 'not_started',
      baseBranch: 'main',
      workBranch: 'spoon/fix-editor',
      forkOwner: 'team',
      forkRepo: 'editor-spoon',
      forkUrl: 'https://github.com/team/editor-spoon',
      upstreamOwner: 'upstream',
      upstreamRepo: 'editor',
      selectedSecretIds,
      aiProviderProfileId,
      model: 'gpt-5.1-codex',
      reasoningEffort: 'medium',
      createdAt: now,
      updatedAt: now,
    });
    await ctx.db.patch(requestId, { agentJobId: jobId });
    await ctx.db.patch(threadId, { latestAgentJobId: jobId });
    return { jobId, requestId, spoonId, threadId };
  });

const expectFailedClaimState = async (
  t: ReturnType<typeof convexTest>,
  jobId: Id<'agentJobs'>,
  requestId: Id<'agentRequests'>,
  threadId: Id<'threads'>,
) => {
  const state = await t.run(async (ctx) => ({
    job: await ctx.db.get(jobId),
    request: await ctx.db.get(requestId),
    thread: await ctx.db.get(threadId),
    events: (await ctx.db.query('agentJobEvents').collect()).filter(
      (event) => event.jobId === jobId,
    ),
  }));
  expect(state.job?.status).toBe('failed');
  expect(state.job?.error).toBeTruthy();
  expect(state.request?.status).toBe('failed');
  expect(state.thread?.status).toBe('failed');
  expect(state.thread?.resolvedAt).toBeTypeOf('number');
  expect(state.events).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        level: 'error',
        phase: 'queued',
        message: expect.stringContaining('Job could not be claimed:'),
      }),
    ]),
  );
};

const expectFailedClaim = async (
  t: ReturnType<typeof convexTest>,
  jobId: Id<'agentJobs'>,
  requestId: Id<'agentRequests'>,
  threadId: Id<'threads'>,
) => {
  const result = await t.mutation(internal.agentJobs.claimNextInternal, {
    workerId: 'w1',
  });
  expect(result).toBeNull();
  await expectFailedClaimState(t, jobId, requestId, threadId);
};

beforeAll(() => {
  process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
  process.env.SPOON_ENCRYPTION_KEY = 'test-encryption-key';
});

describe('claimNextInternal validation', () => {
  test('fails a queued job when its owner has no configured profile', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t);

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('claims a fully valid queued job', async () => {
    const t = convexTest(schema, modules);
    const { jobId } = await seedQueuedJob(t, { profile: 'valid' });

    const result = await t.mutation(internal.agentJobs.claimNextInternal, {
      workerId: 'w1',
    });
    const job = await t.run(async (ctx) => await ctx.db.get(jobId));

    expect(result).not.toBeNull();
    expect(result?.aiProviderProfile?.enabled).toBe(true);
    expect(job?.status).toBe('claimed');
  });

  test('fails a queued job when its profile is disabled', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'disabled',
    });

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a queued job when its profile is missing credentials', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'missing-credentials',
    });

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a queued job when Spoon agent jobs are disabled', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'valid',
      settingsEnabled: false,
    });

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a queued job with an unsupported legacy runtime', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'valid',
      runtime: 'openai_direct',
    });

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a queued job with a secret owned by another user', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'valid',
      secret: 'foreign',
    });

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a queued job whose Spoon was deleted', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, spoonId, threadId } = await seedQueuedJob(t, {
      profile: 'valid',
    });
    await t.mutation(async (ctx) => await ctx.db.delete(spoonId));

    await expectFailedClaim(t, jobId, requestId, threadId);
  });

  test('fails a claim when provider credential decryption fails', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'encrypted-malformed',
    });

    const result = await t.action(api.agentJobsNode.claimNextForWorker, {
      workerId: 'w1',
      workerToken: 'test-worker-token',
    });

    expect(result).toBeNull();
    await expectFailedClaimState(t, jobId, requestId, threadId);
  });

  test('fails a claim when selected secret decryption fails', async () => {
    const t = convexTest(schema, modules);
    const { jobId, requestId, threadId } = await seedQueuedJob(t, {
      profile: 'valid',
      secret: 'malformed',
    });

    const result = await t.action(api.agentJobsNode.claimNextForWorker, {
      workerId: 'w1',
      workerToken: 'test-worker-token',
    });

    expect(result).toBeNull();
    await expectFailedClaimState(t, jobId, requestId, threadId);
  });

  test('returns decrypted provider credentials and selected secrets', async () => {
    const t = convexTest(schema, modules);
    const { jobId } = await seedQueuedJob(t, {
      profile: 'encrypted-valid',
      secret: 'valid',
    });

    const result = await t.action(api.agentJobsNode.claimNextForWorker, {
      workerId: 'w1',
      workerToken: 'test-worker-token',
    });
    const job = await t.run(async (ctx) => await ctx.db.get(jobId));

    expect(result?.aiProviderProfile?.secret).toBe('provider-secret');
    expect(result?.secrets).toEqual([
      { name: 'TOKEN', value: 'secret-value' },
    ]);
    expect(job?.status).toBe('claimed');
  });
});
