import { ConvexError, v } from 'convex/values';

import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';

const jobStatus = v.union(
  v.literal('queued'),
  v.literal('claimed'),
  v.literal('preparing'),
  v.literal('running'),
  v.literal('checks_running'),
  v.literal('changes_ready'),
  v.literal('draft_pr_opened'),
  v.literal('failed'),
  v.literal('cancelled'),
  v.literal('timed_out'),
);

const runtime = v.literal('opencode');

const jobType = v.union(
  v.literal('user_change'),
  v.literal('maintenance_review'),
  v.literal('conflict_resolution'),
);

const workspaceStatus = v.union(
  v.literal('not_started'),
  v.literal('starting'),
  v.literal('active'),
  v.literal('idle'),
  v.literal('stopped'),
  v.literal('expired'),
  v.literal('failed'),
);

const agentRuntimeMode = v.union(
  v.literal('opencode_server'),
  v.literal('codex_exec'),
  v.literal('legacy_cli'),
);

const messageRole = v.union(
  v.literal('user'),
  v.literal('assistant'),
  v.literal('system'),
  v.literal('tool'),
);

const messageStatus = v.union(
  v.literal('queued'),
  v.literal('streaming'),
  v.literal('completed'),
  v.literal('failed'),
);

const changeSource = v.union(
  v.literal('user'),
  v.literal('agent'),
  v.literal('command'),
);

const changeType = v.union(
  v.literal('added'),
  v.literal('modified'),
  v.literal('deleted'),
  v.literal('renamed'),
);

const eventLevel = v.union(
  v.literal('debug'),
  v.literal('info'),
  v.literal('warn'),
  v.literal('error'),
);

const eventPhase = v.union(
  v.literal('queued'),
  v.literal('clone'),
  v.literal('plan'),
  v.literal('edit'),
  v.literal('install'),
  v.literal('check'),
  v.literal('test'),
  v.literal('commit'),
  v.literal('push'),
  v.literal('pr'),
  v.literal('cleanup'),
);

const artifactKind = v.union(
  v.literal('plan'),
  v.literal('diff'),
  v.literal('test_output'),
  v.literal('summary'),
  v.literal('error'),
  v.literal('pr_body'),
);

const artifactContentType = v.union(
  v.literal('text/markdown'),
  v.literal('text/plain'),
  v.literal('application/json'),
  v.literal('text/x-diff'),
);

const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));

const interactionKind = v.union(
  v.literal('question'),
  v.literal('permission'),
  v.literal('tool_confirmation'),
);

const interactionStatus = v.union(
  v.literal('pending'),
  v.literal('answered'),
  v.literal('approved'),
  v.literal('rejected'),
  v.literal('expired'),
);

const maintenanceDecision = v.union(
  v.literal('sync'),
  v.literal('ignore'),
  v.literal('open_review_pr'),
  v.literal('manual_review'),
  v.literal('conflict_resolution'),
  v.literal('unknown'),
);

const maintenanceRisk = v.union(
  v.literal('low'),
  v.literal('medium'),
  v.literal('high'),
  v.literal('unknown'),
);

const defaultAgentSettings = {
  enabled: true,
  runtime: 'opencode' as const,
  branchPrefix: 'spoon/agent',
  agentModel: '',
  reasoningEffort: 'medium' as const,
  maxJobDurationMs: 1_800_000,
  maxOutputBytes: 200_000,
  envFilePath: '.env.local',
  materializeEnvFileByDefault: false,
  autoDetectCommands: true,
  allowUserFileEditing: true,
};

const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();

const requireWorkerToken = (workerToken: string) => {
  const expected = getWorkerToken();
  if (!expected) throw new ConvexError('SPOON_WORKER_TOKEN is not configured.');
  if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};

const mergeMessageMetadata = (
  metadata: string | undefined,
  patch: Record<string, unknown>,
) => {
  if (!metadata) return JSON.stringify(patch);
  try {
    return JSON.stringify({ ...(JSON.parse(metadata) as object), ...patch });
  } catch {
    return JSON.stringify({ note: metadata, ...patch });
  }
};

const parseMessageMetadata = (metadata: string | undefined) => {
  if (!metadata) return null;
  try {
    return JSON.parse(metadata) as Record<string, unknown>;
  } catch {
    return null;
  }
};

const slugify = (value: string) =>
  value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
    .slice(0, 36) || 'task';

const shortId = (id: string) => id.replace(/[^a-zA-Z0-9]/g, '').slice(-8);

const buildBranch = (
  requestId: Id<'agentRequests'>,
  prompt: string,
  prefix: string,
  requestedBranchName?: string,
) => {
  const requested = optionalText(requestedBranchName);
  if (requested) return requested.replace(/^\/+|\/+$/g, '');
  return `${prefix.replace(/\/+$/g, '')}/${shortId(requestId)}/${slugify(
    prompt,
  )}`;
};

const normalizeEnvFilePath = (value?: string) => {
  const trimmed = optionalText(value);
  if (!trimmed) return undefined;
  if (trimmed.startsWith('/') || trimmed.includes('..')) {
    throw new ConvexError('Env file path must stay inside the repository.');
  }
  if (!/^\.env(?:[./-][A-Za-z0-9_.-]+)?$/.test(trimmed)) {
    throw new ConvexError('Env file path must be a .env-style path.');
  }
  return trimmed;
};

