// @vitest-environment node
/**
 * MaestroA2aExecutor のユニットテスト。
 * 実 Worker / 実 LLM は使わない。fake repo + fake eventBus でブリッジ/イベント/Artifact を検証。
 */
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, expect, vi } from 'vitest';
import { MaestroA2aExecutor } from './executor.js';
import { A2aLimiter, DEFAULT_A2A_LIMITS } from './limiter.js';

// ── fake helpers ─────────────────────────────────────────────────────────────

function makeFakeEventBus() {
  const published: unknown[] = [];
  let isFinished = false;
  const bus = {
    published,
    get finishedCalled() { return isFinished; },
    publish(event: unknown) { published.push(event); },
    finished() { isFinished = true; },
    on() { return bus; },
    off() { return bus; },
    once() { return bus; },
    removeAllListeners() { return bus; },
  };
  return bus;
}

/** RequestContext のモック。 */
function makeRequestContext(opts: {
  taskId?: string;
  contextId?: string;
  a2aPrincipal?: unknown;
  messageText?: string;
  skillId?: string;
} = {}) {
  const {
    taskId = 'task-1',
    contextId = 'ctx-1',
    a2aPrincipal,
    messageText = 'research something',
    skillId,
  } = opts;
  return {
    taskId,
    contextId,
    userMessage: {
      kind: 'message',
      messageId: 'msg-1',
      role: 'user',
      parts: [{ kind: 'text', text: messageText }],
      metadata: skillId ? { skillId } : {},
    },
    context: a2aPrincipal
      ? { user: { a2aPrincipal, isAuthenticated: true, userName: 'u1' } }
      : undefined,
  };
}

/**
 * Fake repo ファクトリ。
 * jobSequence: getJob を呼ぶ度にこの配列を順番に返す（最後の要素を繰り返す）。
 *
 * Fix 1: workspacePath は createLocalTask の戻り値には使わない（本番は null）。
 *        代わりに getLocalTask が返す値として使い、re-fetch 経路を検証する。
 */
function makeFakeRepo(opts: {
  spaceSkills?: string[];
  jobSequence?: Array<{ status: string; currentActivity?: string | null; errorSummary?: string | null; abortReason?: string | null; worktreePath?: string | null }>;
  workspacePath?: string | null;
  userStatus?: string;
} = {}) {
  const {
    spaceSkills = ['research'],
    jobSequence = [
      { status: 'queued', currentActivity: null },
      { status: 'running', currentActivity: 'step1' },
      { status: 'succeeded', currentActivity: null, errorSummary: null, abortReason: null },
    ],
    workspacePath = null,
    userStatus = 'active',
  } = opts;

  let callCount = 0;
  const space = { id: 's1', ownerId: 'u1', status: 'open', name: 'Space 1', visibility: 'private' };
  const user = { id: 'u1', role: 'user', status: userStatus, name: 'User One' };

  return {
    getUserById: vi.fn().mockReturnValue(user.status === 'active' ? user : { ...user, status: userStatus }),
    listSpaces: vi.fn().mockResolvedValue([space]),
    getSpaceA2aSkills: vi.fn().mockReturnValue(spaceSkills),
    listUserGiteaOrgs: vi.fn().mockReturnValue([]),
    listUserLocalOrgs: vi.fn().mockReturnValue([]),
    getUserGiteaOrgs: vi.fn().mockReturnValue([]),
    // Fix 1: createLocalTask always returns workspacePath: null (matches production behaviour —
    // Worker backfills workspacePath at dispatch time, not at creation time).
    createLocalTask: vi.fn().mockImplementation(async (params: unknown) => ({
      id: 1,
      workspacePath: null,
      ...(params as object),
    })),
    // Fix 1: getLocalTask returns the backfilled workspacePath (simulates Worker dispatch).
    getLocalTask: vi.fn().mockImplementation(async () => ({
      id: 1,
      workspacePath,
    })),
    createJob: vi.fn().mockResolvedValue({ id: 'job-1' }),
    getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'job-1' }),
    getJob: vi.fn().mockImplementation(async () => {
      const idx = Math.min(callCount, jobSequence.length - 1);
      callCount++;
      return jobSequence[idx];
    }),
    requestJobCancel: vi.fn().mockReturnValue(true),
    cancelA2aLinkedJob: vi.fn().mockReturnValue(true),
    addAuditLog: vi.fn(),
    countNonTerminalA2aTasksByGrant: vi.fn().mockReturnValue(0),
    // Task 5: revocation recheck — return a live delegation by default so existing tests are unaffected
    getA2aDelegationByGrantId: vi.fn().mockReturnValue({
      id: 'deleg-1',
      userId: 'u1',
      clientId: 'client-1',
      grantId: 'grant-1',
      grantedSpaceIds: ['s1'],
      grantedSkills: ['research'],
      audience: null,
      expiresAt: null,
      revokedAt: null,
    }),
  };
}

