import { vi } from 'vitest';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { RepositoryConfig } from '../../../../../config/loader.js';
import type { GitHubWebhookDependencies } from '@/modules/platform-integration/interface-adapters/controllers/webhook/github.controller.js';

const mockConfig = {
  server: { port: 3000 },
  user: {
    gitlabUsername: 'claude-bot',
    githubUsername: 'claude-bot',
  },
  queue: { maxConcurrent: 1, deduplicationWindowMs: 60000 },
  repositories: [],
  github: {
    labelTrigger: 'claude-review',
  },
};

const mockRepoConfig: RepositoryConfig = {
  name: 'test-repo',
  platform: 'github',
  localPath: '/home/user/projects/test-repo',
  remoteUrl: 'https://github.com/test-owner/test-repo.git',
  skill: 'review-front',
  enabled: true,
};

vi.mock('../../../../../config/loader.js', () => ({
  loadConfig: vi.fn(() => mockConfig),
  findRepositoryByRemoteUrl: vi.fn(() => mockRepoConfig),
}));

vi.mock('../../../../../security/verifier.js', () => ({
  verifyGitHubSignature: vi.fn(() => ({ valid: true })),
  getGitHubEventType: vi.fn(() => 'pull_request'),
}));

vi.mock('../../../../../frameworks/queue/pQueueAdapter.js', () => ({
  createJobId: vi.fn(() => 'github-test-owner/test-repo-123'),
  enqueueReview: vi.fn(() => Promise.resolve(true)),
  updateJobProgress: vi.fn(),
  cancelJob: vi.fn(),
}));

vi.mock('../../../../../claude/invoker.js', () => ({
  invokeClaudeReview: vi.fn(),
  sendNotification: vi.fn(),
}));

vi.mock('../../../../../main/websocket.js', () => ({
  startWatchingReviewContext: vi.fn(),
  stopWatchingReviewContext: vi.fn(),
}));

vi.mock('../../../../../config/projectConfig.js', () => ({
  loadProjectConfig: vi.fn(() => null),
  getProjectAgents: vi.fn(() => null),
  getFollowupAgents: vi.fn(() => null),
  getProjectLanguage: vi.fn(() => 'en'),
}));

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { handleGitHubWebhook } from '@/modules/platform-integration/interface-adapters/controllers/webhook/github.controller.js';
import { GitHubEventFactory } from '../../../../factories/gitHubEvent.factory.js';
import { createStubLogger } from '../../../../stubs/logger.stub.js';
import { enqueueReview } from '../../../../../frameworks/queue/pQueueAdapter.js';
import { invokeClaudeReview } from '../../../../../claude/invoker.js';
import { TrackedMrFactory } from '../../../../factories/trackedMr.factory.js';
import type { TrackedMr } from '@/modules/tracking/entities/tracking/trackedMr.js';

function createMockDeps(): GitHubWebhookDependencies {
  return {
    reviewContextGateway: {
      create: vi.fn(),
      read: vi.fn(() => null),
      delete: vi.fn(() => ({ deleted: true })),
      updateProgress: vi.fn(),
      appendAction: vi.fn(),
    },
    threadFetchGateway: {
      fetchThreads: vi.fn(() => []),
    },
    diffMetadataFetchGateway: {
      fetchDiffMetadata: vi.fn(() => ({
        baseSha: 'abc',
        headSha: 'def',
        startSha: 'ghi',
      })),
    },
    diffStatsFetchGateway: {
      fetchDiffStats: vi.fn(() => null),
    },
    trackAssignment: { execute: vi.fn() },
    recordCompletion: { execute: vi.fn() },
    recordPush: { execute: vi.fn(() => null) },
    transitionState: { execute: vi.fn() },
    checkFollowupNeeded: { execute: vi.fn(() => false) },
    syncThreads: { execute: vi.fn(() => null) },
    enforceBudget: {
      execute: vi.fn(async () => ({
        accepted: true,
        status: {
          limitUsd: 200,
          consumedUsd: 0,
          remainingUsd: 200,
          percentUsed: 0,
          exceeded: false,
          periodStart: '2026-05-01T00:00:00.000Z',
        },
      })),
    },
    broadcastBudgetExceeded: vi.fn(),
    getRepositories: vi.fn(() => []),
    removeWorktree: vi.fn(async () => ({ status: 'removed' as const })),
  } as unknown as GitHubWebhookDependencies;
}

