// @vitest-environment node
/**
 * A2aTaskReconciler のユニットテスト。
 * TDD: fake repo + tmp workspace で動作を検証。real DB・real worker は不要。
 */
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { A2aTaskReconciler } from './reconciler.js';
import type { Repository } from '../../db/repository.js';

// ─── fake repo ───────────────────────────────────────────────────────────────

type A2aTaskRow = { id: string; jobId: string | null; payload: Record<string, unknown> };
type SavedA2aTask = {
  id: string;
  contextId: string | null;
  jobId: string | null;
  localTaskId: number | null;
  payload: object;
  delegationId?: string;
  grantId?: string;
  actingUserId?: string;
};

function makeJobStub(status: string, extra: Record<string, unknown> = {}) {
  return {
    id: 'job-1',
    status,
    errorSummary: null,
    abortReason: null,
    worktreePath: null,
    ...extra,
  };
}

function buildFakeRepo(opts: {
  nonTerminalRows: A2aTaskRow[];
  jobs: Map<string, ReturnType<typeof makeJobStub> | null>;
  localTasks?: Map<number, { workspacePath: string | null }>;
}) {
  const store = new Map<string, SavedA2aTask>();

  // Pre-fill store from initial nonTerminalRows (so loadA2aTask works)
  for (const row of opts.nonTerminalRows) {
    store.set(row.id, {
      id: row.id,
      contextId: (row.payload.contextId as string | null) ?? null,
      jobId: row.jobId,
      localTaskId: null,
      payload: row.payload,
    });
  }

  const repo = {
    listNonTerminalA2aTasks: vi.fn((): A2aTaskRow[] => {
      // Return only tasks whose saved payload status is non-terminal
      const terminal = new Set(['completed', 'failed', 'canceled', 'rejected']);
      return opts.nonTerminalRows.filter(row => {
        const saved = store.get(row.id);
        if (!saved) return true;
        const status = (saved.payload as Record<string, unknown>).status as
          | Record<string, unknown>
          | undefined;
        return !status?.state || !terminal.has(status.state as string);
      });
    }),

    getJob: vi.fn(async (id: string) => {
      const j = opts.jobs.get(id);
      return j === undefined ? null : j;
    }),

    getLocalTask: vi.fn(async (id: number) => {
      return opts.localTasks?.get(id) ?? null;
    }),

    saveA2aTask: vi.fn((row: SavedA2aTask) => {
      store.set(row.id, row);
      // Also update the nonTerminalRows payload so subsequent listNonTerminalA2aTasks sees it
      const orig = opts.nonTerminalRows.find(r => r.id === row.id);
      if (orig) {
        orig.payload = row.payload as Record<string, unknown>;
      }
    }),

    // Revocation sweep: return a live delegation by default so existing tests are unaffected.
    // Tests that want to exercise revocation should use a2a-revocation-e2e.test.ts instead.
    getA2aDelegationByGrantId: vi.fn((grantId: string) => ({
      id: 'deleg-fake',
      userId: 'user-1',
      clientId: 'client-fake',
      grantId,
      grantedSpaceIds: [] as string[],
      grantedSkills: [] as string[],
      audience: null as string | null,
      expiresAt: null as string | null,
      revokedAt: null as string | null,
    })),

    // Revocation sweep: cancel linked job (not exercised by existing tests)
    cancelA2aLinkedJob: vi.fn((_jobId: string) => false),

    // Expose store for assertions
    _store: store,
  } as unknown as Repository & { _store: Map<string, SavedA2aTask> };

  return repo;
}

// ─── helpers ─────────────────────────────────────────────────────────────────

function makeWorkingPayload(jobId: string, extra: Record<string, unknown> = {}) {
  return {
    id: 'task-1',
    contextId: 'ctx-1',
    kind: 'task',
    status: { state: 'working', timestamp: '2024-01-01T00:00:00.000Z' },
    metadata: {
      maestroJobId: jobId,
      maestroLocalTaskId: 42,
      a2aDelegationId: null,
      a2aGrantId: null,
      a2aActingUserId: null,
    },
    ...extra,
  };
}

const FIXED_NOW = '2026-07-02T10:00:00.000Z';

// ─── tests ───────────────────────────────────────────────────────────────────