const normalizeWorkspacePath = (value: string) => {
  const trimmed = optionalText(value);
  if (!trimmed) throw new ConvexError('Workspace path is required.');
  if (
    trimmed.startsWith('/') ||
    trimmed.includes('\0') ||
    trimmed.split('/').includes('..') ||
    trimmed === '.git' ||
    trimmed.startsWith('.git/')
  ) {
    throw new ConvexError('Workspace path must stay inside the repository.');
  }
  return trimmed.replace(/^\.\/+/, '');
};

const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
  values
    ?.map(normalizeWorkspacePath)
    .filter((value, index, all) => all.indexOf(value) === index)
    .slice(0, max);

const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
  ['failed', 'cancelled', 'timed_out'].includes(job.status) ||
  ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');

const TERMINAL_STATUSES = new Set([
  'failed',
  'cancelled',
  'timed_out',
  'draft_pr_opened',
]);

const isTerminalStatus = (status: string) => TERMINAL_STATUSES.has(status);

const isTerminalJob = (job: Doc<'agentJobs'>) =>
  isTerminalStatus(job.status) ||
  ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');

const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
  const messages = await ctx.db
    .query('agentJobMessages')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();
  const events = await ctx.db
    .query('agentJobEvents')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();
  const artifacts = await ctx.db
    .query('agentJobArtifacts')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();
  const changes = await ctx.db
    .query('agentWorkspaceChanges')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();
  const uiStates = await ctx.db
    .query('agentWorkspaceUiStates')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();
  const interactions = await ctx.db
    .query('agentInteractionRequests')
    .withIndex('by_job', (q) => q.eq('jobId', job._id))
    .collect();

  for (const row of [
    ...messages,
    ...events,
    ...artifacts,
    ...changes,
    ...uiStates,
    ...interactions,
  ]) {
    await ctx.db.delete(row._id);
  }
  if (job.threadId) {
    const thread = await ctx.db.get(job.threadId);
    if (thread?.latestAgentJobId === job._id) {
      await ctx.db.patch(job.threadId, {
        latestAgentJobId: undefined,
        updatedAt: Date.now(),
      });
    }
  }
  await ctx.db.delete(job._id);
};

const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
  const settings = await ctx.db
    .query('spoonAgentSettings')
    .withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
    .first();
  return {
    ...defaultAgentSettings,
    defaultBaseBranch: spoon.forkDefaultBranch ?? spoon.upstreamDefaultBranch,
    ...settings,
  };
};

const assertSecretOwnership = async (
  ctx: MutationCtx,
  spoonId: Id<'spoons'>,
  ownerId: Id<'users'>,
  secretIds: Id<'spoonSecrets'>[],
) => {
  for (const secretId of secretIds) {
    const secret = await ctx.db.get(secretId);
    if (secret?.ownerId !== ownerId || secret.spoonId !== spoonId) {
      throw new ConvexError('Selected secrets must belong to this Spoon.');
    }
  }
};

const getJobProfile = async (
  ctx: MutationCtx,
  ownerId: Id<'users'>,
  profileId?: Id<'aiProviderProfiles'>,
) => {
  const profile = profileId
    ? await ctx.db.get(profileId)
    : await getDefaultJobProfile(ctx, ownerId);
  if (profile?.ownerId !== ownerId || !profile.enabled) {
    throw new ConvexError('AI provider profile not found.');
  }
  if (profile.authType !== 'none' && !profile.encryptedSecret) {
    throw new ConvexError('Selected AI provider is missing credentials.');
  }
  return profile;
};

const getDefaultJobProfile = async (ctx: MutationCtx, ownerId: Id<'users'>) => {
  const profiles = await ctx.db
    .query('aiProviderProfiles')
    .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
    .collect();
  const configuredProfiles = profiles.filter(
    (profile) =>
      profile.enabled &&
      (profile.authType === 'none' || Boolean(profile.encryptedSecret)),
  );
  const explicitDefault = configuredProfiles.find(
    (profile) =>
      (profile as Doc<'aiProviderProfiles'> & { isDefault?: boolean })
        .isDefault,
  );
  const profile =
    explicitDefault ??
    (configuredProfiles.length === 1 ? configuredProfiles[0] : undefined);
  if (!profile) {
    throw new ConvexError(
      'Choose a default AI provider before queueing agent work.',
    );
  }
  return profile;
};

const listSpoonSecretIds = async (
  ctx: MutationCtx,
  spoonId: Id<'spoons'>,
  ownerId: Id<'users'>,
) => {
  const secrets = await ctx.db
    .query('spoonSecrets')
    .withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
    .collect();
  return secrets
    .filter((secret) => secret.ownerId === ownerId)
    .map((secret) => secret._id);
};

