/**
 * Errand Router Tests
 *
 * Tests all 9 errand tRPC procedures using in-memory SQLite, MockAgentManager,
 * and vi.mock for git operations.
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { nanoid } from 'nanoid';
import type { BranchManager } from '../../git/branch-manager.js';
import type { MergeResult, MergeabilityResult } from '../../git/types.js';
import { MockAgentManager } from '../../agent/mock-manager.js';
import { EventEmitterBus } from '../../events/bus.js';
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
import { createRepositories } from '../../container.js';
import { appRouter, createCallerFactory } from '../router.js';
import { createContext } from '../context.js';

// ---------------------------------------------------------------------------
// vi.hoisted mock handles for git module mocks (hoisted before vi.mock calls)
// ---------------------------------------------------------------------------
const { mockCreate, mockRemove, mockEnsureProjectClone, mockWriteErrandManifest } = vi.hoisted(() => ({
  mockCreate: vi.fn(),
  mockRemove: vi.fn(),
  mockEnsureProjectClone: vi.fn(),
  mockWriteErrandManifest: vi.fn(),
}));

vi.mock('../../git/manager.js', () => ({
  SimpleGitWorktreeManager: class MockWorktreeManager {
    create = mockCreate;
    remove = mockRemove;
  },
}));

vi.mock('../../git/project-clones.js', () => ({
  ensureProjectClone: mockEnsureProjectClone,
}));

vi.mock('../../agent/file-io.js', async (importOriginal) => {
  const original = await importOriginal() as Record<string, unknown>;
  return {
    ...original,
    writeErrandManifest: mockWriteErrandManifest,
  };
});

// ---------------------------------------------------------------------------
// MockBranchManager
// ---------------------------------------------------------------------------
class MockBranchManager implements BranchManager {
  private ensureBranchError: Error | null = null;
  private mergeResultOverride: MergeResult | null = null;
  private diffResult = '';
  public deletedBranches: string[] = [];
  public ensuredBranches: string[] = [];

  setEnsureBranchError(err: Error | null): void { this.ensureBranchError = err; }
  setMergeResult(result: MergeResult): void { this.mergeResultOverride = result; }
  setDiffResult(diff: string): void { this.diffResult = diff; }

  async ensureBranch(_repoPath: string, branch: string, _baseBranch: string): Promise<void> {
    if (this.ensureBranchError) throw this.ensureBranchError;
    this.ensuredBranches.push(branch);
  }
  async mergeBranch(_repoPath: string, _src: string, _target: string): Promise<MergeResult> {
    return this.mergeResultOverride ?? { success: true, message: 'Merged successfully' };
  }
  async diffBranches(_repoPath: string, _base: string, _head: string): Promise<string> {
    return this.diffResult;
  }
  async deleteBranch(_repoPath: string, branch: string): Promise<void> {
    this.deletedBranches.push(branch);
  }
  async branchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
  async remoteBranchExists(_repoPath: string, _branch: string): Promise<boolean> { return false; }
  async listCommits(_repoPath: string, _base: string, _head: string) { return []; }
  async diffCommit(_repoPath: string, _hash: string): Promise<string> { return ''; }
  async getMergeBase(_repoPath: string, _b1: string, _b2: string): Promise<string> { return ''; }
  async pushBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
  async checkMergeability(_repoPath: string, _src: string, _target: string): Promise<MergeabilityResult> {
    return { mergeable: true, conflicts: [] };
  }
  async fetchRemote(_repoPath: string, _remote?: string): Promise<void> {}
  async fastForwardBranch(_repoPath: string, _branch: string, _remote?: string): Promise<void> {}
}

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const createCaller = createCallerFactory(appRouter);

function createTestHarness() {
  const db = createTestDatabase();
  const eventBus = new EventEmitterBus();
  const repos = createRepositories(db);
  const agentManager = new MockAgentManager({ eventBus, agentRepository: repos.agentRepository });
  const branchManager = new MockBranchManager();

  const ctx = createContext({
    eventBus,
    serverStartedAt: new Date(),
    processCount: 0,
    agentManager,
    errandRepository: repos.errandRepository,
    projectRepository: repos.projectRepository,
    branchManager,
    workspaceRoot: '/tmp/test-workspace',
  });

  const caller = createCaller(ctx);

  return {
    db,
    caller,
    agentManager,
    branchManager,
    repos,
  };
}

async function createProject(repos: ReturnType<typeof createRepositories>) {
  const suffix = nanoid().slice(0, 6);
  return repos.projectRepository.create({
    name: `test-project-${suffix}`,
    url: `https://github.com/test/project-${suffix}`,
    defaultBranch: 'main',
  });
}

async function createErrandDirect(
  repos: ReturnType<typeof createRepositories>,
  agentManager: MockAgentManager,
  overrides: Partial<{
    description: string;
    branch: string;
    baseBranch: string;
    agentId: string | null;
    projectId: string;
    status: 'active' | 'pending_review' | 'conflict' | 'merged' | 'abandoned';
    conflictFiles: string | null;
  }> = {},
) {
  const project = await createProject(repos);
  // Spawn an agent to get a real agent ID (unique name to avoid name collision)
  const agent = await agentManager.spawn({
    prompt: 'Test errand',
    name: `errand-agent-${nanoid().slice(0, 6)}`,
    mode: 'errand',
    cwd: '/tmp/fake-worktree',
    taskId: null,
  });

  const errand = await repos.errandRepository.create({
    description: overrides.description ?? 'Fix typo in README',
    branch: overrides.branch ?? 'cw/errand/fix-typo-abc12345',
    baseBranch: overrides.baseBranch ?? 'main',
    agentId: overrides.agentId !== undefined ? overrides.agentId : agent.id,
    projectId: overrides.projectId ?? project.id,
    status: overrides.status ?? 'active',
    conflictFiles: overrides.conflictFiles ?? null,
  });

  return { errand, project, agent };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('errand procedures', () => {
  let h: ReturnType<typeof createTestHarness>;

  beforeEach(() => {
    h = createTestHarness();
    // Reset mock call counts and set default passing behavior
    mockCreate.mockClear();
    mockRemove.mockClear();
    mockEnsureProjectClone.mockClear();
    mockWriteErrandManifest.mockClear();
    mockEnsureProjectClone.mockResolvedValue('/tmp/fake-clone');
    mockCreate.mockResolvedValue({ id: 'errand-id', branch: 'cw/errand/test', path: '/tmp/worktree', isMainWorktree: false });
    mockRemove.mockResolvedValue(undefined);
    mockWriteErrandManifest.mockResolvedValue(undefined);
    h.branchManager.setEnsureBranchError(null);
    h.branchManager.deletedBranches.splice(0);
    h.branchManager.ensuredBranches.splice(0);
  });

  // =========================================================================
  // errand.create
  // =========================================================================
  describe('errand.create', () => {
    it('creates errand with valid input and returns id, branch, agentId', async () => {
      const project = await createProject(h.repos);
      const result = await h.caller.errand.create({
        description: 'Fix typo in README',
        projectId: project.id,
      });

      expect(result).toMatchObject({
        id: expect.any(String),
        branch: expect.stringMatching(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/),
        agentId: expect.any(String),
      });
    });

    it('generates correct slug from description', async () => {
      const project = await createProject(h.repos);
      const result = await h.caller.errand.create({
        description: 'fix typo in README',
        projectId: project.id,
      });

      expect(result.branch).toMatch(/^cw\/errand\/fix-typo-in-readme-[a-zA-Z0-9_-]{8}$/);
    });

    it('uses fallback slug "errand" when description has only special chars', async () => {
      const project = await createProject(h.repos);
      const result = await h.caller.errand.create({
        description: '!!!',
        projectId: project.id,
      });

      expect(result.branch).toMatch(/^cw\/errand\/errand-[a-zA-Z0-9_-]{8}$/);
    });

    it('stores errand in database with correct fields', async () => {
      const project = await createProject(h.repos);
      const result = await h.caller.errand.create({
        description: 'Fix typo in README',
        projectId: project.id,
        baseBranch: 'develop',
      });

      const errand = await h.repos.errandRepository.findById(result.id);
      expect(errand).not.toBeNull();
      expect(errand!.description).toBe('Fix typo in README');
      expect(errand!.baseBranch).toBe('develop');
      expect(errand!.projectId).toBe(project.id);
      expect(errand!.status).toBe('active');
      expect(errand!.agentId).toBe(result.agentId);
    });

    it('throws BAD_REQUEST when description exceeds 200 chars', async () => {
      const project = await createProject(h.repos);
      const longDesc = 'a'.repeat(201);

      await expect(h.caller.errand.create({
        description: longDesc,
        projectId: project.id,
      })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: `description must be ≤200 characters (201 given)`,
      });

      // No DB record created
      const errands = await h.repos.errandRepository.findAll();
      expect(errands).toHaveLength(0);
    });

    it('throws NOT_FOUND for non-existent projectId', async () => {
      await expect(h.caller.errand.create({
        description: 'Fix something',
        projectId: 'nonexistent-project',
      })).rejects.toMatchObject({
        code: 'NOT_FOUND',
        message: 'Project not found',
      });

      // No DB record created
      const errands = await h.repos.errandRepository.findAll();
      expect(errands).toHaveLength(0);
    });

    it('throws INTERNAL_SERVER_ERROR when branch creation fails', async () => {
      const project = await createProject(h.repos);
      h.branchManager.setEnsureBranchError(new Error('Git error: branch locked'));

      await expect(h.caller.errand.create({
        description: 'Fix something',
        projectId: project.id,
      })).rejects.toMatchObject({
        code: 'INTERNAL_SERVER_ERROR',
        message: 'Git error: branch locked',
      });

      // No DB record, no worktree created
      const errands = await h.repos.errandRepository.findAll();
      expect(errands).toHaveLength(0);
      expect(mockCreate).not.toHaveBeenCalled();
    });

    it('throws INTERNAL_SERVER_ERROR when worktree creation fails, cleans up branch and DB record', async () => {
      const project = await createProject(h.repos);
      mockCreate.mockRejectedValueOnce(new Error('Worktree creation failed'));

      await expect(h.caller.errand.create({
        description: 'Fix something',
        projectId: project.id,
      })).rejects.toMatchObject({
        code: 'INTERNAL_SERVER_ERROR',
        message: 'Worktree creation failed',
      });

      // No DB record (was created then deleted)
      const errands = await h.repos.errandRepository.findAll();
      expect(errands).toHaveLength(0);

      // Branch was deleted
      expect(h.branchManager.deletedBranches.length).toBe(1);
    });

    it('throws INTERNAL_SERVER_ERROR when agent spawn fails, cleans up worktree, DB record, and branch', async () => {
      const project = await createProject(h.repos);

      // Make spawn fail by using a scenario that throws immediately
      vi.spyOn(h.agentManager, 'spawn').mockRejectedValueOnce(new Error('Spawn failed'));

      await expect(h.caller.errand.create({
        description: 'Fix something',
        projectId: project.id,
      })).rejects.toMatchObject({
        code: 'INTERNAL_SERVER_ERROR',
        message: 'Spawn failed',
      });

      // No DB record (was created then deleted)
      const errands = await h.repos.errandRepository.findAll();
      expect(errands).toHaveLength(0);

      // Worktree was removed, branch deleted
      expect(mockRemove).toHaveBeenCalledOnce();
      expect(h.branchManager.deletedBranches.length).toBe(1);
    });
  });

  // =========================================================================
  // errand.list
  // =========================================================================
  describe('errand.list', () => {
    it('returns all errands ordered newest first', async () => {
      const { errand: e1 } = await createErrandDirect(h.repos, h.agentManager, { description: 'First' });
      const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
      const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { description: 'Second', projectId: project2.id, branch: 'cw/errand/second-xyz12345' });

      const result = await h.caller.errand.list({});
      expect(result.length).toBe(2);
      // Both errands are present (repository orders by createdAt DESC)
      const ids = result.map(r => r.id);
      expect(ids).toContain(e1.id);
      expect(ids).toContain(e2.id);
    });

    it('filters by projectId', async () => {
      const { errand: e1, project } = await createErrandDirect(h.repos, h.agentManager);
      const project2 = await h.repos.projectRepository.create({ name: 'proj2', url: 'https://github.com/t/p2', defaultBranch: 'main' });
      const agent2 = await h.agentManager.spawn({ prompt: 'x', mode: 'errand', cwd: '/tmp/x', taskId: null });
      await h.repos.errandRepository.create({ description: 'Other', branch: 'cw/errand/other-abc12345', baseBranch: 'main', agentId: agent2.id, projectId: project2.id, status: 'active', conflictFiles: null });

      const result = await h.caller.errand.list({ projectId: project.id });
      expect(result.length).toBe(1);
      expect(result[0].id).toBe(e1.id);
    });

    it('filters by status', async () => {
      await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      const { errand: e2 } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', branch: 'cw/errand/merged-abc12345' });

      const result = await h.caller.errand.list({ status: 'merged' });
      expect(result.length).toBe(1);
      expect(result[0].id).toBe(e2.id);
    });

    it('returns empty array when no errands exist', async () => {
      const result = await h.caller.errand.list({});
      expect(result).toEqual([]);
    });

    it('each record includes agentAlias', async () => {
      await createErrandDirect(h.repos, h.agentManager);
      const result = await h.caller.errand.list({});
      expect(result[0]).toHaveProperty('agentAlias');
    });
  });

  // =========================================================================
  // errand.get
  // =========================================================================
  describe('errand.get', () => {
    it('returns errand with agentAlias and parsed conflictFiles', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager);
      const result = await h.caller.errand.get({ id: errand.id });

      expect(result.id).toBe(errand.id);
      expect(result).toHaveProperty('agentAlias');
      expect(result.conflictFiles).toBeNull();
    });

    it('parses conflictFiles JSON when present', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, {
        status: 'conflict',
        conflictFiles: '["src/a.ts","src/b.ts"]',
      });

      const result = await h.caller.errand.get({ id: errand.id });
      expect(result.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']);
    });

    it('throws NOT_FOUND for unknown id', async () => {
      await expect(h.caller.errand.get({ id: 'nonexistent' })).rejects.toMatchObject({
        code: 'NOT_FOUND',
        message: 'Errand not found',
      });
    });
  });

  // =========================================================================
  // errand.diff
  // =========================================================================
  describe('errand.diff', () => {
    it('returns diff string for an existing errand', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager);
      h.branchManager.setDiffResult('diff --git a/README.md b/README.md\n...');

      const result = await h.caller.errand.diff({ id: errand.id });
      expect(result.diff).toBe('diff --git a/README.md b/README.md\n...');
    });

    it('returns empty diff string when branch has no commits', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager);
      h.branchManager.setDiffResult('');

      const result = await h.caller.errand.diff({ id: errand.id });
      expect(result.diff).toBe('');
    });

    it('throws NOT_FOUND for unknown id', async () => {
      await expect(h.caller.errand.diff({ id: 'nonexistent' })).rejects.toMatchObject({
        code: 'NOT_FOUND',
        message: 'Errand not found',
      });
    });
  });

  // =========================================================================
  // errand.complete
  // =========================================================================
  describe('errand.complete', () => {
    it('transitions active errand to pending_review and stops agent', async () => {
      const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      const stopSpy = vi.spyOn(h.agentManager, 'stop');

      const result = await h.caller.errand.complete({ id: errand.id });

      expect(result!.status).toBe('pending_review');
      expect(stopSpy).toHaveBeenCalledWith(agent.id);
    });

    it('throws BAD_REQUEST when status is pending_review', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });

      await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot complete an errand with status 'pending_review'",
      });
    });

    it('throws BAD_REQUEST when status is merged', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });

      await expect(h.caller.errand.complete({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot complete an errand with status 'merged'",
      });
    });
  });

  // =========================================================================
  // errand.merge
  // =========================================================================
  describe('errand.merge', () => {
    it('merges clean pending_review errand, removes worktree, sets status to merged', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
      h.branchManager.setMergeResult({ success: true, message: 'Merged' });

      const result = await h.caller.errand.merge({ id: errand.id });

      expect(result).toEqual({ status: 'merged' });
      expect(mockRemove).toHaveBeenCalledOnce();

      const updated = await h.repos.errandRepository.findById(errand.id);
      expect(updated!.status).toBe('merged');
    });

    it('merges clean conflict errand (re-merge after resolve)', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, {
        status: 'conflict',
        conflictFiles: '["src/a.ts"]',
      });
      h.branchManager.setMergeResult({ success: true, message: 'Merged' });

      const result = await h.caller.errand.merge({ id: errand.id });
      expect(result).toEqual({ status: 'merged' });
    });

    it('merges into target branch override', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
      const mergeSpy = vi.spyOn(h.branchManager, 'mergeBranch');

      await h.caller.errand.merge({ id: errand.id, target: 'develop' });

      expect(mergeSpy).toHaveBeenCalledWith(
        expect.any(String),
        errand.branch,
        'develop',
      );
    });

    it('throws BAD_REQUEST and stores conflictFiles on merge conflict', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
      h.branchManager.setMergeResult({
        success: false,
        conflicts: ['src/a.ts', 'src/b.ts'],
        message: 'Conflict detected',
      });

      await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: 'Merge conflict in 2 file(s)',
      });

      const updated = await h.repos.errandRepository.findById(errand.id);
      expect(updated!.status).toBe('conflict');
      expect(JSON.parse(updated!.conflictFiles!)).toEqual(['src/a.ts', 'src/b.ts']);
    });

    it('throws BAD_REQUEST when status is active', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });

      await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot merge an errand with status 'active'",
      });
    });

    it('throws BAD_REQUEST when status is abandoned', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });

      await expect(h.caller.errand.merge({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot merge an errand with status 'abandoned'",
      });
    });
  });

  // =========================================================================
  // errand.delete
  // =========================================================================
  describe('errand.delete', () => {
    it('deletes active errand: stops agent, removes worktree, deletes branch and DB record', async () => {
      const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      const stopSpy = vi.spyOn(h.agentManager, 'stop');

      const result = await h.caller.errand.delete({ id: errand.id });

      expect(result).toEqual({ success: true });
      expect(stopSpy).toHaveBeenCalledWith(agent.id);
      expect(mockRemove).toHaveBeenCalledOnce();
      expect(h.branchManager.deletedBranches).toContain(errand.branch);

      const deleted = await h.repos.errandRepository.findById(errand.id);
      expect(deleted).toBeNull();
    });

    it('deletes non-active errand: skips agent stop', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
      const stopSpy = vi.spyOn(h.agentManager, 'stop');

      const result = await h.caller.errand.delete({ id: errand.id });

      expect(result).toEqual({ success: true });
      expect(stopSpy).not.toHaveBeenCalled();

      const deleted = await h.repos.errandRepository.findById(errand.id);
      expect(deleted).toBeNull();
    });

    it('succeeds when worktree already removed (no-op)', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      mockRemove.mockRejectedValueOnce(new Error('Worktree not found'));

      // Should not throw
      const result = await h.caller.errand.delete({ id: errand.id });
      expect(result).toEqual({ success: true });

      const deleted = await h.repos.errandRepository.findById(errand.id);
      expect(deleted).toBeNull();
    });

    it('succeeds when branch already deleted (no-op)', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });

      // DeleteBranch doesn't throw (BranchManager interface says no-op if not found)
      const result = await h.caller.errand.delete({ id: errand.id });
      expect(result).toEqual({ success: true });
    });

    it('throws NOT_FOUND for unknown id', async () => {
      await expect(h.caller.errand.delete({ id: 'nonexistent' })).rejects.toMatchObject({
        code: 'NOT_FOUND',
        message: 'Errand not found',
      });
    });
  });

  // =========================================================================
  // errand.sendMessage
  // =========================================================================
  describe('errand.sendMessage', () => {
    it('sends message to active running errand agent', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      const sendSpy = vi.spyOn(h.agentManager, 'sendUserMessage');

      const result = await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });

      expect(result).toEqual({ success: true });
      expect(sendSpy).toHaveBeenCalledWith(errand.agentId, 'Hello agent');
    });

    it('does NOT create a conversations record', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      await h.caller.errand.sendMessage({ id: errand.id, message: 'Hello agent' });

      // No pending conversation records should exist for the agent
      const convs = await h.repos.conversationRepository.findPendingForAgent(errand.agentId!);
      expect(convs).toHaveLength(0);
    });

    it('throws BAD_REQUEST when agent is stopped', async () => {
      const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      // Stop the agent to set its status to stopped
      await h.agentManager.stop(agent.id);

      await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: 'Agent is not running (status: stopped)',
      });
    });

    it('throws BAD_REQUEST when errand is not active', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });

      await expect(h.caller.errand.sendMessage({ id: errand.id, message: 'Hello' })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: 'Errand is not active',
      });
    });
  });

  // =========================================================================
  // errand.abandon
  // =========================================================================
  describe('errand.abandon', () => {
    it('abandons active errand: stops agent, removes worktree, deletes branch, sets status', async () => {
      const { errand, agent } = await createErrandDirect(h.repos, h.agentManager, { status: 'active' });
      const stopSpy = vi.spyOn(h.agentManager, 'stop');

      const result = await h.caller.errand.abandon({ id: errand.id });

      expect(result!.status).toBe('abandoned');
      expect(result!.agentId).toBe(agent.id); // agentId preserved
      expect(stopSpy).toHaveBeenCalledWith(agent.id);
      expect(mockRemove).toHaveBeenCalledOnce();
      expect(h.branchManager.deletedBranches).toContain(errand.branch);

      // DB record preserved with abandoned status
      const found = await h.repos.errandRepository.findById(errand.id);
      expect(found!.status).toBe('abandoned');
    });

    it('abandons pending_review errand: skips agent stop', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'pending_review' });
      const stopSpy = vi.spyOn(h.agentManager, 'stop');

      const result = await h.caller.errand.abandon({ id: errand.id });

      expect(result!.status).toBe('abandoned');
      expect(stopSpy).not.toHaveBeenCalled();
    });

    it('abandons conflict errand: skips agent stop, removes worktree, deletes branch', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, {
        status: 'conflict',
        conflictFiles: '["src/a.ts"]',
        agentId: null,
      });

      const result = await h.caller.errand.abandon({ id: errand.id });
      expect(result!.status).toBe('abandoned');
    });

    it('throws BAD_REQUEST when status is merged', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'merged', agentId: null });

      await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot abandon an errand with status 'merged'",
      });
    });

    it('throws BAD_REQUEST when status is abandoned', async () => {
      const { errand } = await createErrandDirect(h.repos, h.agentManager, { status: 'abandoned', agentId: null });

      await expect(h.caller.errand.abandon({ id: errand.id })).rejects.toMatchObject({
        code: 'BAD_REQUEST',
        message: "Cannot abandon an errand with status 'abandoned'",
      });
    });
  });
});