function createMockTrackingGateway() {
  const basicMr = TrackedMrFactory.create({
    id: 'github-test-owner/test-repo-123',
    mrNumber: 123,
    platform: 'github',
    project: 'test-owner/test-repo',
  });

  return {
    getById: vi.fn((): TrackedMr | null => basicMr),
    getByNumber: vi.fn(() => null),
    create: vi.fn(),
    update: vi.fn(),
    getByState: vi.fn(() => []),
    getActiveMrs: vi.fn(() => []),
    remove: vi.fn(() => true),
    archive: vi.fn(() => true),
    recordReviewEvent: vi.fn(),
    recordPush: vi.fn(() => null),
    loadTracking: vi.fn(() => null),
    saveTracking: vi.fn(),
  };
}

describe('handleGitHubWebhook', () => {
  let mockReply: FastifyReply;
  let mockGateway: ReturnType<typeof createMockTrackingGateway>;
  let mockDeps: GitHubWebhookDependencies;

  const logger = createStubLogger();

  beforeEach(() => {
    vi.clearAllMocks();
    mockReply = {
      status: vi.fn().mockReturnThis(),
      send: vi.fn().mockReturnThis(),
    } as unknown as FastifyReply;
    mockGateway = createMockTrackingGateway();
    mockDeps = createMockDeps();
  });

  afterEach(() => {
    vi.resetAllMocks();
  });

  describe('PR tracking on review request', () => {
    it('should track PR assignment when review is requested', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          projectPath: '/home/user/projects/test-repo',
          mrInfo: expect.objectContaining({
            mrNumber: 123,
            title: 'Test PR',
            url: 'https://github.com/test-owner/test-repo/pull/123',
            project: 'test-owner/test-repo',
            platform: 'github',
            sourceBranch: 'feature/test',
            targetBranch: 'main',
          }),
          assignedBy: expect.objectContaining({
            username: 'developer',
          }),
        })
      );
    });

    it('should track PR assignment when labeled with needs-review', async () => {
      const event = GitHubEventFactory.createLabeledPr('needs-review');
      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          mrInfo: expect.objectContaining({
            mrNumber: 123,
            platform: 'github',
          }),
        })
      );
    });
  });

  describe('review completion callback', () => {
    it('should record review stats after successful review', async () => {
      const mockResult = {
        success: true,
        cancelled: false,
        stdout: '[REVIEW_STATS:blocking=2:warnings=3:suggestions=1:score=7.5]',
        durationMs: 120000,
        exitCode: 0,
        stderr: '',
      };

      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        await callback(job, new AbortController().signal);
        return true;
      });

      vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);

      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.recordCompletion.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          projectPath: '/home/user/projects/test-repo',
          mrId: 'github-test-owner/test-repo-123',
          reviewData: expect.objectContaining({
            type: 'review',
            score: 7.5,
            blocking: 2,
            warnings: 3,
            suggestions: 1,
          }),
        })
      );
    });

    it('should count only blocking issues as open threads, not warnings', async () => {
      const mockResult = {
        success: true,
        cancelled: false,
        stdout: '[REVIEW_STATS:blocking=1:warnings=5:suggestions=2:score=6]',
        durationMs: 90000,
        exitCode: 0,
        stderr: '',
      };

      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        await callback(job, new AbortController().signal);
        return true;
      });

      vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);

      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.recordCompletion.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          reviewData: expect.objectContaining({
            blocking: 1,
            threadsOpened: 1, // Only blocking count, not blocking + warnings (6)
          }),
        })
      );
    });

    it('should not record stats when review is cancelled', async () => {
      const mockResult = {
        success: false,
        cancelled: true,
        stdout: '',
        durationMs: 5000,
        exitCode: 1,
        stderr: '',
      };

      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        await callback(job, new AbortController().signal);
        return true;
      });

      vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);

      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.recordCompletion.execute).not.toHaveBeenCalled();
    });

    it('should not record stats when review fails', async () => {
      const mockResult = {
        success: false,
        cancelled: false,
        stdout: 'Error occurred',
        durationMs: 10000,
        exitCode: 1,
        stderr: 'dispatch-failed: branch-not-found',
      };

      // Simulate pQueue behaviour: queue swallows processor throws and surfaces
      // them via jobStatus.error (see pQueueAdapter.ts catch block).
      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        try {
          await callback(job, new AbortController().signal);
        } catch {
          // expected: controller now throws on dispatch failure so the
          // queue can populate jobStatus.error for dashboard surfacing
        }
        return true;
      });

      vi.mocked(invokeClaudeReview).mockResolvedValue(mockResult);

      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.recordCompletion.execute).not.toHaveBeenCalled();
    });
  });

  describe('assignedBy attribution', () => {
    it('should use PR assignee as assignedBy when assignee is present', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      event.pull_request.assignees = [{ login: 'pr-owner' }];
      event.sender = { login: 'reviewer-who-requested' };

      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          assignedBy: expect.objectContaining({
            username: 'pr-owner',
            displayName: 'pr-owner',
          }),
        })
      );
    });

    it('should fallback to sender when no assignee is present', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      event.pull_request.assignees = [];
      event.sender = { login: 'webhook-sender' };

      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          assignedBy: expect.objectContaining({
            username: 'webhook-sender',
            displayName: 'webhook-sender',
          }),
        })
      );
    });

    it('should use first assignee when multiple assignees exist', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      event.pull_request.assignees = [
        { login: 'primary-owner' },
        { login: 'secondary-owner' },
      ];
      event.sender = { login: 'reviewer' };

      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          assignedBy: expect.objectContaining({
            username: 'primary-owner',
            displayName: 'primary-owner',
          }),
        })
      );
    });

    it('should fallback to sender when assignees field is undefined', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      (event.pull_request as Record<string, unknown>).assignees = undefined;
      event.sender = { login: 'fallback-sender' };

      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(mockDeps.trackAssignment.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          assignedBy: expect.objectContaining({
            username: 'fallback-sender',
            displayName: 'fallback-sender',
          }),
        })
      );
    });
  });

  describe('budget cap gate', () => {
    it('rejects a fresh review and broadcasts budget-exceeded when enforceBudget returns accepted=false', async () => {
      const exceededDeps = createMockDeps() as unknown as {
        enforceBudget: { execute: ReturnType<typeof vi.fn> };
        broadcastBudgetExceeded: ReturnType<typeof vi.fn>;
      } & GitHubWebhookDependencies;
      exceededDeps.enforceBudget.execute = vi.fn(async () => ({
        accepted: false,
        status: {
          limitUsd: 200,
          consumedUsd: 200.1,
          remainingUsd: 0,
          percentUsed: 100.05,
          exceeded: true,
          periodStart: '2026-05-01T00:00:00.000Z',
        },
      }));

      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, exceededDeps);

      expect(exceededDeps.enforceBudget.execute).toHaveBeenCalled();
      expect(enqueueReview).not.toHaveBeenCalled();
      expect(exceededDeps.broadcastBudgetExceeded).toHaveBeenCalledWith(
        expect.objectContaining({
          mrNumber: 123,
          platform: 'github',
          projectPath: 'test-owner/test-repo',
          limitUsd: 200,
          consumedUsd: 200.1,
        }),
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'rejected', reason: 'budget-exceeded' }),
      );
    });

    it('enqueues a fresh review when enforceBudget returns accepted=true', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      const acceptedDeps = mockDeps as unknown as {
        enforceBudget: { execute: ReturnType<typeof vi.fn> };
        broadcastBudgetExceeded: ReturnType<typeof vi.fn>;
      };
      expect(acceptedDeps.enforceBudget.execute).toHaveBeenCalled();
      expect(enqueueReview).toHaveBeenCalled();
      expect(acceptedDeps.broadcastBudgetExceeded).not.toHaveBeenCalled();
    });
  });

  describe('followup branch on synchronize event', () => {
    function buildFollowupDeps(): GitHubWebhookDependencies {
      const deps = createMockDeps();
      const trackedMr = TrackedMrFactory.create({
        id: 'github-test-owner/test-repo-123',
        mrNumber: 123,
        platform: 'github',
        project: 'test-owner/test-repo',
        state: 'pending-fix',
        openThreads: 3,
        totalThreads: 3,
        lastPushAt: '2026-05-20T12:00:00Z',
        lastReviewAt: '2026-05-20T10:00:00Z',
        autoFollowup: true,
      });
      (deps.recordPush.execute as ReturnType<typeof vi.fn>).mockReturnValue(trackedMr);
      (deps.checkFollowupNeeded.execute as ReturnType<typeof vi.fn>).mockReturnValue(true);
      return deps;
    }

    it('records push, checks followup, and enqueues followup job on synchronize', async () => {
      const deps = buildFollowupDeps();
      const event = GitHubEventFactory.createSynchronizePr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(deps.recordPush.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          projectPath: '/home/user/projects/test-repo',
          mrNumber: 123,
          platform: 'github',
        }),
      );
      expect(deps.checkFollowupNeeded.execute).toHaveBeenCalledWith(
        expect.objectContaining({
          projectPath: '/home/user/projects/test-repo',
          mrNumber: 123,
          platform: 'github',
        }),
      );
      expect(enqueueReview).toHaveBeenCalledWith(
        expect.objectContaining({
          jobType: 'followup',
          platform: 'github',
          mrNumber: 123,
        }),
        expect.any(Function),
      );
      expect(mockReply.status).toHaveBeenCalledWith(202);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'followup-queued', prNumber: 123 }),
      );
    });

    it('does not enqueue when autoFollowup is disabled', async () => {
      const deps = buildFollowupDeps();
      const trackedMr = TrackedMrFactory.create({
        id: 'github-test-owner/test-repo-123',
        mrNumber: 123,
        platform: 'github',
        project: 'test-owner/test-repo',
        state: 'pending-fix',
        autoFollowup: false,
      });
      (deps.recordPush.execute as ReturnType<typeof vi.fn>).mockReturnValue(trackedMr);

      const event = GitHubEventFactory.createSynchronizePr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).not.toHaveBeenCalled();
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'ignored', reason: 'Auto-followup disabled' }),
      );
    });

    it('does not enqueue when checkFollowupNeeded returns false', async () => {
      const deps = buildFollowupDeps();
      (deps.checkFollowupNeeded.execute as ReturnType<typeof vi.fn>).mockReturnValue(false);

      const event = GitHubEventFactory.createSynchronizePr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).not.toHaveBeenCalled();
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });

    it('does not enqueue when no MR is tracked (recordPush returns null)', async () => {
      const deps = buildFollowupDeps();
      (deps.recordPush.execute as ReturnType<typeof vi.fn>).mockReturnValue(null);

      const event = GitHubEventFactory.createSynchronizePr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).not.toHaveBeenCalled();
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });

    it('rejects followup when enforceBudget denies it', async () => {
      const deps = buildFollowupDeps();
      (deps.enforceBudget.execute as ReturnType<typeof vi.fn>).mockResolvedValue({
        accepted: false,
        status: {
          limitUsd: 200,
          consumedUsd: 200.1,
          remainingUsd: 0,
          percentUsed: 100.05,
          exceeded: true,
          periodStart: '2026-05-01T00:00:00.000Z',
        },
      });

      const event = GitHubEventFactory.createSynchronizePr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).not.toHaveBeenCalled();
      expect(deps.broadcastBudgetExceeded).toHaveBeenCalledWith(
        expect.objectContaining({
          mrNumber: 123,
          platform: 'github',
          projectPath: 'test-owner/test-repo',
        }),
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'rejected', reason: 'budget-exceeded' }),
      );
    });

    it('does not enqueue followup on draft PR synchronize', async () => {
      const deps = buildFollowupDeps();
      const event = GitHubEventFactory.createPullRequestEvent({
        action: 'synchronize',
        pull_request: { state: 'open', draft: true },
      });
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).not.toHaveBeenCalled();
      expect(deps.recordPush.execute).not.toHaveBeenCalled();
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });
  });

  describe('cross-fork PR detection (FR-8)', () => {
    it('populates ReviewJob.sourceForkCloneUrl on a fresh review when head.repo differs from base.repo', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      (event.pull_request.head as Record<string, unknown>).repo = {
        full_name: 'contributor/test-repo',
        clone_url: 'https://github.com/contributor/test-repo.git',
      };
      (event.pull_request.base as Record<string, unknown>).repo = {
        full_name: 'test-owner/test-repo',
      };

      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(enqueueReview).toHaveBeenCalledWith(
        expect.objectContaining({
          jobType: 'review',
          sourceForkCloneUrl: 'https://github.com/contributor/test-repo.git',
        }),
        expect.any(Function),
      );
    });

    it('leaves ReviewJob.sourceForkCloneUrl undefined for a same-repo review', async () => {
      const event = GitHubEventFactory.createReviewRequestedPr('claude-bot');
      (event.pull_request.head as Record<string, unknown>).repo = {
        full_name: 'test-owner/test-repo',
        clone_url: 'https://github.com/test-owner/test-repo.git',
      };
      (event.pull_request.base as Record<string, unknown>).repo = {
        full_name: 'test-owner/test-repo',
      };

      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, mockDeps);

      expect(enqueueReview).toHaveBeenCalled();
      const enqueuedJob = vi.mocked(enqueueReview).mock.calls[0][0];
      expect(enqueuedJob.sourceForkCloneUrl).toBeUndefined();
    });

    it('propagates sourceForkCloneUrl to the followup job on cross-fork synchronize', async () => {
      const deps = createMockDeps();
      const trackedMr = TrackedMrFactory.create({
        id: 'github-test-owner/test-repo-123',
        mrNumber: 123,
        platform: 'github',
        project: 'test-owner/test-repo',
        state: 'pending-fix',
        openThreads: 3,
        totalThreads: 3,
        lastPushAt: '2026-05-20T12:00:00Z',
        lastReviewAt: '2026-05-20T10:00:00Z',
        autoFollowup: true,
      });
      (deps.recordPush.execute as ReturnType<typeof vi.fn>).mockReturnValue(trackedMr);
      (deps.checkFollowupNeeded.execute as ReturnType<typeof vi.fn>).mockReturnValue(true);

      const event = GitHubEventFactory.createSynchronizePr();
      (event.pull_request.head as Record<string, unknown>).repo = {
        full_name: 'contributor/test-repo',
        clone_url: 'https://github.com/contributor/test-repo.git',
      };
      (event.pull_request.base as Record<string, unknown>).repo = {
        full_name: 'test-owner/test-repo',
      };

      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(enqueueReview).toHaveBeenCalledWith(
        expect.objectContaining({
          jobType: 'followup',
          sourceForkCloneUrl: 'https://github.com/contributor/test-repo.git',
        }),
        expect.any(Function),
      );
    });
  });

  describe('worktree cleanup on close', () => {
    it('calls removeWorktree on PR close with github identity (covers both closed and merged)', async () => {
      const removeWorktree = vi.fn(async () => ({ status: 'removed' as const }));
      const deps = { ...createMockDeps(), removeWorktree };
      const event = GitHubEventFactory.createClosedPr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(removeWorktree).toHaveBeenCalledWith(
        expect.objectContaining({
          identity: expect.objectContaining({
            platform: 'github',
            mrNumber: 123,
          }),
        }),
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });

    it('keeps webhook response success when removeWorktree fails on close', async () => {
      const removeWorktree = vi.fn(async () => ({ status: 'failed' as const, warning: 'boom' }));
      const deps = { ...createMockDeps(), removeWorktree };
      const event = GitHubEventFactory.createClosedPr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitHubWebhook(request, mockReply, logger, mockGateway, deps);

      expect(removeWorktree).toHaveBeenCalled();
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'cleaned' }),
      );
    });
  });
});
