import { vi } from 'vitest';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { RepositoryConfig } from '@/config/loader.js';

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

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

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

vi.mock('@/security/verifier.js', () => ({
  verifyGitLabSignature: vi.fn(() => ({ valid: true })),
  getGitLabEventType: vi.fn(() => 'Merge Request Hook'),
}));

vi.mock('@/frameworks/queue/pQueueAdapter.js', () => ({
  createJobId: vi.fn(() => 'gitlab-test-org/test-project-42'),
  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'),
}));

vi.mock('@/modules/review-execution/interface-adapters/gateways/reviewContext.fileSystem.gateway.js', () => ({
  ReviewContextFileSystemGateway: vi.fn().mockImplementation(() => ({
    create: vi.fn(),
    read: vi.fn(() => null),
    delete: vi.fn(() => ({ deleted: true })),
    updateProgress: vi.fn(),
  })),
}));

vi.mock('@/modules/platform-integration/interface-adapters/gateways/threadFetch.gitlab.gateway.js', () => ({
  GitLabThreadFetchGateway: vi.fn().mockImplementation(() => ({
    fetchThreads: vi.fn(() => []),
  })),
  defaultGitLabExecutor: vi.fn(),
}));

vi.mock('@/modules/platform-integration/interface-adapters/gateways/diffMetadataFetch.gitlab.gateway.js', () => ({
  GitLabDiffMetadataFetchGateway: vi.fn().mockImplementation(() => ({
    fetchDiffMetadata: vi.fn(() => undefined),
  })),
}));

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { handleGitLabWebhook } from '@/modules/platform-integration/interface-adapters/controllers/webhook/gitlab.controller.js';
import { enqueueReview } from '@/frameworks/queue/pQueueAdapter.js';
import { invokeClaudeReview } from '@/claude/invoker.js';
import { GitLabEventFactory } from '@/tests/factories/gitLabEvent.factory.js';
import { createStubLogger } from '@/tests/stubs/logger.stub.js';
import { TrackedMrFactory } from '@/tests/factories/trackedMr.factory.js';
import type { TrackedMr } from '@/modules/tracking/entities/tracking/trackedMr.js';
import { TrackAssignmentUseCase } from '@/modules/tracking/usecases/tracking/trackAssignment.usecase.js';
import { RecordReviewCompletionUseCase } from '@/modules/tracking/usecases/tracking/recordReviewCompletion.usecase.js';
import { RecordPushUseCase } from '@/modules/tracking/usecases/tracking/recordPush.usecase.js';
import { TransitionStateUseCase } from '@/modules/tracking/usecases/tracking/transitionState.usecase.js';
import { CheckFollowupNeededUseCase } from '@/modules/tracking/usecases/tracking/checkFollowupNeeded.usecase.js';
import { SyncThreadsUseCase } from '@/modules/tracking/usecases/tracking/syncThreads.usecase.js';

function createMockTrackingGateway() {
  const basicMr = TrackedMrFactory.create({
    id: 'gitlab-test-org/test-project-42',
    mrNumber: 42,
    platform: 'gitlab',
    project: 'test-org/test-project',
  });

  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(),
  };
}

function createStubContextGateway() {
  return {
    create: vi.fn(() => ({ success: true, filePath: '' })),
    read: vi.fn(() => null),
    delete: vi.fn(() => ({ success: true, deleted: true })),
    exists: vi.fn(() => false),
    getFilePath: vi.fn(() => ''),
    appendAction: vi.fn(() => ({ success: true })),
    updateProgress: vi.fn(() => ({ success: true })),
    setResult: vi.fn(() => ({ success: true })),
  };
}

function createAcceptAllEnforceBudget() {
  return {
    execute: vi.fn(async () => ({
      accepted: true,
      status: {
        limitUsd: 200,
        consumedUsd: 0,
        remainingUsd: 200,
        percentUsed: 0,
        exceeded: false,
        periodStart: '2026-05-01T00:00:00.000Z',
      },
    })),
  };
}

