import { describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Worker } from './worker.js';
import { userPiecesDir } from './user-folder/paths.js';
import type { AppConfig } from './config.js';
import type { Job } from './db/repository.js';

/**
 * executeJob の pre-flight ゲート（切り出しリファクタ前のピン留めテスト）。
 *
 * 対象:
 *  - MCP 認証ゲート: piece.required_mcp のトークンが無い場合、
 *    status='waiting_human' + wait_reason='mcp_auth_required' で park する。
 *    wait_reason の永続化が漏れるとジョブが沈黙して永久停止する既知の
 *    失敗モードがあるため、パッチ内容を厳密に固定する。
 *  - Model-mismatch requeue ゲート: piece が pin したモデルをこの worker が
 *    サーブできない場合、status='queued' + workerId=null で再キューする。
 *
 * どちらも executeJob を実際に走らせ、リポジトリモックへの書き込みを検証する
 * （Given-When-Then）。
 */

function makeConfig(overrides: Partial<AppConfig>): AppConfig {
  return {
    provider: {
      model: 'test-model',
      workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1' }],
    },
    worktreeDir: '/tmp/worker-gate-test',
    concurrency: 1,
    maxMovements: 30,
    retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] },
    ask: { maxPerJob: 2 },
    subtasks: { maxDepth: 2 },
    tools: {
      searxngUrl: 'http://localhost:8080',
      visionModel: 'vision',
      visionTimeout: 60,
      visionMaxTokens: 1024,
      webfetchTimeout: 30,
      websearchTimeout: 15,
      webfetchAllowedHosts: [],
    },
    ...overrides,
  } as AppConfig;
}

function makeLocalTaskJob(overrides: Partial<Job>): Job {
  return {
    id: 'job-gate-1',
    repo: 'local/task-77',
    issueNumber: 77,
    prNumber: null,
    status: 'running',
    pieceName: 'pin-gate-piece',
    requiredRole: 'auto',
    requiredProfile: 'auto',
    currentMovement: null,
    instruction: 'do something',
    branchName: null,
    worktreePath: null,
    attempt: 1,
    maxAttempts: 3,
    nextRetryAt: null,
    errorSummary: null,
    resumeMovement: null,
    askCount: 0,
    workerId: 'worker-1',
    parentJobId: null,
    subtaskDepth: 0,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  } as unknown as Job;
}

function makeRepoMock() {
  const updateJobCalls: Array<[string, Record<string, unknown>]> = [];
  const repo = {
    lockIssue: vi.fn().mockResolvedValue(true),
    unlockIssue: vi.fn().mockResolvedValue(undefined),
    addAuditLog: vi.fn().mockResolvedValue(undefined),
    updateWorkerNodeHealth: vi.fn().mockResolvedValue(undefined),
    updateJob: vi.fn().mockImplementation((id: string, patch: Record<string, unknown>) => {
      updateJobCalls.push([id, patch]);
      return Promise.resolve(undefined);
    }),
    getLocalTask: vi.fn().mockResolvedValue(null),
    updateLocalTask: vi.fn().mockResolvedValue(undefined),
    addLocalTaskComment: vi.fn().mockResolvedValue(undefined),
  };
  return { repo, updateJobCalls };
}

/** Given: userFolderRoot 配下の local オーナー custom piece ディレクトリに piece を配置 */
function writeCustomPiece(userFolderRoot: string, name: string, extraTopLevel: string): void {
  const dir = userPiecesDir(userFolderRoot, 'local');
  mkdirSync(dir, { recursive: true });
  const yaml = [
    `name: ${name}`,
    'description: gate pinning test piece',
    'initial_movement: respond',
    extraTopLevel,
    'movements:',
    '  - name: respond',
    '    persona: assistant',
    '    instruction: answer the user',
    '    default_next: COMPLETE',
    '    rules: []',
    '',
  ].join('\n');
  writeFileSync(join(dir, `${name}.yaml`), yaml, 'utf-8');
}

describe('executeJob pre-flight gates (behaviour pinning)', () => {
  it('parks the job as waiting_human with wait_reason=mcp_auth_required when a required MCP token is missing', async () => {
    // Given: required_mcp を持つ custom piece と、MCP トークンを一切持たない worker
    const root = mkdtempSync(join(tmpdir(), 'worker-gate-mcp-'));
    const userFolderRoot = join(root, 'users');
    writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'required_mcp:\n  - github-mcp');
    const { repo, updateJobCalls } = makeRepoMock();
    const worker = new Worker(
      'worker-1',
      'http://localhost:11434/v1',
      'test-model',
      repo as never,
      makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }),
    );

    // When: executeJob を最後まで走らせる
    await (worker as unknown as { executeJob: (job: Job) => Promise<void> }).executeJob(
      makeLocalTaskJob({}),
    );

    // Then: wait_reason='mcp_auth_required' を含む park パッチが永続化される
    //（resume_movement は piece の initial_movement）
    const parkCall = updateJobCalls.find(([, patch]) => patch.status === 'waiting_human');
    expect(parkCall).toBeDefined();
    expect(parkCall![1]).toEqual({
      status: 'waiting_human',
      waitReason: 'mcp_auth_required',
      resumeMovement: 'respond',
    });
    // Then: ユーザーに system イベントコメントで連携を促す
    expect(repo.addLocalTaskComment).toHaveBeenCalledWith(
      77,
      'system',
      expect.stringContaining('github-mcp'),
      'event',
    );
    // Then: 失敗扱いにはならず、issue ロックは finally で必ず解放される
    expect(updateJobCalls.some(([, patch]) => patch.status === 'failed')).toBe(false);
    expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77);
  });

  it('requeues the job (status=queued, workerId=null) when the piece pins a model this worker cannot serve', async () => {
    // Given: model を pin した custom piece と、そのモデルを持たない direct worker
    const root = mkdtempSync(join(tmpdir(), 'worker-gate-model-'));
    const userFolderRoot = join(root, 'users');
    writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'model: exotic-model');
    const { repo, updateJobCalls } = makeRepoMock();
    const worker = new Worker(
      'worker-1',
      'http://localhost:11434/v1',
      'test-model',
      repo as never,
      makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }),
    );
    // initialize() 相当: probed available models（exotic-model を含まない）
    (worker as unknown as { availableModels: Set<string> }).availableModels = new Set(['test-model']);

    // When
    await (worker as unknown as { executeJob: (job: Job) => Promise<void> }).executeJob(
      makeLocalTaskJob({}),
    );

    // Then: queued に戻し worker を外す（他 worker が拾えるように）
    const requeueCall = updateJobCalls.find(([, patch]) => patch.status === 'queued');
    expect(requeueCall).toBeDefined();
    expect(requeueCall![1]).toEqual({
      status: 'queued',
      workerId: null,
      errorSummary: 'Required model exotic-model is not available on worker-1',
    });
    expect(repo.addAuditLog).toHaveBeenCalledWith('job-gate-1', 'job_requeued_model_mismatch', 'worker', {
      workerId: 'worker-1',
      requiredModel: 'exotic-model',
      availableModels: ['test-model'],
    });
    // Then: park はしない
    expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false);
    expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77);
  });
});
