import { afterEach, describe, expect, it, vi } from 'vitest';
import { Worker, shouldDeadlineAbort, canSpawnSubtask } from './worker.js';
import type { AppConfig } from './config.js';

function makeConfig(maxJobMinutes?: number): AppConfig {
  return {
    provider: {
      model: 'test-model',
      workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1' }],
    },
    worktreeDir: '/tmp/worker-guard-test',
    concurrency: 1,
    maxMovements: 30,
    retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] },
    ask: { maxPerJob: 2 },
    subtasks: { maxDepth: 2 },
    safety: maxJobMinutes !== undefined ? { maxJobMinutes } : {},
    tools: {
      searxngUrl: 'http://localhost:8080', visionModel: 'v', visionTimeout: 60,
      visionMaxTokens: 1024, webfetchTimeout: 30, websearchTimeout: 15, webfetchAllowedHosts: [],
    },
  } as AppConfig;
}

type StartGuard = (
  jobId: string, startedAt: number, maxJobMs: number, cancelCheck: () => boolean, ac: AbortController,
) => ReturnType<typeof setInterval>;

function buildWorker(config: AppConfig, getStatus: () => string) {
  const repo = { getJobStatusSync: vi.fn().mockImplementation(() => getStatus()) };
  const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'm', repo as never, config);
  const startGuard = ((worker as never as Record<string, unknown>)['startJobGuard'] as StartGuard).bind(worker);
  return { worker, repo, startGuard };
}

describe('shouldDeadlineAbort', () => {
  it('aborts once elapsed passes the deadline', () => {
    expect(shouldDeadlineAbort(61_000, 60_000, false)).toBe(true);
  });
  it('does not abort before the deadline', () => {
    expect(shouldDeadlineAbort(59_000, 60_000, false)).toBe(false);
  });
  it('never aborts when the deadline is disabled (0)', () => {
    expect(shouldDeadlineAbort(10_000_000, 0, false)).toBe(false);
  });
  it('does not re-abort an already-aborted job', () => {
    expect(shouldDeadlineAbort(99_000, 60_000, true)).toBe(false);
  });
});

describe('canSpawnSubtask (②B zombie orphan guard)', () => {
  it('allows a spawn while the run is live (not aborted, status running)', () => {
    expect(canSpawnSubtask(false, 'running')).toBe(true);
  });
  it('refuses once the abort signal has fired (in-grace window, before hardkill)', () => {
    expect(canSpawnSubtask(true, 'running')).toBe(false);
  });
  it('refuses after the job row is terminal (post-hardkill / user cancel)', () => {
    expect(canSpawnSubtask(false, 'cancelled')).toBe(false);
    expect(canSpawnSubtask(false, 'failed')).toBe(false);
    expect(canSpawnSubtask(false, 'succeeded')).toBe(false);
    expect(canSpawnSubtask(false, '')).toBe(false);
  });
});

describe('Worker job guard timer', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  it('fires the abort within one poll interval when the job is cancelled mid-run', () => {
    vi.useFakeTimers();
    let status = 'running';
    const { startGuard } = buildWorker(makeConfig(60), () => status);
    const ctrl = new AbortController();
    // Mirror executeJob: cancelCheck reads DB status and aborts on 'cancelled'.
    const cancelCheck = () => {
      const isCancelled = status === 'cancelled';
      if (isCancelled) ctrl.abort();
      return isCancelled;
    };
    const timer = startGuard('j1', Date.now(), 60 * 60 * 1000, cancelCheck, ctrl);

    // Not cancelled yet → no abort after a few intervals.
    vi.advanceTimersByTime(9_000);
    expect(ctrl.signal.aborted).toBe(false);

    // User presses cancel → DB flips to 'cancelled'. Next poll fires the abort.
    status = 'cancelled';
    vi.advanceTimersByTime(3_000);
    expect(ctrl.signal.aborted).toBe(true);
    clearInterval(timer);
  });

  it('force-aborts a job that exceeds the hard deadline', () => {
    vi.useFakeTimers();
    const { startGuard } = buildWorker(makeConfig(1), () => 'running'); // 1 minute deadline
    const ctrl = new AbortController();
    const timer = startGuard('j1', Date.now(), 60 * 1000, () => false, ctrl);

    vi.advanceTimersByTime(57_000);
    expect(ctrl.signal.aborted).toBe(false);

    vi.advanceTimersByTime(6_000); // a poll tick now lands past 60s elapsed
    expect(ctrl.signal.aborted).toBe(true);
    // ①: the deadline abort carries a distinguishable reason so the loop can
    // tell a timeout apart from a user cancel (which aborts with no reason).
    expect(ctrl.signal.reason).toBe('deadline_exceeded');
    clearInterval(timer);
  });
});

type FinalizeHardkill = (
  job: { id: string },
  reporter: { reportFinalResult: (status: string, output: string) => Promise<unknown> },
  isSubTask: boolean,
  parentJobId: string | null,
  deadline: boolean,
) => Promise<void>;

describe('Worker finalizeHardkill (②B terminal write)', () => {
  afterEach(() => vi.restoreAllMocks());

  function buildHardkillWorker() {
    const repo = {
      getJobStatusSync: vi.fn(() => 'running'),
      updateJob: vi.fn().mockResolvedValue(undefined),
      getSubJobs: vi.fn().mockResolvedValue([]),
      addAuditLog: vi.fn().mockResolvedValue(undefined),
      requeueParentJobIfAllSubtasksDone: vi.fn().mockResolvedValue(false),
    };
    const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'm', repo as never, makeConfig(1));
    const finalize = ((worker as never as Record<string, unknown>)['finalizeHardkill'] as FinalizeHardkill).bind(worker);
    return { repo, finalize };
  }

  it('marks a deadline hard-kill terminal as cancelled with the deadline reason', async () => {
    const { repo, finalize } = buildHardkillWorker();
    const reportFinalResult = vi.fn().mockResolvedValue(1);

    await finalize({ id: 'j1' }, { reportFinalResult }, false, null, true);

    expect(repo.updateJob).toHaveBeenCalledWith('j1', expect.objectContaining({
      status: 'cancelled',
      abortReason: 'deadline_exceeded',
    }));
    expect(reportFinalResult).toHaveBeenCalledWith('cancelled', expect.stringContaining('実行時間の上限'));
    expect(repo.addAuditLog).toHaveBeenCalledWith('j1', 'job_hardkill', 'worker', expect.objectContaining({ deadline: true }));
    // Not a subtask → no parent requeue.
    expect(repo.requeueParentJobIfAllSubtasksDone).not.toHaveBeenCalled();
  });

  it('uses the plain cancel reason when the hard-kill follows a user cancel', async () => {
    const { repo, finalize } = buildHardkillWorker();
    await finalize({ id: 'j2' }, { reportFinalResult: vi.fn().mockResolvedValue(1) }, false, null, false);
    expect(repo.updateJob).toHaveBeenCalledWith('j2', expect.objectContaining({
      status: 'cancelled',
      abortReason: 'cancelled',
    }));
    expect(repo.addAuditLog).toHaveBeenCalledWith('j2', 'job_hardkill', 'worker', expect.objectContaining({ deadline: false }));
  });

  it('releases a parked parent when the hard-killed run is itself a subtask', async () => {
    const { repo, finalize } = buildHardkillWorker();
    await finalize({ id: 'sub1' }, { reportFinalResult: vi.fn().mockResolvedValue(1) }, true, 'parent1', true);
    expect(repo.requeueParentJobIfAllSubtasksDone).toHaveBeenCalledWith('parent1');
  });
});