const insertJob = async (
  ctx: MutationCtx,
  {
    ownerId,
    spoon,
    requestId,
    prompt,
    settings,
    threadId,
    requestedJobType,
    baseBranch,
    requestedBranchName,
    requestedRuntime,
    materializeEnvFile,
    requestedEnvFilePath,
    requestedProfileId,
  }: {
    ownerId: Id<'users'>;
    spoon: Doc<'spoons'>;
    requestId: Id<'agentRequests'>;
    prompt: string;
    settings: Awaited<ReturnType<typeof getAgentSettings>>;
    threadId?: Id<'threads'>;
    requestedJobType:
      | 'user_change'
      | 'maintenance_review'
      | 'conflict_resolution';
    baseBranch?: string;
    requestedBranchName?: string;
    requestedRuntime?: 'opencode';
    materializeEnvFile?: boolean;
    requestedEnvFilePath?: string;
    requestedProfileId?: Id<'aiProviderProfiles'>;
  },
) => {
  if (spoon.provider !== 'github') {
    throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
  }
  if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
    throw new ConvexError(
      'Add fork repository metadata before queueing a job.',
    );
  }
  if (!settings.enabled) {
    throw new ConvexError('Agent jobs are disabled for this Spoon.');
  }
  const aiProviderProfileId =
    requestedProfileId ?? settings.aiProviderProfileId;
  const profile = await getJobProfile(ctx, ownerId, aiProviderProfileId);
  const selectedSecretIds = await listSpoonSecretIds(ctx, spoon._id, ownerId);
  const now = Date.now();
  const resolvedBaseBranch =
    optionalText(baseBranch) ?? settings.defaultBaseBranch;
  const jobRuntime = requestedRuntime ?? 'opencode';
  const shouldMaterializeEnvFile =
    materializeEnvFile ?? settings.materializeEnvFileByDefault;
  const envFilePath =
    normalizeEnvFilePath(requestedEnvFilePath) ??
    normalizeEnvFilePath(
      settings.envFilePath === 'custom'
        ? settings.customEnvFilePath
        : settings.envFilePath,
    );
  const workBranch = buildBranch(
    requestId,
    prompt,
    settings.branchPrefix,
    requestedBranchName,
  );
  const jobId = await ctx.db.insert('agentJobs', {
    spoonId: spoon._id,
    ownerId,
    agentRequestId: requestId,
    threadId,
    jobType: requestedJobType,
    status: 'queued',
    prompt,
    runtime: jobRuntime,
    workspaceStatus: 'not_started',
    baseBranch: resolvedBaseBranch,
    workBranch,
    envFilePath,
    materializeEnvFile: shouldMaterializeEnvFile,
    githubInstallationId: spoon.githubInstallationId,
    forkOwner: spoon.forkOwner,
    forkRepo: spoon.forkRepo,
    forkUrl: spoon.forkUrl,
    upstreamOwner: spoon.upstreamOwner,
    upstreamRepo: spoon.upstreamRepo,
    selectedSecretIds,
    aiProviderProfileId: profile._id,
    model: profile.defaultModel,
    reasoningEffort: profile.reasoningEffort,
    createdAt: now,
    updatedAt: now,
  });
  await ctx.db.patch(requestId, {
    agentJobId: jobId,
    selectedSecretIds,
    baseBranch: resolvedBaseBranch,
    requestedBranchName: optionalText(requestedBranchName),
    status: 'queued',
    updatedAt: now,
  });
  if (threadId) {
    await ctx.db.patch(threadId, {
      latestAgentJobId: jobId,
      relatedAgentRequestId: requestId,
      status: 'queued',
      updatedAt: now,
    });
  }
  await ctx.db.insert('agentJobEvents', {
    jobId,
    spoonId: spoon._id,
    ownerId,
    level: 'info',
    phase: 'queued',
    message: 'OpenCode job queued.',
    createdAt: now,
  });
  await ctx.db.insert('agentJobMessages', {
    jobId,
    spoonId: spoon._id,
    ownerId,
    role: 'user',
    content: prompt,
    status: 'completed',
    createdAt: now,
    updatedAt: now,
  });
  return jobId;
};

export const createFromRequest = mutation({
  args: {
    requestId: v.id('agentRequests'),
    selectedSecretIds: v.array(v.id('spoonSecrets')),
    baseBranch: v.optional(v.string()),
    requestedBranchName: v.optional(v.string()),
    runtime: v.optional(runtime),
    materializeEnvFile: v.optional(v.boolean()),
    envFilePath: v.optional(v.string()),
    aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
  },
  handler: async (ctx, args) => {
    const ownerId = await getRequiredUserId(ctx);
    const request = await ctx.db.get(args.requestId);
    if (request?.ownerId !== ownerId) {
      throw new ConvexError('Agent request not found.');
    }
    if (request.agentJobId) {
      throw new ConvexError('This request already has an agent job.');
    }
    const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
    const settings = await getAgentSettings(ctx, spoon);
    await assertSecretOwnership(
      ctx,
      spoon._id,
      ownerId,
      args.selectedSecretIds,
    );
    return await insertJob(ctx, {
      ownerId,
      spoon,
      requestId: request._id,
      prompt: request.prompt,
      settings,
      requestedJobType: 'user_change',
      baseBranch: args.baseBranch,
      requestedBranchName: args.requestedBranchName,
      requestedRuntime: args.runtime,
      materializeEnvFile: args.materializeEnvFile,
      requestedEnvFilePath: args.envFilePath,
      requestedProfileId: args.aiProviderProfileId,
    });
  },
});