// デフォルトの A2A principal（空間 s1 で research スキルを委任）
const DEFAULT_PRINCIPAL = {
  actingUserId: 'u1',
  clientId: 'client-1',
  grantId: 'grant-1',
  delegation: {
    // Task 5: id は委任行の PK（a2aDelegationId に使用）
    id: 'deleg-1',
    grantedSpaceIds: ['s1'],
    grantedSkills: ['research'],
    expiresAt: null,
    revokedAt: null,
  },
};

// ── テスト ────────────────────────────────────────────────────────────────────

describe('MaestroA2aExecutor', () => {
  describe('execute — happy path', () => {
    it('working イベント + artifact + completed が publish され finished() が呼ばれる', async () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
      try {
        const outputDir = path.join(tmpDir, 'output');
        fs.mkdirSync(outputDir);
        fs.writeFileSync(path.join(outputDir, 'result.txt'), 'Hello result');

        // Fix 1: workspacePath is passed so getLocalTask returns it (createLocalTask returns null).
        const repo = makeFakeRepo({ workspacePath: tmpDir });
        const eventBus = makeFakeEventBus();
        const executor = new MaestroA2aExecutor(repo as any, {
          pollMs: 0,
          sleep: async () => {},
        });
        const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

        await executor.execute(ctx as any, eventBus as any);

        expect(eventBus.finishedCalled).toBe(true);

        // Fix 1 verification: createLocalTask returned workspacePath=null; getLocalTask
        // returned the real path, and the artifact was still found via the re-fetch.
        expect(repo.createLocalTask).toHaveBeenCalled();
        const createCallResult = await repo.createLocalTask.mock.results[0].value;
        expect(createCallResult.workspacePath).toBeNull();
        expect(repo.getLocalTask).toHaveBeenCalledWith(1);

        // 初期 Task（submitted）が先頭に publish されている
        const initialTask = eventBus.published.find((e: any) => e.kind === 'task') as any;
        expect(initialTask).toBeDefined();
        expect(initialTask.id).toBe('task-1');
        expect(initialTask.metadata?.maestroJobId).toBe('job-1');
        expect(initialTask.metadata?.maestroLocalTaskId).toBe(1);
        // Task 3: 委任 metadata が初期 Task に含まれる
        expect(initialTask.metadata?.a2aActingUserId).toBe('u1');
        expect(initialTask.metadata?.a2aGrantId).toBe('grant-1');
        // Task 5 (2C-1): a2aDelegationId は委任行の PK（delegation.id）、a2aGrantId とは別物
        expect(initialTask.metadata?.a2aDelegationId).toBe('deleg-1');
        expect(initialTask.status.state).toBe('submitted');

        // working 進捗が少なくとも 1 回 publish されている
        const workingEvents = eventBus.published.filter(
          (e: any) => e.kind === 'status-update' && e.status?.state === 'working',
        );
        expect(workingEvents.length).toBeGreaterThan(0);
        // final: false であること
        for (const ev of workingEvents) {
          expect((ev as any).final).toBe(false);
        }

        // result.txt の artifact-update が publish されている（re-fetch 経路で取得）
        const artifactEvents = eventBus.published.filter(
          (e: any) => e.kind === 'artifact-update',
        );
        expect(artifactEvents.length).toBe(1);
        const art = (artifactEvents[0] as any).artifact;
        expect(art.name).toBe('result.txt');
        expect(art.parts[0].kind).toBe('text');
        expect(art.parts[0].text).toBe('Hello result');

        // 最終 completed（final: true）が publish されている
        const completedEvent = eventBus.published.find(
          (e: any) => e.kind === 'status-update' && e.status?.state === 'completed' && e.final === true,
        );
        expect(completedEvent).toBeDefined();

        // T8: A2A enqueue 経路で audit log が書き込まれること
        expect(repo.addAuditLog).toHaveBeenCalledWith(
          'job-1',
          'a2a_job_queued',
          'a2a',
          expect.objectContaining({ taskId: 1, actingUserId: 'u1' }),
        );

        // createLocalTask が正しい引数で呼ばれた
        expect(repo.createLocalTask).toHaveBeenCalledWith(
          expect.objectContaining({
            pieceName: 'research',
            spaceId: 's1',
            ownerId: 'u1',
            visibility: 'private',
            workspaceMode: 'persistent',
          }),
        );
      } finally {
        fs.rmSync(tmpDir, { recursive: true });
      }
    });
  });

  describe('execute — out-of-scope', () => {
    it('effectiveSkills が空 → failed publish + finished、createLocalTask は呼ばれない', async () => {
      // spaceSkills = [] → effectiveSkills が空 → selectPieceForMessage は null
      const repo = makeFakeRepo({ spaceSkills: [] });
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      expect(repo.createLocalTask).not.toHaveBeenCalled();

      // Task 4: pre-initial-Task denial must publish a full Task object (kind:'task')
      // so the SDK ResultManager can save and return it. A bare status-update would be
      // silently dropped, leaving a blocking message/send with no result.
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'failed',
      );
      expect(failedEvent).toBeDefined();
      // Must NOT be a bare status-update
      expect(eventBus.published.some((e: any) => e.kind === 'status-update')).toBe(false);
    });
  });

  describe('execute — no principal', () => {
    it('context に a2aPrincipal が無い → failed Task (kind:task) + finished', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
      });
      // a2aPrincipal を渡さない
      const ctx = makeRequestContext();

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      // Task 4: pre-initial-Task denial → full Task object, NOT a bare status-update
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'failed',
      );
      expect(failedEvent).toBeDefined();
      expect(failedEvent).toMatchObject({ id: 'task-1', contextId: 'ctx-1', kind: 'task' });
      // Must NOT be a bare status-update
      expect(eventBus.published.some((e: any) => e.kind === 'status-update')).toBe(false);
      // createLocalTask は呼ばれない
      expect(repo.createLocalTask).not.toHaveBeenCalled();
    });
  });

  describe('execute — job failed', () => {
    it('job が failed → failed status-update が publish される', async () => {
      const repo = makeFakeRepo({
        jobSequence: [
          { status: 'queued', currentActivity: null },
          { status: 'failed', currentActivity: null, errorSummary: 'something broke', abortReason: null },
        ],
      });
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true,
      );
      expect(failedEvent).toBeDefined();
      expect((failedEvent as any).status.message.parts[0].text).toBe('something broke');
    });
  });

  // Fix 2: execute() が内部エラーを throw → failed Task + finished()（ストリームハングなし）
  // Task 4: createLocalTask throws before initialTask is published → outer catch fires with
  //         initialTaskPublished=false → publishTerminal emits kind:'task' (not status-update)
  describe('execute — internal error (Fix 2 + Task 4)', () => {
    it('createLocalTask が throw → failed Task (kind:task) が publish され finished() が呼ばれる', async () => {
      const baseRepo = makeFakeRepo();
      const repo = {
        ...baseRepo,
        createLocalTask: vi.fn().mockRejectedValue(new Error('DB connection failed')),
      };
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      // SSE ストリームがハングしないこと
      expect(eventBus.finishedCalled).toBe(true);
      // Task 4: pre-initial-Task throw → full Task object so ResultManager can save it
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'failed',
      );
      expect(failedEvent).toBeDefined();
      // 内部エラーの詳細（"DB connection failed"）はクライアントに漏れない
      const msg = (failedEvent as any).status?.message?.parts?.[0]?.text ?? '';
      expect(msg).not.toContain('DB connection failed');
    });
  });

  // Fix 3: バイナリ / サイズ超過ファイルの安全な Artifact 処理
  describe('execute — artifact safety (Fix 3)', () => {
    it('バイナリファイル → descriptive artifact、通常テキストはそのままインライン', async () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
      try {
        const outputDir = path.join(tmpDir, 'output');
        fs.mkdirSync(outputDir);

        // null byte を含むバイナリファイル
        const binaryBuf = Buffer.alloc(100, 0x41); // 'A' × 100
        binaryBuf[10] = 0x00; // null byte
        fs.writeFileSync(path.join(outputDir, 'data.bin'), binaryBuf);

        // 通常テキストファイル
        fs.writeFileSync(path.join(outputDir, 'readme.txt'), 'Hello text');

        const repo = makeFakeRepo({ workspacePath: tmpDir });
        const eventBus = makeFakeEventBus();
        const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
        const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

        await executor.execute(ctx as any, eventBus as any);

        const artifactEvents = eventBus.published.filter(
          (e: any) => e.kind === 'artifact-update',
        ) as any[];
        expect(artifactEvents.length).toBe(2);

        const binArt = artifactEvents.find(e => e.artifact.name === 'data.bin');
        expect(binArt).toBeDefined();
        // バイナリは descriptive メッセージになる（null byte を含まない）
        expect(binArt.artifact.parts[0].text).toMatch(/binary file data\.bin/);
        expect(binArt.artifact.parts[0].text).toMatch(/not inlined/);
        expect(binArt.artifact.parts[0].text).not.toContain('\0');

        const txtArt = artifactEvents.find(e => e.artifact.name === 'readme.txt');
        expect(txtArt).toBeDefined();
        // 通常テキストはそのままインライン
        expect(txtArt.artifact.parts[0].text).toBe('Hello text');
      } finally {
        fs.rmSync(tmpDir, { recursive: true });
      }
    });

    it('サイズ超過ファイル（> 1 MiB）→ descriptive artifact（インラインしない）', async () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-exec-test-'));
      try {
        const outputDir = path.join(tmpDir, 'output');
        fs.mkdirSync(outputDir);

        // 1 MiB + 1 byte のファイル（上限超過）
        const bigBuf = Buffer.alloc(1024 * 1024 + 1, 0x78); // 'x' × (1MiB+1)
        fs.writeFileSync(path.join(outputDir, 'bigfile.txt'), bigBuf);

        const repo = makeFakeRepo({ workspacePath: tmpDir });
        const eventBus = makeFakeEventBus();
        const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
        const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

        await executor.execute(ctx as any, eventBus as any);

        const artifactEvents = eventBus.published.filter(
          (e: any) => e.kind === 'artifact-update',
        ) as any[];
        expect(artifactEvents.length).toBe(1);
        const art = artifactEvents[0].artifact;
        expect(art.name).toBe('bigfile.txt');
        // サイズ超過は descriptive メッセージ
        expect(art.parts[0].text).toMatch(/omitted/);
        expect(art.parts[0].text).toMatch(/exceeds inline limit/);
        // ファイル全体の内容はインラインされない（'x' が大量に出てこない）
        expect(art.parts[0].text.length).toBeLessThan(200);
      } finally {
        fs.rmSync(tmpDir, { recursive: true });
      }
    });
  });

  // Task 3: poll-cap タイムアウト時に cancelA2aLinkedJob が呼ばれ、failed が publish される
  describe('execute — poll-cap cancel (Task 3)', () => {
    it('poll cap 到達 → cancelA2aLinkedJob(jobId) が呼ばれ failed(final) が publish される', async () => {
      // getJob が常に非 terminal を返す（ループが上限まで回る）
      const repo = makeFakeRepo({
        jobSequence: [
          { status: 'running', currentActivity: 'stuck' },
        ],
      });
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        maxPollIterations: 2,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);

      // cancelA2aLinkedJob が jobId='job-1' で呼ばれていること（queued/retry 含む全非終端対象）
      expect(repo.cancelA2aLinkedJob).toHaveBeenCalledWith('job-1');

      // failed(final) が publish されていること
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true,
      );
      expect(failedEvent).toBeDefined();
      expect((failedEvent as any).status.message.parts[0].text).toMatch(/timeout/);
    });
  });

  describe('cancelTask', () => {
    it('execute 後に job が cancelled → A2A canceled イベントが publish される（Fix 5）', async () => {
      const repo = makeFakeRepo({
        // running → cancelled のシーケンス
        jobSequence: [
          { status: 'running', currentActivity: 'step1' },
          { status: 'cancelled', currentActivity: null, errorSummary: null, abortReason: null },
        ],
      });
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);

      // Fix 5: job が 'cancelled' になったら A2A 'canceled'（'failed' ではない）
      const canceledEvent = eventBus.published.find(
        (e: any) => e.kind === 'status-update' && e.status?.state === 'canceled' && e.final === true,
      );
      expect(canceledEvent).toBeDefined();

      // 'failed' イベントは出ないこと
      const failedEvent = eventBus.published.find(
        (e: any) => e.kind === 'status-update' && e.status?.state === 'failed',
      );
      expect(failedEvent).toBeUndefined();
    });

    it('cancelTask 単独で呼ぶと canceled イベント + finished が来る', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });

      await executor.cancelTask('task-999', eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const canceledEvent = eventBus.published.find(
        (e: any) => e.kind === 'status-update' && e.status?.state === 'canceled',
      );
      expect(canceledEvent).toBeDefined();
      expect((canceledEvent as any).final).toBe(true);
    });

    // Fix 4: map cleanup verification
    it('execute 完了後にマップが掃除される（unbounded leak なし）', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      // execute 後はマップから削除されている — cancelTask を呼んでもジョブ cancel は呼ばれない
      const cancelBus = makeFakeEventBus();
      await executor.cancelTask('task-1', cancelBus as any);
      // jobId が消えているので cancel 系は呼ばれない
      expect(repo.cancelA2aLinkedJob).not.toHaveBeenCalled();
      expect(repo.requestJobCancel).not.toHaveBeenCalled();
    });

    // sonnet review Finding 1: cancelTask は in-flight ジョブを broad cancel すべき
    it('cancelTask は in-flight ジョブを cancelA2aLinkedJob で取消す（queued/waiting も対象）', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const executor = new MaestroA2aExecutor(repo as any, { pollMs: 0, sleep: async () => {} });
      // in-flight タスクを再現: マップに jobId を直接 seed（execute 進行中と同状態）
      (executor as any).jobIdByA2aTaskId.set('task-x', 'job-x');

      await executor.cancelTask('task-x', eventBus as any);

      // narrow な requestJobCancel ではなく全非終端対象の cancelA2aLinkedJob を使う
      expect(repo.cancelA2aLinkedJob).toHaveBeenCalledWith('job-x');
      expect(repo.requestJobCancel).not.toHaveBeenCalled();
    });
  });

  // ── Task 5: ガバナンスガード ─────────────────────────────────────────────────
  describe('execute — governance guards (Task 5)', () => {
    const NOW_ISO = '2026-07-07T00:00:00.000Z';
    const nowFn = () => NOW_ISO;

    it('payload guard: oversized message → rejected Task (kind:task), createLocalTask not called', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      // maxPayloadBytes: 1 なので userMessage を JSON 化した時点で確実にオーバー
      const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 1 }, () => 0);
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL, messageText: 'hello' });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      // kind:'task' (pre-initial-Task path) with state='rejected'
      const rejected = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'rejected',
      );
      expect(rejected).toBeDefined();
      // No job row created
      expect(repo.createLocalTask).not.toHaveBeenCalled();
    });

    it('rate guard: rate bucket drained → rejected Task (kind:task), createLocalTask not called', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, () => 0);
      // Pre-drain: consume the 1 available token so the next call is rejected
      limiter.tryConsumeRate('grant-1');
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const rejected = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'rejected',
      );
      expect(rejected).toBeDefined();
      expect(repo.createLocalTask).not.toHaveBeenCalled();
    });

    it('concurrency guard: db count at cap → rejected Task (kind:task), createLocalTask not called', async () => {
      const repo = makeFakeRepo();
      // Seed the mock so countNonTerminalA2aTasksByGrant returns at-cap value
      (repo.countNonTerminalA2aTasksByGrant as any).mockReturnValue(5);
      const eventBus = makeFakeEventBus();
      const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 5 }, () => 0);
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const rejected = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'rejected',
      );
      expect(rejected).toBeDefined();
      expect(repo.createLocalTask).not.toHaveBeenCalled();
    });

    it('skill budget guard: budget exhausted → rejected Task (kind:task), createLocalTask not called', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1 }, () => 0);
      // Pre-drain: exhaust the 1 available skill budget slot
      limiter.tryConsumeSkillBudget('grant-1');
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const rejected = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'rejected',
      );
      expect(rejected).toBeDefined();
      // Guard fires before createLocalTask — no DB row is created and no cancel is needed
      expect(repo.createLocalTask).not.toHaveBeenCalled();
      expect(repo.cancelA2aLinkedJob).not.toHaveBeenCalled();
    });

    it('happy path with generous limiter → initial submitted Task published', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      // All default limits are generous; task should proceed normally
      const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS }, () => Date.now());
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      await executor.execute(ctx as any, eventBus as any);

      expect(eventBus.finishedCalled).toBe(true);
      const submitted = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'submitted',
      );
      expect(submitted).toBeDefined();
    });

    it('reservation release: skill_budget reject after reservation → slot freed, no leak', async () => {
      const repo = makeFakeRepo();
      const eventBus = makeFakeEventBus();
      const limiter = new A2aLimiter(
        { ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1, maxConcurrentTasks: 2 },
        () => 0,
      );
      // Pre-drain skill budget only, leaving concurrency intact
      limiter.tryConsumeSkillBudget('grant-1');
      const executor = new MaestroA2aExecutor(repo as any, {
        pollMs: 0,
        sleep: async () => {},
        now: nowFn,
        limiter,
      });
      const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });

      // Execute → concurrency reserved, then skill_budget reject, then finally releases concurrency
      await executor.execute(ctx as any, eventBus as any);

      const rejected = eventBus.published.find(
        (e: any) => e.kind === 'task' && e.status?.state === 'rejected',
      );
      expect(rejected).toBeDefined();

      // After the exit, the reservation must be freed — new reservation at dbCount=0 should succeed
      expect(limiter.tryReserveConcurrency('grant-1', 0)).toBe(true);
      limiter.releaseConcurrency('grant-1'); // cleanup
    });
  });
});
