import { access, readFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';

import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';

import { runOpenAiEdit } from './agent';
import { env } from './env';
import {
  cloneRepository,
  commitAndPush,
  getStatus,
  getWorktreeDiff,
  run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import { createRedactor, truncate } from './redact';
import { runInJobContainer } from './runtime/docker';

type Claim = {
  job: {
    _id: Id<'agentJobs'>;
    prompt: string;
    baseBranch: string;
    workBranch: string;
    forkOwner: string;
    forkRepo: string;
    upstreamOwner: string;
    upstreamRepo: string;
  };
  spoon: { name: string };
  openai: {
    apiKey: string;
    model: string;
    reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
  };
  github: { installationId?: string };
  agentSettings?: {
    installCommand?: string;
    checkCommand?: string;
    testCommand?: string;
  } | null;
  secrets: { name: string; value: string }[];
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const client = new ConvexHttpClient(env.convexUrl);

const appendEvent = async (
  jobId: Id<'agentJobs'>,
  level: 'debug' | 'info' | 'warn' | 'error',
  phase:
    | 'queued'
    | 'clone'
    | 'plan'
    | 'edit'
    | 'install'
    | 'check'
    | 'test'
    | 'commit'
    | 'push'
    | 'pr'
    | 'cleanup',
  message: string,
  metadata?: string,
) =>
  await client.mutation(api.agentJobs.appendEvent, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    level,
    phase,
    message,
    metadata,
  });

const updateStatus = async (
  jobId: Id<'agentJobs'>,
  status:
    | 'queued'
    | 'claimed'
    | 'preparing'
    | 'running'
    | 'checks_running'
    | 'changes_ready'
    | 'draft_pr_opened'
    | 'failed'
    | 'cancelled'
    | 'timed_out',
  extra?: { error?: string; summary?: string },
) =>
  await client.mutation(api.agentJobs.updateStatus, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    status,
    ...extra,
  });

const addArtifact = async (args: {
  jobId: Id<'agentJobs'>;
  kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
  title: string;
  content: string;
  contentType:
    | 'text/markdown'
    | 'text/plain'
    | 'application/json'
    | 'text/x-diff';
}) =>
  await client.mutation(api.agentJobs.addArtifact, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const completeWithDraftPr = async (args: {
  jobId: Id<'agentJobs'>;
  commitSha: string;
  pullRequestUrl: string;
  pullRequestNumber: number;
  summary: string;
}) =>
  await client.mutation(api.agentJobs.completeWithDraftPr, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const commandToShell = (command: string) => ['bash', '-lc', command];

const fileExists = async (filePath: string) => {
  try {
    await access(filePath);
    return true;
  } catch {
    return false;
  }
};

const runProjectCommand = async (args: {
  command: string;
  phase: 'install' | 'check' | 'test';
  claim: Claim;
  workdir: string;
  repoDir: string;
  redact: (value: string) => string;
}) => {
  await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
  const result =
    env.runtime === 'docker'
      ? await runInJobContainer({
          workdir: args.workdir,
          command: commandToShell(args.command),
          environment: Object.fromEntries(
            args.claim.secrets.map((secret) => [secret.name, secret.value]),
          ),
          redact: args.redact,
          timeoutMs: env.jobTimeoutMs,
        })
      : await run('bash', ['-lc', args.command], {
          cwd: args.repoDir,
          env: Object.fromEntries(
            args.claim.secrets.map((secret) => [secret.name, secret.value]),
          ),
          redact: args.redact,
          timeoutMs: env.jobTimeoutMs,
        });
  await addArtifact({
    jobId: args.claim.job._id,
    kind: args.phase === 'test' ? 'test_output' : 'summary',
    title: args.command,
    content: truncate(result.output, 100_000),
    contentType: 'text/plain',
  });
  if (result.exitCode !== 0) {
    throw new Error(`${args.command} failed:\n${result.output}`);
  }
};

const detectPackageCommands = async (
  repoDir: string,
): Promise<{ install?: string; check?: string; test?: string }> => {
  const packageJsonPath = path.join(repoDir, 'package.json');
  try {
    const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
      scripts?: Record<string, string>;
    };
    const scripts = packageJson.scripts ?? {};
    return {
      install: (await fileExists(path.join(repoDir, 'bun.lock')))
        ? 'bun install'
        : (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
          ? 'pnpm install'
          : (await fileExists(path.join(repoDir, 'yarn.lock')))
            ? 'yarn install'
            : 'npm install',
      check: scripts.typecheck
        ? 'npm run typecheck'
        : scripts.lint
          ? 'npm run lint'
          : undefined,
      test: scripts.test ? 'npm test' : undefined,
    };
  } catch {
    return {};
  }
};

const buildPrBody = (args: {
  prompt: string;
  summary: string;
  commands: string[];
  limitations: string[];
}) => `## Spoon agent request

${args.prompt}

## Summary

${args.summary}

## Validation

${
  args.commands.length
    ? args.commands.map((command) => `- \`${command}\``).join('\n')
    : '- No validation commands were requested by the agent.'
}

## Limitations

${
  args.limitations.length
    ? args.limitations.map((item) => `- ${item}`).join('\n')
    : '- No limitations reported.'
}

Generated by Spoon.`;

const runClaim = async (claim: Claim) => {
  const jobId = claim.job._id;
  const workdir = path.resolve(env.workdir, jobId);
  const secretValues = [
    claim.openai.apiKey,
    ...claim.secrets.map((secret) => secret.value),
  ];
  const redact = createRedactor(secretValues);
  try {
    await updateStatus(jobId, 'preparing');
    await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
    if (!claim.github.installationId) {
      throw new Error('GitHub installation ID is missing.');
    }
    const githubToken = await getInstallationToken(claim.github.installationId);
    const repoDir = await cloneRepository({
      workdir,
      token: githubToken,
      owner: claim.job.forkOwner,
      repo: claim.job.forkRepo,
      baseBranch: claim.job.baseBranch,
      workBranch: claim.job.workBranch,
      redact,
      timeoutMs: env.jobTimeoutMs,
    });
    await updateStatus(jobId, 'running');
    await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
    const edit = await runOpenAiEdit({
      repoDir,
      apiKey: claim.openai.apiKey,
      model: claim.openai.model,
      reasoningEffort: claim.openai.reasoningEffort,
      prompt: claim.job.prompt,
      secretNames: claim.secrets.map((secret) => secret.name),
      spoonName: claim.spoon.name,
      upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
      forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
    });
    await addArtifact({
      jobId,
      kind: 'plan',
      title: 'Agent plan',
      content: edit.summary,
      contentType: 'text/markdown',
    });
    const status = await getStatus(repoDir, redact);
    if (!status.output.trim()) {
      throw new Error('No changes produced by the agent.');
    }
    const diff = await getWorktreeDiff(repoDir, redact);
    await addArtifact({
      jobId,
      kind: 'diff',
      title: 'Git diff',
      content: truncate(diff.output, 200_000),
      contentType: 'text/x-diff',
    });
    await updateStatus(jobId, 'checks_running');
    const detected = await detectPackageCommands(repoDir);
    const settings = claim.agentSettings;
    const installCommand = settings?.installCommand ?? detected.install;
    const checkCommand = settings?.checkCommand ?? detected.check;
    const testCommand = settings?.testCommand ?? detected.test;
    if (installCommand) {
      await runProjectCommand({
        command: installCommand,
        phase: 'install',
        claim,
        workdir,
        repoDir,
        redact,
      });
    }
    if (checkCommand) {
      await runProjectCommand({
        command: checkCommand,
        phase: 'check',
        claim,
        workdir,
        repoDir,
        redact,
      });
    }
    if (testCommand) {
      await runProjectCommand({
        command: testCommand,
        phase: 'test',
        claim,
        workdir,
        repoDir,
        redact,
      });
    }
    await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
    const commitSha = await commitAndPush({
      repoDir,
      workBranch: claim.job.workBranch,
      message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
      redact,
      timeoutMs: env.jobTimeoutMs,
    });
    const prBody = buildPrBody({
      prompt: claim.job.prompt,
      summary: edit.summary,
      commands: [
        installCommand,
        checkCommand,
        testCommand,
        ...edit.commands,
      ].filter((command): command is string => Boolean(command)),
      limitations: edit.limitations,
    });
    await addArtifact({
      jobId,
      kind: 'pr_body',
      title: 'Draft PR body',
      content: prBody,
      contentType: 'text/markdown',
    });
    await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
    const pullRequest = await openDraftPullRequest({
      installationId: claim.github.installationId,
      forkOwner: claim.job.forkOwner,
      forkRepo: claim.job.forkRepo,
      baseBranch: claim.job.baseBranch,
      workBranch: claim.job.workBranch,
      title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
      body: prBody,
    });
    await completeWithDraftPr({
      jobId,
      commitSha,
      pullRequestUrl: pullRequest.html_url,
      pullRequestNumber: pullRequest.number,
      summary: edit.summary,
    });
    await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    await appendEvent(
      jobId,
      'error',
      'cleanup',
      truncate(redact(message), 20_000),
    );
    await addArtifact({
      jobId,
      kind: 'error',
      title: 'Failure',
      content: truncate(redact(message), 50_000),
      contentType: 'text/plain',
    });
    await updateStatus(
      jobId,
      message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
      { error: truncate(redact(message), 10_000) },
    );
  } finally {
    await rm(workdir, { recursive: true, force: true });
  }
};

export const startWorker = async () => {
  console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
  for (;;) {
    try {
      const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
        workerId: env.workerId,
        workerToken: env.workerToken,
      });
      if (!claim) {
        await sleep(env.pollMs);
        continue;
      }
      await runClaim(claim);
    } catch (error) {
      console.error(error);
      await sleep(env.pollMs);
    }
  }
};