export const createForThread = mutation({
  args: {
    threadId: v.id('threads'),
    jobType,
    baseBranch: v.optional(v.string()),
    requestedBranchName: v.optional(v.string()),
    materializeEnvFile: v.optional(v.boolean()),
    envFilePath: v.optional(v.string()),
    aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
  },
  handler: async (ctx, args) => {
    const ownerId = await getRequiredUserId(ctx);
    const thread = await ctx.db.get(args.threadId);
    if (thread?.ownerId !== ownerId || !thread.spoonId) {
      throw new ConvexError('Thread not found.');
    }
    if (thread.latestAgentJobId) {
      const latestJob = await ctx.db.get(thread.latestAgentJobId);
      if (latestJob && !isTerminalJob(latestJob)) {
        throw new ConvexError('This thread already has an active agent job.');
      }
    }
    const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
    const promptMessage = await ctx.db
      .query('threadMessages')
      .withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
      .order('desc')
      .first();
    const prompt =
      promptMessage?.content ??
      thread.summary ??
      `Work on thread: ${thread.title}`;
    const now = Date.now();
    const requestId = await ctx.db.insert('agentRequests', {
      spoonId: spoon._id,
      ownerId,
      prompt,
      status: 'queued',
      requestType:
        args.jobType === 'user_change'
          ? 'future_code_change'
          : 'upstream_review',
      priority: thread.priority,
      source: thread.source === 'user_request' ? 'user' : 'system',
      targetBranch: optionalText(args.baseBranch),
      createdAt: now,
      updatedAt: now,
    });
    const settings = await getAgentSettings(ctx, spoon);
    const jobId = await insertJob(ctx, {
      ownerId,
      spoon,
      requestId,
      prompt,
      settings,
      threadId: args.threadId,
      requestedJobType: args.jobType,
      baseBranch: args.baseBranch,
      requestedBranchName: args.requestedBranchName,
      materializeEnvFile: args.materializeEnvFile,
      requestedEnvFilePath: args.envFilePath,
      requestedProfileId: args.aiProviderProfileId,
    });
    return jobId;
  },
});

export const createForThreadInternal = internalMutation({
  args: {
    threadId: v.id('threads'),
    ownerId: v.id('users'),
    jobType,
    baseBranch: v.optional(v.string()),
    requestedBranchName: v.optional(v.string()),
    materializeEnvFile: v.optional(v.boolean()),
    envFilePath: v.optional(v.string()),
    aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
  },
  handler: async (ctx, args) => {
    const thread = await ctx.db.get(args.threadId);
    if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
      throw new ConvexError('Thread not found.');
    }
    if (thread.latestAgentJobId) {
      const latestJob = await ctx.db.get(thread.latestAgentJobId);
      if (latestJob && !isTerminalJob(latestJob)) {
        return thread.latestAgentJobId;
      }
    }
    const spoon = await ctx.db.get(thread.spoonId);
    if (spoon?.ownerId !== args.ownerId) {
      throw new ConvexError('Spoon not found.');
    }
    const promptMessage = await ctx.db
      .query('threadMessages')
      .withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
      .order('desc')
      .first();
    const prompt =
      promptMessage?.content ??
      thread.summary ??
      `Review maintenance thread: ${thread.title}`;
    const now = Date.now();
    const requestId = await ctx.db.insert('agentRequests', {
      spoonId: spoon._id,
      ownerId: args.ownerId,
      prompt,
      status: 'queued',
      requestType:
        args.jobType === 'user_change'
          ? 'future_code_change'
          : 'upstream_review',
      priority: thread.priority,
      source: thread.source === 'user_request' ? 'user' : 'system',
      targetBranch: optionalText(args.baseBranch),
      createdAt: now,
      updatedAt: now,
    });
    const settings = await getAgentSettings(ctx, spoon);
    return await insertJob(ctx, {
      ownerId: args.ownerId,
      spoon,
      requestId,
      prompt,
      settings,
      threadId: args.threadId,
      requestedJobType: args.jobType,
      baseBranch: args.baseBranch,
      requestedBranchName: args.requestedBranchName,
      materializeEnvFile: args.materializeEnvFile,
      requestedEnvFilePath: args.envFilePath,
      requestedProfileId: args.aiProviderProfileId,
    });
  },
});

export const listForSpoon = query({
  args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
  handler: async (ctx, { spoonId, limit }) => {
    const ownerId = await getRequiredUserId(ctx);
    await getOwnedSpoon(ctx, spoonId, ownerId);
    return await ctx.db
      .query('agentJobs')
      .withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
      .order('desc')
      .take(limit ?? 25);
  },
});

export const get = query({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return job;
  },
});

export const assertOwned = query({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return { job, ownerId };
  },
});

export const listMessages = query({
  args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
  handler: async (ctx, { jobId, limit }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return await ctx.db
      .query('agentJobMessages')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .order('asc')
      .take(limit ?? 200);
  },
});

export const getWorkspaceUiState = query({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    const state = await ctx.db
      .query('agentWorkspaceUiStates')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .first();
    return (
      state ?? {
        jobId,
        spoonId: job.spoonId,
        ownerId,
        openFilePaths: [],
        activeFilePath: undefined,
        vimEnabled: false,
        expandedDirectoryPaths: [],
        agentThreadWidth: 420,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      }
    );
  },
});