function createDefaultDeps(trackingGateway: ReturnType<typeof createMockTrackingGateway>) {
  const threadFetchGateway = { fetchThreads: vi.fn(() => []) };
  return {
    reviewContextGateway: createStubContextGateway(),
    threadFetchGateway,
    diffMetadataFetchGateway: { fetchDiffMetadata: vi.fn(() => ({ baseSha: 'abc', headSha: 'def', startSha: 'ghi' })) },
    diffStatsFetchGateway: { fetchDiffStats: vi.fn(() => null) },
    trackAssignment: new TrackAssignmentUseCase(trackingGateway),
    recordCompletion: new RecordReviewCompletionUseCase(trackingGateway),
    recordPush: new RecordPushUseCase(trackingGateway),
    transitionState: new TransitionStateUseCase(trackingGateway),
    checkFollowupNeeded: new CheckFollowupNeededUseCase(trackingGateway),
    syncThreads: new SyncThreadsUseCase(trackingGateway, threadFetchGateway),
    enforceBudget: createAcceptAllEnforceBudget(),
    broadcastBudgetExceeded: vi.fn(),
    getRepositories: vi.fn(() => []),
    removeWorktree: vi.fn(async () => ({ status: 'removed' as const })),
  };
}

describe('handleGitLabWebhook', () => {
  let mockReply: FastifyReply;
  let mockGateway: ReturnType<typeof createMockTrackingGateway>;
  let defaultDeps: ReturnType<typeof createDefaultDeps>;

  const logger = createStubLogger();

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

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

  describe('when MR is merged', () => {
    it('should transition state to merged via gateway', async () => {
      const event = GitLabEventFactory.createMergedMr();
      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.update).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        'gitlab-test-org/test-project-42',
        expect.objectContaining({ state: 'merged' })
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'merged' })
      );
    });
  });

  describe('when MR is approved', () => {
    it('should transition state to approved via gateway', async () => {
      const event = GitLabEventFactory.createApprovedMr();
      const request = {
        body: event,
        headers: {},
      } as unknown as FastifyRequest;

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.update).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        'gitlab-test-org/test-project-42',
        expect.objectContaining({ state: 'approved' })
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
      expect(mockReply.send).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'approved' })
      );
    });
  });

  describe('assignedBy attribution', () => {
    it('should use MR assignee as assignedBy when assignee is present', async () => {
      mockGateway.getById.mockReturnValue(null);
      const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
      event.assignees = [{ username: 'mr-owner', name: 'MR Owner' }];
      event.user = { username: 'reviewer-who-added', name: 'Reviewer Who Added' };

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

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.create).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        expect.objectContaining({
          assignment: expect.objectContaining({
            username: 'mr-owner',
            displayName: 'MR Owner',
          }),
        })
      );
    });

    it('should fallback to event.user when no assignee is present', async () => {
      mockGateway.getById.mockReturnValue(null);
      const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
      event.assignees = [];
      event.user = { username: 'webhook-trigger', name: 'Webhook Trigger' };

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

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.create).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        expect.objectContaining({
          assignment: expect.objectContaining({
            username: 'webhook-trigger',
            displayName: 'Webhook Trigger',
          }),
        })
      );
    });

    it('should use first assignee when multiple assignees exist', async () => {
      mockGateway.getById.mockReturnValue(null);
      const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
      event.assignees = [
        { username: 'primary-owner', name: 'Primary Owner' },
        { username: 'secondary-owner', name: 'Secondary Owner' },
      ];
      event.user = { username: 'reviewer', name: 'Reviewer' };

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

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.create).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        expect.objectContaining({
          assignment: expect.objectContaining({
            username: 'primary-owner',
            displayName: 'Primary Owner',
          }),
        })
      );
    });

    it('should fallback to event.user when assignees field is undefined', async () => {
      mockGateway.getById.mockReturnValue(null);
      const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
      (event as Record<string, unknown>).assignees = undefined;
      event.user = { username: 'fallback-user', name: 'Fallback User' };

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

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(mockGateway.create).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        expect.objectContaining({
          assignment: expect.objectContaining({
            username: 'fallback-user',
            displayName: 'Fallback User',
          }),
        })
      );
    });
  });

  describe('dependency injection: reviewContextGateway', () => {
    it('should delete review context via injected gateway when MR is closed', async () => {
      const contextGateway = createStubContextGateway();
      const deps = { ...defaultDeps, reviewContextGateway: contextGateway };

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

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

      expect(contextGateway.delete).toHaveBeenCalledWith(
        '/home/user/projects/test-project',
        'gitlab-test-org/test-project-42'
      );
    });

    it('should create review context via injected gateway when review is enqueued', async () => {
      vi.mocked(invokeClaudeReview).mockResolvedValue({
        success: false,
        cancelled: true,
        stdout: '',
        stderr: '',
        exitCode: null,
        durationMs: 0,
      });
      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        await callback(job, new AbortController().signal);
        return true;
      });

      const contextGateway = createStubContextGateway();
      const deps = { ...defaultDeps, reviewContextGateway: contextGateway };

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

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

      expect(contextGateway.create).toHaveBeenCalledWith(
        expect.objectContaining({
          platform: 'gitlab',
          projectPath: 'test-org/test-project',
          mergeRequestNumber: 42,
        })
      );
    });

    it('should use injected threadFetchGateway to fetch threads when review is enqueued', async () => {
      const stubThreadFetch = { fetchThreads: vi.fn(() => []) };
      const stubDiffMetadataFetch = { fetchDiffMetadata: vi.fn(() => ({ baseSha: 'a', headSha: 'b', startSha: 'c' })) };

      vi.mocked(invokeClaudeReview).mockResolvedValue({
        success: false,
        cancelled: true,
        stdout: '',
        stderr: '',
        exitCode: null,
        durationMs: 0,
      });
      vi.mocked(enqueueReview).mockImplementation(async (job, callback) => {
        await callback(job, new AbortController().signal);
        return true;
      });

      const deps = {
        ...defaultDeps,
        threadFetchGateway: stubThreadFetch,
        diffMetadataFetchGateway: stubDiffMetadataFetch,
      };

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

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

      expect(stubThreadFetch.fetchThreads).toHaveBeenCalledWith(
        'test-org/test-project',
        42
      );
    });
  });

  describe('worktree cleanup on close and merge', () => {
    it('calls removeWorktree on MR close with platform/projectPath/mrNumber identity', async () => {
      const removeWorktree = vi.fn(async () => ({ status: 'removed' as const }));
      const deps = { ...defaultDeps, removeWorktree };
      const event = GitLabEventFactory.createClosedMr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

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

      expect(removeWorktree).toHaveBeenCalledWith(
        expect.objectContaining({
          identity: {
            platform: 'gitlab',
            projectPath: 'test-org/test-project',
            mrNumber: 42,
          },
        }),
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });

    it('calls removeWorktree on MR merge with platform/projectPath/mrNumber identity', async () => {
      const removeWorktree = vi.fn(async () => ({ status: 'removed' as const }));
      const deps = { ...defaultDeps, removeWorktree };
      const event = GitLabEventFactory.createMergedMr();
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

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

      expect(removeWorktree).toHaveBeenCalledWith(
        expect.objectContaining({
          identity: {
            platform: 'gitlab',
            projectPath: 'test-org/test-project',
            mrNumber: 42,
          },
        }),
      );
      expect(mockReply.status).toHaveBeenCalledWith(200);
    });

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

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

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

  describe('budget cap gate', () => {
    it('rejects a fresh review and broadcasts budget-exceeded when enforceBudget returns accepted=false', async () => {
      mockGateway.getById.mockReturnValue(null);
      const 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 broadcastBudgetExceeded = vi.fn();
      const deps = { ...defaultDeps, enforceBudget, broadcastBudgetExceeded };

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

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

      expect(enforceBudget.execute).toHaveBeenCalled();
      expect(enqueueReview).not.toHaveBeenCalled();
      expect(broadcastBudgetExceeded).toHaveBeenCalledWith(
        expect.objectContaining({
          mrNumber: 42,
          platform: 'gitlab',
          projectPath: 'test-org/test-project',
          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 () => {
      mockGateway.getById.mockReturnValue(null);
      const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
      const request = { body: event, headers: {} } as unknown as FastifyRequest;

      await handleGitLabWebhook(request, mockReply, logger, mockGateway, defaultDeps);

      expect(defaultDeps.enforceBudget.execute).toHaveBeenCalled();
      expect(enqueueReview).toHaveBeenCalled();
      expect(defaultDeps.broadcastBudgetExceeded).not.toHaveBeenCalled();
    });
  });
});