describe('A2aTaskReconciler.reconcileOnce', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reconciler-test-'));
  });

  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  // ── Case 1: succeeded job + output file → completed + artifact ──────────
  it('succeeded job + output/result.txt → completed with artifact', async () => {
    // Arrange: create output/result.txt in a tmp workspace
    const workspacePath = path.join(tmpDir, 'ws1');
    fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
    fs.writeFileSync(path.join(workspacePath, 'output', 'result.txt'), 'hello world');

    const payload = makeWorkingPayload('job-1');
    const row: A2aTaskRow = { id: 'task-1', jobId: 'job-1', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-1', makeJobStub('succeeded')]]),
      localTasks: new Map([[42, { workspacePath }]]),
    });

    const reconciler = new A2aTaskReconciler({
      repo,
      now: () => FIXED_NOW,
    });

    // Act
    const result = await reconciler.reconcileOnce();

    // Assert counts
    expect(result.scanned).toBe(1);
    expect(result.finalized).toBe(1);

    // Assert saved payload
    expect(repo.saveA2aTask).toHaveBeenCalledOnce();
    const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-1')!;
    const savedPayload = saved.payload as Record<string, unknown>;

    // Status → completed
    const status = savedPayload.status as Record<string, unknown>;
    expect(status.state).toBe('completed');
    expect(status.timestamp).toBe(FIXED_NOW);

    // Artifacts → contains result.txt
    const artifacts = savedPayload.artifacts as Array<Record<string, unknown>>;
    expect(artifacts).toHaveLength(1);
    expect(artifacts[0].name).toBe('result.txt');
    const parts = artifacts[0].parts as Array<Record<string, unknown>>;
    expect(parts[0].text).toBe('hello world');
  });

  // ── Case 2: getJob returns null → failed ───────────────────────────────
  it('getJob returns null → task finalized as failed with "job not found"', async () => {
    const payload = makeWorkingPayload('job-missing');
    const row: A2aTaskRow = { id: 'task-2', jobId: 'job-missing', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-missing', null]]),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    const result = await reconciler.reconcileOnce();

    expect(result.finalized).toBe(1);
    const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-2')!;
    const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
    expect(status.state).toBe('failed');
    // message should mention 'job not found'
    const msg = status.message as Record<string, unknown>;
    expect((msg.parts as Array<Record<string, unknown>>)[0].text).toContain('job not found');
  });

  // ── Case 3: running job → untouched ────────────────────────────────────
  it('running job → task unchanged, finalized=0', async () => {
    const payload = makeWorkingPayload('job-running');
    const row: A2aTaskRow = { id: 'task-3', jobId: 'job-running', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-running', makeJobStub('running')]]),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    const result = await reconciler.reconcileOnce();

    expect(result.finalized).toBe(0);
    expect(repo.saveA2aTask).not.toHaveBeenCalled();
  });

  // ── Case 4: null jobId → skipped ───────────────────────────────────────
  it('null jobId (just enqueued) → skipped', async () => {
    const payload = makeWorkingPayload('irrelevant');
    const row: A2aTaskRow = { id: 'task-4', jobId: null, payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map(),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    const result = await reconciler.reconcileOnce();

    expect(result.scanned).toBe(1);
    expect(result.finalized).toBe(0);
    expect(repo.getJob).not.toHaveBeenCalled();
  });

  // ── Case 5: idempotent — completed task not revisited ──────────────────
  it('reconcileOnce twice → completed task not finalized again (idempotent)', async () => {
    const workspacePath = path.join(tmpDir, 'ws5');
    fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
    fs.writeFileSync(path.join(workspacePath, 'output', 'out.txt'), 'done');

    const payload = makeWorkingPayload('job-5');
    const row: A2aTaskRow = { id: 'task-5', jobId: 'job-5', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-5', makeJobStub('succeeded')]]),
      localTasks: new Map([[42, { workspacePath }]]),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });

    // First tick — should finalize
    const r1 = await reconciler.reconcileOnce();
    expect(r1.finalized).toBe(1);

    // After first tick, the task's payload.status.state is 'completed'
    // listNonTerminalA2aTasks mock will exclude it
    const r2 = await reconciler.reconcileOnce();
    expect(r2.scanned).toBe(0); // task no longer in non-terminal list
    expect(r2.finalized).toBe(0);

    // saveA2aTask called exactly once (first tick only)
    expect(repo.saveA2aTask).toHaveBeenCalledTimes(1);
  });

  // ── Case 6: delegation columns preserved ───────────────────────────────
  it('delegation columns from payload.metadata are passed through to saveA2aTask', async () => {
    const payload = makeWorkingPayload('job-deleg', {
      metadata: {
        maestroJobId: 'job-deleg',
        maestroLocalTaskId: null,
        a2aDelegationId: 'deleg-id-123',
        a2aGrantId: 'grant-id-456',
        a2aActingUserId: 'user-789',
      },
    });
    const row: A2aTaskRow = { id: 'task-6', jobId: 'job-deleg', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-deleg', makeJobStub('failed', { errorSummary: 'oops' })]]),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    await reconciler.reconcileOnce();

    expect(repo.saveA2aTask).toHaveBeenCalledOnce();
    const call = (repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as SavedA2aTask;
    expect(call.delegationId).toBe('deleg-id-123');
    expect(call.grantId).toBe('grant-id-456');
    expect(call.actingUserId).toBe('user-789');

    // Status should be failed with errorSummary
    const savedPayload = call.payload as Record<string, unknown>;
    const status = savedPayload.status as Record<string, unknown>;
    expect(status.state).toBe('failed');
    const msg = status.message as Record<string, unknown>;
    expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('oops');
  });

  // ── Case 7: failed job → failed A2A task ───────────────────────────────
  it('failed job → task finalized as failed', async () => {
    const payload = makeWorkingPayload('job-fail');
    const row: A2aTaskRow = { id: 'task-7', jobId: 'job-fail', payload };

    const repo = buildFakeRepo({
      nonTerminalRows: [row],
      jobs: new Map([['job-fail', makeJobStub('failed', { errorSummary: 'something broke' })]]),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    const result = await reconciler.reconcileOnce();

    expect(result.finalized).toBe(1);
    const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-7')!;
    const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
    expect(status.state).toBe('failed');
    const msg = status.message as Record<string, unknown>;
    expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('something broke');
  });

  // ── Case 8: per-row error doesn't abort full scan ──────────────────────
  it('one bad row (throws) does not abort the rest', async () => {
    const goodPayload = makeWorkingPayload('job-good');
    const badPayload = makeWorkingPayload('job-bad');

    const rows: A2aTaskRow[] = [
      { id: 'bad-task', jobId: 'job-bad', payload: badPayload },
      { id: 'good-task', jobId: 'job-good', payload: goodPayload },
    ];

    const repo = buildFakeRepo({
      nonTerminalRows: rows,
      jobs: new Map([
        ['job-bad', makeJobStub('succeeded')], // will throw in getLocalTask
        ['job-good', makeJobStub('failed', { errorSummary: 'err' })],
      ]),
      localTasks: new Map(), // no workspace → enumerateOutputArtifacts returns []
    });

    // Override getLocalTask to throw for bad-task
    let callCount = 0;
    (repo.getLocalTask as ReturnType<typeof vi.fn>).mockImplementation(async (_id: number) => {
      callCount++;
      if (callCount === 1) throw new Error('simulated DB error');
      return null;
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
    // Should not throw
    const result = await reconciler.reconcileOnce();

    // good-task should still be finalized
    expect(result.scanned).toBe(2);
    // bad-task errored, good-task succeeded
    expect(result.finalized).toBe(1);
    const goodSaved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('good-task')!;
    const status = (goodSaved.payload as Record<string, unknown>).status as Record<string, unknown>;
    expect(status.state).toBe('failed');
  });
  // ── Case 9: waiting_human → input-required churn prevention ────────────────
  it('waiting_human job: second tick does NOT re-write input-required (state-equality skip)', async () => {
    const payload = makeWorkingPayload('job-waiting');
    const rows: A2aTaskRow[] = [{ id: 'task-wh', jobId: 'job-waiting', payload }];

    const repo = buildFakeRepo({
      nonTerminalRows: rows,
      jobs: new Map([['job-waiting', makeJobStub('waiting_human')]]),
      localTasks: new Map(),
    });

    const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });

    // First tick: working → input-required
    const r1 = await reconciler.reconcileOnce();
    expect(r1.finalized).toBe(1);
    const saved1 = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-wh')!;
    expect((saved1.payload as Record<string, unknown>).status).toMatchObject({ state: 'input-required' });

    // Second tick: job still waiting_human, but A2A state is already input-required → skip
    const r2 = await reconciler.reconcileOnce();
    expect(r2.finalized).toBe(0);

    // saveA2aTask was called exactly once across both ticks
    expect((repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
  });
});

// ─── start / stop mechanics ─────────────────────────────────────────────────

describe('A2aTaskReconciler start/stop', () => {
  it('start() calls reconcileOnce immediately and then on interval', async () => {
    const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
    const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });

    const spy = vi.spyOn(reconciler, 'reconcileOnce');
    reconciler.start();

    // Immediate call should have been triggered (async, small wait)
    await new Promise(r => setTimeout(r, 10));
    expect(spy).toHaveBeenCalledTimes(1);

    // Wait for at least one interval tick
    await new Promise(r => setTimeout(r, 80));
    expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2);

    reconciler.stop();
  });

  it('stop() prevents further reconcileOnce calls', async () => {
    const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
    const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });

    const spy = vi.spyOn(reconciler, 'reconcileOnce');
    reconciler.start();
    await new Promise(r => setTimeout(r, 10));
    reconciler.stop();
    const callsAfterStop = spy.mock.calls.length;

    // Wait past one interval to confirm no more calls
    await new Promise(r => setTimeout(r, 80));
    expect(spy.mock.calls.length).toBe(callsAfterStop);
  });
});