export const patchWorkspaceUiState = mutation({
  args: {
    jobId: v.id('agentJobs'),
    openFilePaths: v.optional(v.array(v.string())),
    activeFilePath: v.optional(v.string()),
    vimEnabled: v.optional(v.boolean()),
    expandedDirectoryPaths: v.optional(v.array(v.string())),
    agentThreadWidth: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(args.jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    const now = Date.now();
    const existing = await ctx.db
      .query('agentWorkspaceUiStates')
      .withIndex('by_job', (q) => q.eq('jobId', args.jobId))
      .first();
    const patch = {
      ...(args.openFilePaths !== undefined
        ? { openFilePaths: normalizeWorkspacePaths(args.openFilePaths, 40) }
        : {}),
      ...(args.activeFilePath !== undefined
        ? {
            activeFilePath: args.activeFilePath
              ? normalizeWorkspacePath(args.activeFilePath)
              : undefined,
          }
        : {}),
      ...(args.vimEnabled !== undefined ? { vimEnabled: args.vimEnabled } : {}),
      ...(args.expandedDirectoryPaths !== undefined
        ? {
            expandedDirectoryPaths: normalizeWorkspacePaths(
              args.expandedDirectoryPaths,
              500,
            ),
          }
        : {}),
      ...(args.agentThreadWidth !== undefined
        ? {
            agentThreadWidth: Math.min(
              Math.max(Math.round(args.agentThreadWidth), 320),
              720,
            ),
          }
        : {}),
      updatedAt: now,
    };
    if (existing) {
      await ctx.db.patch(existing._id, patch);
      return existing._id;
    }
    return await ctx.db.insert('agentWorkspaceUiStates', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId,
      openFilePaths: patch.openFilePaths ?? [],
      activeFilePath: patch.activeFilePath,
      vimEnabled: patch.vimEnabled ?? false,
      expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
      agentThreadWidth: patch.agentThreadWidth ?? 420,
      createdAt: now,
      updatedAt: now,
    });
  },
});

export const listInteractionRequests = query({
  args: {
    jobId: v.id('agentJobs'),
    status: v.optional(v.union(v.literal('pending'), v.literal('all'))),
  },
  handler: async (ctx, { jobId, status }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    if (status === 'pending') {
      return await ctx.db
        .query('agentInteractionRequests')
        .withIndex('by_job_status', (q) =>
          q.eq('jobId', jobId).eq('status', 'pending'),
        )
        .order('asc')
        .collect();
    }
    return await ctx.db
      .query('agentInteractionRequests')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .order('asc')
      .collect();
  },
});

export const appendUserMessage = mutation({
  args: { jobId: v.id('agentJobs'), content: v.string() },
  handler: async (ctx, { jobId, content }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    const trimmed = optionalText(content);
    if (!trimmed) throw new ConvexError('Message is required.');
    const now = Date.now();
    return await ctx.db.insert('agentJobMessages', {
      jobId,
      spoonId: job.spoonId,
      ownerId,
      role: 'user',
      content: trimmed,
      status: 'queued',
      createdAt: now,
      updatedAt: now,
    });
  },
});

export const listWorkspaceChanges = query({
  args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
  handler: async (ctx, { jobId, limit }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return await ctx.db
      .query('agentWorkspaceChanges')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .order('desc')
      .take(limit ?? 100);
  },
});

export const listEvents = query({
  args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
  handler: async (ctx, { jobId, limit }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return await ctx.db
      .query('agentJobEvents')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .order('asc')
      .take(limit ?? 200);
  },
});

export const listArtifacts = query({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    return await ctx.db
      .query('agentJobArtifacts')
      .withIndex('by_job', (q) => q.eq('jobId', jobId))
      .order('asc')
      .collect();
  },
});

export const cancel = mutation({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    if (
      !['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
        job.status,
      )
    ) {
      throw new ConvexError('This job cannot be cancelled.');
    }
    const now = Date.now();
    await ctx.db.patch(jobId, {
      status: 'cancelled',
      completedAt: now,
      updatedAt: now,
    });
    await ctx.db.patch(job.agentRequestId, {
      status: 'cancelled',
      updatedAt: now,
    });
    await ctx.db.insert('agentJobEvents', {
      jobId,
      spoonId: job.spoonId,
      ownerId,
      level: 'warn',
      phase: 'cleanup',
      message: 'Agent job cancelled by user.',
      createdAt: now,
    });
    return { success: true };
  },
});

export const deleteWorkspace = mutation({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    if (!isDeletableWorkspace(job)) {
      throw new ConvexError(
        'Only stopped, cancelled, failed, or expired workspaces can be deleted.',
      );
    }
    await deleteWorkspaceRows(ctx, job);
    return { success: true };
  },
});

export const markWorkspaceLost = mutation({
  args: { jobId: v.id('agentJobs') },
  handler: async (ctx, { jobId }) => {
    const ownerId = await getRequiredUserId(ctx);
    const job = await ctx.db.get(jobId);
    if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
    const now = Date.now();
    await ctx.db.patch(jobId, {
      status: 'failed',
      workspaceStatus: 'failed',
      error: 'Workspace is not active on the configured worker.',
      completedAt: job.completedAt ?? now,
      updatedAt: now,
    });
    if (job.threadId) {
      await ctx.db.patch(job.threadId, {
        status: 'failed',
        updatedAt: now,
      });
    }
    return { success: true };
  },
});

