/**
 * MaestroA2aExecutor — inbound A2A message → MAESTRO job → A2A events + artifacts
 *
 * implements AgentExecutor from @a2a-js/sdk/server:
 *   execute(requestContext, eventBus): enqueue MAESTRO job, poll, publish SSE, emit artifacts
 *   cancelTask(taskId, eventBus):     best-effort job cancel + publish canceled event
 */

import type { AgentExecutor, ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import type { Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '@a2a-js/sdk';
import type { Repository } from '../../db/repository.js';
import { localTaskRepoName } from '../../db/repository.js';
import { computeEffectiveScope, isDelegationLive, type A2aPrincipalLike } from './delegation.js';
import type { A2aPrincipal } from './token-auth.js';
import { selectPieceForMessage } from './skill-router.js';
import { logger } from '../../logger.js';
import { enumerateOutputArtifacts, finalizeStatusFromJob, TERMINAL_JOB_STATUSES } from './task-finalize.js';
import { A2aLimiter } from './limiter.js';

export interface MaestroA2aExecutorDeps {
  /** ポーリング間隔 (ms)。デフォルト 1000。テストでは 0 を注入。 */
  pollMs?: number;
  /** sleep 実装。テストでは no-op を注入してループを同期的に回す。 */
  sleep?: (ms: number) => Promise<void>;
  /** 現在時刻 ISO 文字列。テスト決定論化のため注入可能。 */
  now?: () => string;
  /** ポーリング上限イテレーション数。デフォルト 600（1s×600 = 10 分）。 */
  maxPollIterations?: number;
  /** ガバナンスリミッター（Task 5）。省略時はガバナンスチェックをスキップ。 */
  limiter?: A2aLimiter;
}

// TERMINAL_STATUSES は task-finalize.ts の TERMINAL_JOB_STATUSES を使用（ドリフト防止）
const TERMINAL_STATUSES = TERMINAL_JOB_STATUSES;

export class MaestroA2aExecutor implements AgentExecutor {
  private readonly pollMs: number;
  private readonly sleep: (ms: number) => Promise<void>;
  private readonly now: () => string;
  private readonly maxPollIterations: number;
  private readonly limiter?: A2aLimiter;

  /** A2A taskId → Maestro jobId のマッピング（cancelTask に使用）。 */
  private readonly jobIdByA2aTaskId = new Map<string, string>();
  /** A2A taskId → contextId のマッピング（cancelTask でイベントを組み立てるために保持）。 */
  private readonly contextIdByA2aTaskId = new Map<string, string>();

  constructor(
    private readonly repo: Repository,
    deps: MaestroA2aExecutorDeps = {},
  ) {
    this.pollMs = deps.pollMs ?? 1000;
    this.sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
    this.now = deps.now ?? (() => new Date().toISOString());
    this.limiter = deps.limiter;
    if (deps.maxPollIterations !== undefined) {
      this.maxPollIterations = deps.maxPollIterations;
    } else if (deps.limiter && this.pollMs > 0) {
      this.maxPollIterations = Math.max(1, Math.ceil(deps.limiter.limits.maxStreamSeconds * 1000 / this.pollMs));
    } else {
      this.maxPollIterations = 600;
    }
  }

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

  /**
   * agent ロールのメッセージオブジェクトを組み立てる。
   * SDK の TaskStatus.message は Message2 (内部型) を要求するが、
   * Message と Message2 は構造的に同一のため any 経由で渡す。
   */
  private makeStatusMessage(text: string, contextId: string, taskId: string): any {
    return {
      kind: 'message',
      messageId: crypto.randomUUID(),
      role: 'agent',
      parts: [{ kind: 'text', text }],
      contextId,
      taskId,
    };
  }

  private publishFailedFinal(
    eventBus: ExecutionEventBus,
    taskId: string,
    contextId: string,
    reason: string,
  ): void {
    const event: TaskStatusUpdateEvent = {
      kind: 'status-update',
      taskId,
      contextId,
      status: {
        state: 'failed',
        message: this.makeStatusMessage(reason, contextId, taskId),
        timestamp: this.now(),
      } as TaskStatusUpdateEvent['status'],
      final: true,
    };
    eventBus.publish(event);
  }

  /** Parallel to publishFailedFinal — publishes a terminal `canceled` event (A2A state, one 'l'). */
  private publishCanceledFinal(
    eventBus: ExecutionEventBus,
    taskId: string,
    contextId: string,
    reason: string,
  ): void {
    const event: TaskStatusUpdateEvent = {
      kind: 'status-update',
      taskId,
      contextId,
      status: {
        state: 'canceled',
        message: this.makeStatusMessage(reason, contextId, taskId),
        timestamp: this.now(),
      } as TaskStatusUpdateEvent['status'],
      final: true,
    };
    eventBus.publish(event);
  }

  /**
   * Unified terminal publisher (Task 4).
   *
   * When the initial Task event has NOT yet been published, the SDK's ResultManager
   * has no entry for this taskId, so a bare status-update would be silently dropped.
   * In that case we emit a full Task object (`kind:'task'`) which ResultManager saves
   * and returns to the blocking caller. Once the initial Task is in the store
   * (`initialPublished === true`) we fall back to a status-update (`final: true`),
   * the same shape that publishFailedFinal / publishCanceledFinal have always produced.
   */
  private publishTerminal(
    eventBus: ExecutionEventBus,
    taskId: string,
    contextId: string,
    state: 'failed' | 'rejected' | 'canceled',
    reason: string,
    initialPublished: boolean,
    metadata?: Record<string, unknown>,
  ): void {
    if (!initialPublished) {
      const task: Task = {
        id: taskId,
        contextId,
        kind: 'task',
        status: {
          state,
          message: this.makeStatusMessage(reason, contextId, taskId),
          timestamp: this.now(),
        },
        ...(metadata ? { metadata } : {}),
      };
      eventBus.publish(task); // ResultManager saves + returns this terminal Task
      return;
    }
    const ev: TaskStatusUpdateEvent = {
      kind: 'status-update',
      taskId,
      contextId,
      status: {
        state,
        message: this.makeStatusMessage(reason, contextId, taskId),
        timestamp: this.now(),
      } as TaskStatusUpdateEvent['status'],
      final: true,
    };
    eventBus.publish(ev);
  }

  // ── AgentExecutor.execute ─────────────────────────────────────────────────

  execute = async (requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> => {
    const { taskId, contextId } = requestContext;

    // Fix 2: guard finished() so it is called exactly once regardless of path
    let finishedCalled = false;
    // Task 4: track whether the initial Task event has been published so publishTerminal
    // knows whether to emit a full Task object (required by SDK ResultManager for pre-initial
    // denial paths) or a bare status-update (valid once the Task is in the store).
    let initialTaskPublished = false;
    // Task 5: concurrency reservation — must be declared above try so finally can release it.
    let reservedGrant: string | null = null;
    const safeFinished = () => {
      if (!finishedCalled) {
        finishedCalled = true;
        eventBus.finished();
      }
    };

    try {
      // 1. principal を取得（Task 4 が ServerCallContext.user に注入する）
      const principal = (requestContext.context?.user as Record<string, unknown> | undefined)
        ?.a2aPrincipal as A2aPrincipal | undefined;

      if (!principal) {
        this.publishTerminal(eventBus, taskId, contextId, 'failed', 'unauthorized: no A2A principal', initialTaskPublished);
        safeFinished();
        return;
      }

      // 1b. ガバナンス: payload サイズ + レートリミット（scope 算出前に弾く = flood に scope DB 処理させない）
      if (this.limiter) {
        const L = this.limiter.limits;
        const bytes = Buffer.byteLength(JSON.stringify(requestContext.userMessage ?? {}), 'utf8');
        if (bytes > L.maxPayloadBytes) {
          await this.rejectGoverned(eventBus, taskId, contextId, principal, `payload too large: ${bytes} > ${L.maxPayloadBytes}`, 'payload', initialTaskPublished);
          safeFinished();
          return;
        }
        if (!this.limiter.tryConsumeRate(principal.grantId)) {
          await this.rejectGoverned(eventBus, taskId, contextId, principal, `rate limit exceeded (${L.ratePerMinute}/min)`, 'rate', initialTaskPublished);
          safeFinished();
          return;
        }
      }

      // 2. acting user の実際の可視スコープを算出（IDOR 防止・fail-closed）
      const scope = await computeEffectiveScope(this.repo, principal);
      if (!scope) {
        this.publishTerminal(eventBus, taskId, contextId, 'failed', 'unauthorized: unknown or inactive acting user', initialTaskPublished);
        safeFinished();
        return;
      }

      // 3. メッセージ本文と要求スキルを抽出
      const parts = (requestContext.userMessage.parts ?? []) as Array<{ kind: string; text?: string }>;
      const messageText = parts
        .filter(p => p.kind === 'text' && typeof p.text === 'string')
        .map(p => p.text as string)
        .join('\n');
      const requestedSkillId = requestContext.userMessage.metadata?.skillId as string | undefined;

      // 4. piece/space を選択（fail-closed）
      const sel = selectPieceForMessage({
        messageText,
        effectiveSkills: scope.effectiveSkills,
        effectiveSpaceIds: scope.effectiveSpaceIds,
        requestedSkillId,
      });
      if (!sel) {
        this.publishTerminal(eventBus, taskId, contextId, 'failed', 'out of scope: no matching skill or space', initialTaskPublished);
        safeFinished();
        return;
      }

      // 4b. ガバナンス: 同時実行数チェック（createLocalTask 前に予約 — DB 行作成でカウントが増える前に弾く）
      if (this.limiter) {
        const dbCount = this.repo.countNonTerminalA2aTasksByGrant(principal.grantId);
        if (!this.limiter.tryReserveConcurrency(principal.grantId, dbCount)) {
          await this.rejectGoverned(eventBus, taskId, contextId, principal, `concurrency limit reached (${this.limiter.limits.maxConcurrentTasks})`, 'concurrency', initialTaskPublished);
          safeFinished();
          return;
        }
        reservedGrant = principal.grantId;
      }

      // 4c. ガバナンス: スキルバジェットチェック（createLocalTask 前 — 拒否時に local_tasks/jobs 行を作らない）
      //     concurrency 予約済みなので denial 時は finally ブロックが slot を解放する。
      if (this.limiter && !this.limiter.tryConsumeSkillBudget(principal.grantId)) {
        await this.rejectGoverned(eventBus, taskId, contextId, principal, `hourly skill budget exhausted (${this.limiter.limits.skillBudgetPerHour}/h)`, 'skill_budget', initialTaskPublished);
        safeFinished();
        return;
      }

      // 5. MAESTRO ローカルタスクを作成
      const localTask = await this.repo.createLocalTask({
        title: (messageText.slice(0, 80) || 'A2A task'),
        body: messageText,
        pieceName: sel.pieceName,
        ownerId: principal.actingUserId,
        spaceId: sel.spaceId,
        visibility: 'private',
        workspaceMode: 'persistent',
      });

      // 5b. ジョブをキューに積む。createLocalTask は local_tasks 行のみを作るため、
      //     通常の作成 API (local-tasks-crud-api.ts) と同じく queued ジョブを明示的に
      //     enqueue しないと Worker が拾うジョブが存在せず、直後の getLatestJobForIssue が
      //     null を返して即 fail する（A2A 経路のジョブブリッジ結線点）。
      await this.repo.createJob({
        repo: localTaskRepoName(localTask.id),
        issueNumber: localTask.id,
        instruction: messageText || `A2A task (${sel.pieceName})`,
        pieceName: sel.pieceName,
        ownerId: principal.actingUserId,
        visibility: 'private',
        spaceId: sel.spaceId,
      });

      // 6. 作成直後のジョブを取得
      const job = await this.repo.getLatestJobForIssue(
        localTaskRepoName(localTask.id),
        localTask.id,
      );
      if (!job) {
        this.publishTerminal(eventBus, taskId, contextId, 'failed', 'internal error: job not created', initialTaskPublished);
        safeFinished();
        return;
      }
      const jobId = job.id;

      // T8: best-effort audit log — 外部経路のジョブ enqueue を記録（forensics）
      try {
        await this.repo.addAuditLog(jobId, 'a2a_job_queued', 'a2a', {
          taskId: localTask.id,
          actingUserId: principal.actingUserId,
          clientId: principal.clientId,
          pieceName: sel.pieceName,
          spaceId: sel.spaceId,
        });
      } catch { /* best-effort audit */ }

      // キャンセル用にマッピングを保持
      this.jobIdByA2aTaskId.set(taskId, jobId);
      this.contextIdByA2aTaskId.set(taskId, contextId);

      // 7. 初期 Task イベントを publish（submitted → ResultManager が TaskStore に保存）
      const initialTask: Task = {
        id: taskId,
        contextId,
        kind: 'task',
        status: {
          state: 'submitted',
          timestamp: this.now(),
        },
        metadata: {
          maestroJobId: jobId,
          maestroLocalTaskId: localTask.id,
          // 委任トレーサビリティ: Task 2 の task-store.save が a2a_tasks 列へ写す
          // a2aDelegationId = 委任行の PK (row.id); a2aGrantId = OAuth grant id (別物)
          a2aDelegationId: principal.delegation.id,
          a2aGrantId: principal.grantId,
          a2aActingUserId: principal.actingUserId,
        },
      };
      eventBus.publish(initialTask);
      initialTaskPublished = true;

      // 8. ポーリングループ
      let lastStatus: string | null = null;
      let lastActivity: string | null = null;

      for (let i = 0; i < this.maxPollIterations; i++) {
        await this.sleep(this.pollMs);

        const j = await this.repo.getJob(jobId);
        if (!j) break;

        // 委任の revocation チェック（fail-closed: 行が見つからない場合も revoked 扱い）
        const deleg = this.repo.getA2aDelegationByGrantId(principal.grantId);
        if (!isDelegationLive(deleg ?? { expiresAt: null, revokedAt: 'revoked' }, this.now())) {
          logger.info(`[a2a-exec] delegation revoked mid-flight jobId=${jobId} grant=${principal.grantId}`);
          this.repo.cancelA2aLinkedJob(jobId);
          this.publishCanceledFinal(eventBus, taskId, contextId, 'delegation revoked');
          safeFinished();
          return;
        }

        const currentActivity = j.currentActivity ?? null;
        const statusChanged = j.status !== lastStatus;
        const activityChanged = currentActivity !== lastActivity;

        // status/activity 変化があれば working イベントを publish（terminal 手前まで）
        if ((statusChanged || activityChanged) && !TERMINAL_STATUSES.has(j.status)) {
          const workingEvent: TaskStatusUpdateEvent = {
            kind: 'status-update',
            taskId,
            contextId,
            status: {
              state: 'working',
              message: this.makeStatusMessage(currentActivity ?? j.status, contextId, taskId),
              timestamp: this.now(),
            } as TaskStatusUpdateEvent['status'],
            final: false,
          };
          eventBus.publish(workingEvent);
        }

        lastStatus = j.status;
        lastActivity = currentActivity;

        if (TERMINAL_STATUSES.has(j.status)) {
          await this.handleTerminal(j, localTask, taskId, contextId, eventBus);
          safeFinished();
          return;
        }
      }

      // 上限超過 → 実行中 Maestro ジョブを orphan にしないよう best-effort でキャンセル
      logger.warn(`[a2a-executor] poll cap exceeded taskId=${taskId} jobId=${jobId}`);
      try { this.repo.cancelA2aLinkedJob(jobId); } catch { /* best-effort */ }
      this.publishFailedFinal(eventBus, taskId, contextId, 'timeout: job exceeded max poll duration');
      safeFinished();
    } catch (err) {
      // Fix 2: catch unhandled DB / internal errors so the SSE stream never hangs
      // Task 4: use publishTerminal so an early throw (before initial Task) still produces a
      // valid Task object that SDK ResultManager can save and return to the caller.
      logger.warn(`[a2a] executor error: ${err}`);
      if (!finishedCalled) {
        this.publishTerminal(eventBus, taskId, contextId, 'failed', 'internal error', initialTaskPublished);
      }
    } finally {
      // T6: per-task map cleanup — 全終了経路（terminal/timeout/throw）で確実に実行
      this.jobIdByA2aTaskId.delete(taskId);
      this.contextIdByA2aTaskId.delete(taskId);
      // Task 5: concurrency reservation release — every exit path (terminal/timeout/throw/reject-after-reserve)
      if (reservedGrant && this.limiter) this.limiter.releaseConcurrency(reservedGrant);
      // Fix 2: guarantee finished() is called exactly once on every exit path
      safeFinished();
    }
  };

  // ── 終端処理 ──────────────────────────────────────────────────────────────

  private async handleTerminal(
    job: {
      status: string;
      errorSummary: string | null;
      abortReason: string | null;
      /** Fix 1: terminal job's worktree path — used as fallback when getLocalTask still returns null. */
      worktreePath?: string | null;
    },
    localTask: { id: number; workspacePath: string | null },
    taskId: string,
    contextId: string,
    eventBus: ExecutionEventBus,
  ): Promise<void> {
    // Fix 1 (succeeded only): re-fetch to get Worker-backfilled workspacePath and enumerate artifacts.
    if (job.status === 'succeeded') {
      const fresh = await this.repo.getLocalTask(localTask.id);
      const workspacePath = fresh?.workspacePath ?? job.worktreePath ?? null;
      for (const art of enumerateOutputArtifacts(workspacePath)) {
        const artifactEvent: TaskArtifactUpdateEvent = {
          kind: 'artifact-update',
          taskId,
          contextId,
          artifact: {
            artifactId: art.artifactId,
            name: art.name,
            parts: [{ kind: 'text', text: art.text }],
          },
        };
        eventBus.publish(artifactEvent);
      }
    }

    // Determine terminal A2A state via shared helper (Fix 3/5: handles all statuses).
    const { state, message } = finalizeStatusFromJob(job);

    if (state === 'failed') {
      this.publishFailedFinal(eventBus, taskId, contextId, message ?? 'job failed');
    } else if (message) {
      // input-required: wrap the text in a Message object
      const inputReqEvent: TaskStatusUpdateEvent = {
        kind: 'status-update',
        taskId,
        contextId,
        status: {
          state,
          message: this.makeStatusMessage(message, contextId, taskId),
          timestamp: this.now(),
        } as TaskStatusUpdateEvent['status'],
        final: true,
      };
      eventBus.publish(inputReqEvent);
    } else {
      // completed or canceled — no message payload
      const finalEvent: TaskStatusUpdateEvent = {
        kind: 'status-update',
        taskId,
        contextId,
        status: { state, timestamp: this.now() },
        final: true,
      };
      eventBus.publish(finalEvent);
    }
  }

  // ── governance helper ─────────────────────────────────────────────────────

  /**
   * Publishes a terminal `rejected` event and records a best-effort audit log entry.
   * Called by the governance guards (payload / rate / concurrency / skill_budget).
   */
  private async rejectGoverned(
    eventBus: ExecutionEventBus,
    taskId: string,
    contextId: string,
    principal: A2aPrincipal,
    reason: string,
    limit: string,
    initialPublished: boolean,
  ): Promise<void> {
    logger.info(`[a2a-exec] governance reject limit=${limit} grant=${principal.grantId}`);
    this.publishTerminal(eventBus, taskId, contextId, 'rejected', reason, initialPublished);
    try {
      await this.repo.addAuditLog(null, 'a2a_governance_reject', 'a2a', {
        limit,
        reason,
        grantId: principal.grantId,
        clientId: principal.clientId,
        actingUserId: principal.actingUserId,
      });
    } catch { /* best-effort audit */ }
  }

  // ── AgentExecutor.cancelTask ──────────────────────────────────────────────

  cancelTask = async (taskId: string, eventBus: ExecutionEventBus): Promise<void> => {
    const jobId = this.jobIdByA2aTaskId.get(taskId);
    // Fix 4: read contextId before deleting from the map
    const contextId = this.contextIdByA2aTaskId.get(taskId) ?? taskId;

    if (jobId) {
      try {
        // 全非終端ステータス (queued/dispatching/running/waiting_human/waiting_subtasks/retry)
        // を対象にする。requestJobCancel は running/dispatching のみで queued/waiting を取りこぼす。
        this.repo.cancelA2aLinkedJob(jobId);
      } catch (err) {
        logger.warn(`[a2a-executor] cancelJob best-effort failed: ${err}`);
      }
    }

    // Fix 4: clean up maps after cancel to avoid unbounded growth
    this.jobIdByA2aTaskId.delete(taskId);
    this.contextIdByA2aTaskId.delete(taskId);

    const event: TaskStatusUpdateEvent = {
      kind: 'status-update',
      taskId,
      contextId,
      status: { state: 'canceled', timestamp: this.now() },
      final: true,
    };
    eventBus.publish(event);
    eventBus.finished();
  };
}