export const countOldWorkspaces = query({
  args: { olderThanDays: v.optional(v.number()) },
  handler: async (ctx, { olderThanDays }) => {
    const ownerId = await getRequiredUserId(ctx);
    const cutoff =
      olderThanDays && olderThanDays > 0
        ? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
        : Number.POSITIVE_INFINITY;
    const jobs = await ctx.db
      .query('agentJobs')
      .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
      .collect();
    return jobs.filter(
      (job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
    ).length;
  },
});

export const deleteOldWorkspaces = mutation({
  args: {
    olderThanDays: v.optional(v.number()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, { olderThanDays, limit }) => {
    const ownerId = await getRequiredUserId(ctx);
    const cutoff =
      olderThanDays && olderThanDays > 0
        ? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
        : Number.POSITIVE_INFINITY;
    const max = Math.min(Math.max(limit ?? 50, 1), 100);
    const jobs = await ctx.db
      .query('agentJobs')
      .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
      .collect();
    const deletable = jobs
      .filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
      .sort((a, b) => a.updatedAt - b.updatedAt)
      .slice(0, max);
    for (const job of deletable) {
      await deleteWorkspaceRows(ctx, job);
    }
    return { deleted: deletable.length };
  },
});

export const claimNextInternal = internalMutation({
  args: { workerId: v.string() },
  handler: async (ctx, { workerId }) => {
    const job = await ctx.db
      .query('agentJobs')
      .withIndex('by_claim', (q) => q.eq('status', 'queued'))
      .order('asc')
      .first();
    if (!job) return null;
    const spoon = await ctx.db.get(job.spoonId);
    const aiSettings = await ctx.db
      .query('userAiSettings')
      .withIndex('by_user_provider', (q) =>
        q.eq('userId', job.ownerId).eq('provider', 'openai'),
      )
      .first();
    const agentSettings = await ctx.db
      .query('spoonAgentSettings')
      .withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
      .first();
    const aiProviderProfile = job.aiProviderProfileId
      ? await ctx.db.get(job.aiProviderProfileId)
      : null;
    const secrets = [];
    for (const secretId of job.selectedSecretIds) {
      const secret = await ctx.db.get(secretId);
      if (secret?.ownerId === job.ownerId && secret.spoonId === job.spoonId) {
        secrets.push(secret);
      }
    }
    const now = Date.now();
    await ctx.db.patch(job._id, {
      status: 'claimed',
      claimedBy: workerId,
      claimedAt: now,
      updatedAt: now,
    });
    await ctx.db.patch(job.agentRequestId, {
      status: 'running',
      updatedAt: now,
    });
    await ctx.db.insert('agentJobEvents', {
      jobId: job._id,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      level: 'info',
      phase: 'queued',
      message: `Claimed by ${workerId}.`,
      createdAt: now,
    });
    return {
      job: { ...job, status: 'claimed' as const, claimedBy: workerId },
      spoon,
      aiSettings,
      aiProviderProfile:
        aiProviderProfile?.ownerId === job.ownerId ? aiProviderProfile : null,
      agentSettings,
      secrets,
    };
  },
});

export const updateStatus = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    status: jobStatus,
    error: v.optional(v.string()),
    summary: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    if (isTerminalStatus(job.status)) {
      return { success: true, ignored: true as const };
    }
    const now = Date.now();
    const patch: Partial<Doc<'agentJobs'>> = {
      status: args.status,
      updatedAt: now,
    };
    if (args.status === 'running' && !job.startedAt) patch.startedAt = now;
    if (
      ['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
        args.status,
      )
    ) {
      patch.completedAt = now;
    }
    if (args.error !== undefined) patch.error = args.error;
    if (args.summary !== undefined) patch.summary = args.summary;
    await ctx.db.patch(args.jobId, patch);
    if (job.threadId) {
      const threadStatus =
        args.status === 'queued' || args.status === 'claimed'
          ? 'queued'
          : args.status === 'running' || args.status === 'checks_running'
            ? 'running'
            : args.status === 'changes_ready'
              ? 'changes_ready'
              : args.status === 'draft_pr_opened'
                ? 'draft_pr_opened'
                : args.status === 'failed' || args.status === 'timed_out'
                  ? 'failed'
                  : args.status === 'cancelled'
                    ? 'cancelled'
                    : undefined;
      if (threadStatus) {
        const threadPatch: Partial<Doc<'threads'>> = {
          status: threadStatus,
          summary: args.summary ?? job.summary,
          updatedAt: now,
        };
        if (
          ['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
            args.status,
          )
        ) {
          threadPatch.resolvedAt = now;
        }
        await ctx.db.patch(job.threadId, threadPatch);
      }
    }
    return { success: true };
  },
});

export const markWorkspaceActive = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    opencodeSessionId: v.optional(v.string()),
    containerId: v.optional(v.string()),
    workspaceUrl: v.optional(v.string()),
    workspaceExpiresAt: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const now = Date.now();
    await ctx.db.patch(args.jobId, {
      workspaceStatus: 'active',
      opencodeSessionId: optionalText(args.opencodeSessionId),
      containerId: optionalText(args.containerId),
      workspaceUrl: optionalText(args.workspaceUrl),
      workspaceExpiresAt: args.workspaceExpiresAt,
      lastHeartbeatAt: now,
      updatedAt: now,
    });
    return { success: true };
  },
});

export const setRuntimeSession = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    agentRuntimeMode,
    opencodeSessionId: v.optional(v.string()),
    codexSessionId: v.optional(v.string()),
    containerId: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    await ctx.db.patch(args.jobId, {
      agentRuntimeMode: args.agentRuntimeMode,
      opencodeSessionId: optionalText(args.opencodeSessionId),
      codexSessionId: optionalText(args.codexSessionId),
      containerId: optionalText(args.containerId),
      updatedAt: Date.now(),
    });
    return { success: true };
  },
});

export const setCodexSessionId = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    codexSessionId: v.string(),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    await ctx.db.patch(args.jobId, {
      codexSessionId: optionalText(args.codexSessionId),
      agentRuntimeMode: 'codex_exec',
      updatedAt: Date.now(),
    });
    return { success: true };
  },
});

export const createInteractionRequest = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    runtime: interactionRuntime,
    externalRequestId: v.string(),
    kind: interactionKind,
    title: v.string(),
    body: v.string(),
    options: v.optional(v.array(v.string())),
    metadata: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const now = Date.now();
    const existing = (
      await ctx.db
        .query('agentInteractionRequests')
        .withIndex('by_job', (q) => q.eq('jobId', args.jobId))
        .collect()
    ).find((request) => request.externalRequestId === args.externalRequestId);
    const record = {
      runtime: args.runtime,
      externalRequestId: args.externalRequestId,
      kind: args.kind,
      title: args.title,
      body: args.body,
      options: args.options,
      metadata: args.metadata,
      status: 'pending' as const,
      updatedAt: now,
    };
    if (existing) {
      await ctx.db.patch(existing._id, record);
      return existing._id;
    }
    const requestId = await ctx.db.insert('agentInteractionRequests', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      ...record,
      createdAt: now,
    });
    await ctx.db.patch(args.jobId, {
      status: 'running',
      updatedAt: now,
    });
    return requestId;
  },
});

export const patchInteractionRequest = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    interactionId: v.id('agentInteractionRequests'),
    status: interactionStatus,
    response: v.optional(v.string()),
    metadata: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const interaction = await ctx.db.get(args.interactionId);
    if (!interaction) throw new ConvexError('Interaction request not found.');
    const job = await ctx.db.get(interaction.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    await ctx.db.patch(args.interactionId, {
      status: args.status,
      response: optionalText(args.response),
      metadata: args.metadata,
      updatedAt: Date.now(),
    });
    return { success: true };
  },
});

export const markWorkspaceStopped = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    workspaceStatus: v.optional(workspaceStatus),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const now = Date.now();
    await ctx.db.patch(args.jobId, {
      workspaceStatus: args.workspaceStatus ?? 'stopped',
      updatedAt: now,
    });
    return { success: true };
  },
});

export const heartbeatWorkspace = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    await ctx.db.patch(args.jobId, {
      workspaceStatus: 'active',
      lastHeartbeatAt: Date.now(),
      updatedAt: Date.now(),
    });
    return { success: true };
  },
});

export const completeWithDraftPr = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    commitSha: v.string(),
    pullRequestUrl: v.string(),
    pullRequestNumber: v.number(),
    summary: v.string(),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const now = Date.now();
    await ctx.db.patch(args.jobId, {
      status: 'draft_pr_opened',
      commitSha: args.commitSha,
      pullRequestUrl: args.pullRequestUrl,
      pullRequestNumber: args.pullRequestNumber,
      summary: args.summary,
      completedAt: now,
      updatedAt: now,
    });
    await ctx.db.patch(job.agentRequestId, {
      status: 'merge_request_opened',
      mergeRequestUrl: args.pullRequestUrl,
      summary: args.summary,
      updatedAt: now,
    });
    if (job.threadId) {
      await ctx.db.patch(job.threadId, {
        status: 'draft_pr_opened',
        summary: args.summary,
        updatedAt: now,
        resolvedAt: now,
      });
    }
    return { success: true };
  },
});

export const applyMaintenanceDecision = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    decision: maintenanceDecision,
    risk: maintenanceRisk,
    summary: v.string(),
    ignoredCommitShas: v.array(v.string()),
    ignoredReason: v.optional(v.string()),
    recommendedAction: v.string(),
    requiresUserApproval: v.boolean(),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    if (!job.threadId) return { success: true };
    const now = Date.now();
    const outcome =
      args.decision === 'sync'
        ? 'sync_recommended'
        : args.decision === 'ignore'
          ? 'ignored'
          : args.decision === 'open_review_pr'
            ? 'review_pr_recommended'
            : args.decision === 'conflict_resolution'
              ? 'conflict_resolution_required'
              : args.decision === 'manual_review'
                ? 'manual_review_required'
                : 'unknown';
    const status =
      args.decision === 'ignore'
        ? 'ignored'
        : args.decision === 'sync' && !args.requiresUserApproval
          ? 'resolved'
          : 'waiting_for_user';
    const threadPatch: Partial<Doc<'threads'>> = {
      status,
      maintenanceOutcome: outcome,
      summary: args.summary,
      ignoredCommitShas: args.ignoredCommitShas,
      ignoredReason: args.ignoredReason,
      updatedAt: now,
    };
    if (status === 'ignored' || status === 'resolved') {
      threadPatch.resolvedAt = now;
    }
    await ctx.db.patch(job.threadId, threadPatch);
    if (args.decision === 'ignore' && args.ignoredCommitShas.length > 0) {
      const thread = await ctx.db.get(job.threadId);
      await ctx.db.insert('ignoredUpstreamChanges', {
        spoonId: job.spoonId,
        ownerId: job.ownerId,
        upstreamFrom: thread?.upstreamFrom,
        upstreamTo: thread?.upstreamTo ?? job.upstreamRepo,
        commitShas: args.ignoredCommitShas,
        reason: args.ignoredReason ?? args.summary,
        decidedBy: 'agent',
        threadId: job.threadId,
        createdAt: now,
      });
    }
    await ctx.db.insert('threadMessages', {
      threadId: job.threadId,
      ownerId: job.ownerId,
      spoonId: job.spoonId,
      role: 'assistant',
      content: args.summary,
      status: 'completed',
      metadata: JSON.stringify({
        decision: args.decision,
        risk: args.risk,
        recommendedAction: args.recommendedAction,
      }),
      createdAt: now,
      updatedAt: now,
    });
    return { success: true };
  },
});

export const appendEvent = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    level: eventLevel,
    phase: eventPhase,
    message: v.string(),
    metadata: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    return await ctx.db.insert('agentJobEvents', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      level: args.level,
      phase: args.phase,
      message: args.message,
      metadata: args.metadata,
      createdAt: Date.now(),
    });
  },
});

export const appendMessage = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    role: messageRole,
    content: v.string(),
    status: messageStatus,
    metadata: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const now = Date.now();
    const messageId = await ctx.db.insert('agentJobMessages', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      role: args.role,
      content: args.content,
      status: args.status,
      metadata: args.metadata,
      createdAt: now,
      updatedAt: now,
    });
    if (job.threadId) {
      await ctx.db.insert('threadMessages', {
        threadId: job.threadId,
        spoonId: job.spoonId,
        ownerId: job.ownerId,
        role: args.role,
        content: args.content,
        status: args.status,
        metadata: mergeMessageMetadata(args.metadata, {
          agentJobMessageId: messageId,
        }),
        createdAt: now,
        updatedAt: now,
      });
    }
    return messageId;
  },
});

export const updateMessage = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    messageId: v.id('agentJobMessages'),
    content: v.optional(v.string()),
    status: v.optional(messageStatus),
    metadata: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const message = await ctx.db.get(args.messageId);
    if (!message) throw new ConvexError('Agent message not found.');
    const job = await ctx.db.get(message.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    const patch: Partial<Doc<'agentJobMessages'>> = {
      updatedAt: Date.now(),
    };
    if (args.content !== undefined) patch.content = args.content;
    if (args.status !== undefined) patch.status = args.status;
    if (args.metadata !== undefined) patch.metadata = args.metadata;
    await ctx.db.patch(args.messageId, patch);
    const threadId = job.threadId;
    if (threadId) {
      const threadMessages = await ctx.db
        .query('threadMessages')
        .withIndex('by_thread', (q) => q.eq('threadId', threadId))
        .order('desc')
        .take(300);
      const mirrored = threadMessages.find(
        (threadMessage) =>
          parseMessageMetadata(threadMessage.metadata)?.agentJobMessageId ===
          args.messageId,
      );
      if (mirrored) {
        const threadPatch: Partial<Doc<'threadMessages'>> = {
          updatedAt: patch.updatedAt,
        };
        if (args.content !== undefined) threadPatch.content = args.content;
        if (args.status !== undefined) threadPatch.status = args.status;
        if (args.metadata !== undefined) {
          threadPatch.metadata = mergeMessageMetadata(args.metadata, {
            agentJobMessageId: args.messageId,
          });
        }
        await ctx.db.patch(mirrored._id, threadPatch);
      }
    }
    return { success: true };
  },
});

export const recordWorkspaceChange = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    path: v.string(),
    source: changeSource,
    changeType,
    diff: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    return await ctx.db.insert('agentWorkspaceChanges', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      path: args.path,
      source: args.source,
      changeType: args.changeType,
      diff: args.diff,
      createdAt: Date.now(),
    });
  },
});

export const addArtifact = mutation({
  args: {
    workerToken: v.string(),
    workerId: v.string(),
    jobId: v.id('agentJobs'),
    kind: artifactKind,
    title: v.string(),
    content: v.string(),
    contentType: artifactContentType,
  },
  handler: async (ctx, args) => {
    requireWorkerToken(args.workerToken);
    const job = await ctx.db.get(args.jobId);
    if (job?.claimedBy !== args.workerId) {
      throw new ConvexError('Agent job not claimed by this worker.');
    }
    return await ctx.db.insert('agentJobArtifacts', {
      jobId: args.jobId,
      spoonId: job.spoonId,
      ownerId: job.ownerId,
      kind: args.kind,
      title: args.title,
      content: args.content,
      contentType: args.contentType,
      createdAt: Date.now(),
    });
  },
});
