import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import {
  createAgentSession,
  DefaultResourceLoader,
  getAgentDir,
  ModelRuntime,
  SessionManager,
  type AgentSession,
  type AgentSessionEvent,
  type CreateAgentSessionOptions,
  type SessionEntry,
  type SessionHeader,
  type Skill,
  type ToolDefinition,
} from '@earendil-works/pi-coding-agent';
import {
  getSupportedThinkingLevels,
  InMemoryCredentialStore,
  type Credential,
  type CredentialStore,
} from '@earendil-works/pi-ai';
import type { ArtifactService } from '../artifacts/service.js';
import { readAppliedAgentProfileSnapshot } from '../agent-profiles/types.js';
import type { EnvironmentService } from '../environments/service.js';
import { validateEnvironmentContext } from '../environments/tool.js';
import type { NormalizedEvent } from '../events/types.js';
import type { ExternalResourceService } from '../external-resources/service.js';
import { getModels, type Api, type Context, type Model } from '@earendil-works/pi-ai/compat';
import type { RepositoryAccessProvider } from '../repositories/setup.js';
import {
  checkoutRepositoryPreparation,
  completeRepositoryPreparation,
  planActiveFirstRepositoryPreparations,
  preparedRepositoryFromPlan,
  type RepositoryCheckoutResult,
  type RepositoryPreparationPlan,
  type RepositoryPreparationResult,
} from '../repositories/prepare.js';
import {
  AMAZON_BEDROCK_INFERENCE_PROFILE_MODELS,
  AMAZON_BEDROCK_PROVIDER,
  BEDROCK_CONVERSE_STREAM_API,
  resolveBedrockRuntimeBaseUrl,
} from '../runner/bedrock.js';
import { type RepositoryToolServices, type RepositoryToolState } from '../repositories/tool.js';
import { sandboxRepositoryShell } from '../repositories/shell.js';
import type { GenerateTitleInput, Runner, RunnerInput, RunnerResult } from '../runner/types.js';
import { REASONING_LEVELS, type ReasoningLevel } from '../runner/reasoning.js';
import type { SandboxKeepaliveService } from '../sandbox/service.js';
import { startSandboxService } from '../sandbox/service-process.js';
import { PI_SESSION_DATA_VERSION, type PiSessionData, type PiSessionStore } from './session-store.js';
import { createPiArtifactToolDefinition } from './artifact-tool.js';
import { createPiDeputyToolDefinition } from './deputy-tool.js';
import { createPiScheduledFollowUpsToolDefinition } from './scheduled-follow-ups-tool.js';
import { createPiNotepadToolDefinition } from './notepad-tool.js';
import { createPiGitToolDefinition } from './git-tool.js';
import { createPiGitHubCliToolDefinition } from './github-cli-tool.js';
import { createPiMcpToolDefinitions } from './mcp-tools.js';
import { createPiRepositoryToolDefinition } from './repository-tool.js';
import { createPiEnvironmentToolDefinition } from './environment-tool.js';
import { createSandboxPiToolDefinitions } from './sandbox-tools.js';
import { createPiServiceToolDefinition } from './service-tool.js';
import { createPiWebSearchToolDefinition } from './web-search-tool.js';
import { preparePiSkills, unavailablePiSkills, type PiSkillsProvider, type PreparedSkillTrace } from './skills.js';
import { SkillInvocationRuntime } from './skill-invocation-runtime.js';
import { SerialEmitter } from './serial-emitter.js';
import { connectMcpServer, type ConnectMcpServerOptions } from '../mcp/client.js';
import { closeMcpConnections, logMcpUnavailable, mcpUnavailableNote } from '../mcp/runner.js';
import type { McpConnection, McpRuntimeOptions } from '../mcp/types.js';
import type { WebSearchToolServices } from '../web-search/tool.js';
import type { DeputyToolBaseServices } from '../sessions/deputy-tool.js';
import type { NotepadToolBaseServices } from '../notepads/tool.js';
import type { ScheduledFollowUpToolBaseServices } from '../scheduled-follow-ups/tool.js';
import { sessionTitleFromGeneratedResponse } from '../sessions/service.js';
import {
  createPiSubagentToolDefinition,
  piSubagentSystemPrompt,
  resolvePiSubagentProfile,
  type PiSubagentProfile,
  type PiSubagentRunInput,
  type PiSubagentRunResult,
} from './subagent-tool.js';

function deputiesSystemPrompt(sessionId: string, context?: Record<string, unknown>): string {
  const snapshot = readAppliedAgentProfileSnapshot(context?.agentProfileSnapshot);
  return [
    'You are an agent running in a sandbox for the Deputies product.',
    `The current durable Deputies session ID is "${sessionId}".`,
    'When generating files for users, prefer broadly compatible formats that can be opened in modern browsers and common desktop tools.',
    ...(snapshot ? ['', '<agent_profile>', snapshot.instructions, '</agent_profile>'] : []),
  ].join('\n');
}

// Pi's built-ins execute in the SDK process by default. Suppress them and register
// Deputies' same-name custom tools so filesystem and shell access goes through SandboxHandle.
const PI_NO_TOOLS: NonNullable<CreateAgentSessionOptions['noTools']> = 'builtin';
const PI_SUBAGENT_MAX_DEPTH = 4;
const BEDROCK_AUTHENTICATED_SENTINEL = '<authenticated>';
const ACTIVE_MESSAGE_GUIDANCE =
  'The user sent this while you were working. Address both the original request and this message unless this message explicitly redirects or cancels the original request.';

export type PiRunnerOptions = {
  model: string;
  reasoningLevelDefault?: ReasoningLevel;
  authFile?: string;
  authBase64?: string;
  credentials?: CredentialStore;
  sessionStore?: PiSessionStore;
  repositoryAccess?: {
    github?: RepositoryAccessProvider;
  };
  environments?: EnvironmentService;
  artifacts?: ArtifactService;
  externalResources?: ExternalResourceService;
  artifactToolMaxBytes?: number;
  sandboxKeepalive?: SandboxKeepaliveService;
  sandboxKeepaliveMaxExtensionMs?: number;
  setupScript?: { enabled: boolean; timeoutMs: number };
  webSearch?: WebSearchToolServices;
  mcp?: McpRuntimeOptions & { connect?: typeof connectMcpServer };
  deputy?: DeputyToolBaseServices;
  scheduledFollowUps?: ScheduledFollowUpToolBaseServices;
  notepad?: NotepadToolBaseServices;
  skills?: PiSkillsProvider;
  modelUnavailableReason?: (model: string | undefined) => string | undefined;
  resolveAgentProfile?: (id: string) => Promise<PiSubagentProfile>;
  listAgentProfiles?: () => Promise<PiSubagentProfile[]>;
  listDeputyProfiles?: () => Promise<PiSubagentProfile[]>;
};

type PiRuntimeAuth =
  | { kind: 'credentials'; credentials: CredentialStore }
  | { kind: 'file'; authPath: string }
  | { kind: 'default' };

type PiSessionLease = {
  manager: SessionManager;
  cleanup: () => Promise<void>;
};

type PiToolSet = {
  customTools: ToolDefinition[];
};

type PiToolSetContext = {
  subagentDepth: number;
  deputyRunState: { spawns: number };
  runSubagent?: (input: PiSubagentRunInput) => Promise<PiSubagentRunResult>;
  agentProfiles?: PiSubagentProfile[];
  deputyProfiles?: PiSubagentProfile[];
  mcpTools?: ToolDefinition[];
};

type PiModelSelection = {
  provider: string;
  modelId: string;
};

type PiMcpSetup = {
  connections: McpConnection[];
  tools: ToolDefinition[];
  note: string | null;
};

export class PiRunner implements Runner {
  private readonly sessions = new Map<string, SessionManager>();
  private readonly runtimeAuth: Promise<PiRuntimeAuth>;
  private readonly agentDir: string;

  constructor(private readonly options: PiRunnerOptions) {
    this.runtimeAuth = Promise.resolve(resolveRuntimeAuth(options));
    this.agentDir = options.authFile ? path.dirname(options.authFile) : getAgentDir();
  }

  async run(input: RunnerInput): Promise<RunnerResult> {
    const store = this.options.sessionStore;
    if (store?.withLock) return store.withLock(input.sessionId, () => this.runUnlocked(input));
    return this.runUnlocked(input);
  }

  async generateTitle(input: GenerateTitleInput): Promise<string> {
    const modelName = input.model ?? this.options.model;
    const unavailableReason = this.options.modelUnavailableReason?.(modelName);
    if (unavailableReason) throw new Error(unavailableReason);
    const modelRuntime = await this.createModelRuntime();
    registerAmazonBedrockInferenceProfiles(modelRuntime, modelName);
    const modelSelection = parseModelSelection(modelName);
    const model = modelRuntime.getModel(modelSelection.provider, modelSelection.modelId);
    if (!model) throw new Error(`Pi model is not available: ${modelName}`);
    // Reasoning and visible text share the output-token budget. A short budget
    // can therefore produce only thinking even though the requested title is
    // tiny. Keep ordinary models cheap, but let any reasoning-capable model
    // finish its reasoning before enforcing the 64-character title limit.
    const titleTokenBudget = Math.min(model.maxTokens, model.reasoning ? 4_096 : 64);
    const titleReasoning = getSupportedThinkingLevels(model).find((level) => level !== 'off');
    const context: Context = {
      systemPrompt:
        'Create a brief, specific title for this software engineering session. Prefer 3-7 words and omit filler or unnecessary detail. Return only the title, with no quotes, markup, or explanation. Never exceed 64 characters.',
      messages: [{ role: 'user', content: input.prompt, timestamp: Date.now() }],
    };
    const response = await modelRuntime.completeSimple(model, context, {
      maxTokens: titleTokenBudget,
      ...(titleReasoning ? { reasoning: titleReasoning } : {}),
      ...(input.signal ? { signal: input.signal } : {}),
    });
    if (response.stopReason === 'error' || response.stopReason === 'aborted') {
      throw new Error(response.errorMessage ?? 'Title generation failed');
    }
    const title = sessionTitleFromGeneratedResponse(assistantMessageText(response));
    if (!title) {
      const contentTypes = [...new Set(response.content.map((item) => item.type))].join(',') || 'none';
      throw new Error(
        `Title generation returned no text (model=${modelName}, stopReason=${response.stopReason}, outputTokens=${response.usage.output}, reasoningTokens=${response.usage.reasoning ?? 'unknown'}, contentTypes=${contentTypes}, tokenBudget=${titleTokenBudget})`,
      );
    }
    return title;
  }

  private async runUnlocked(input: RunnerInput): Promise<RunnerResult> {
    const modelName = input.model ?? this.options.model;
    const unavailableReason = this.options.modelUnavailableReason?.(modelName);
    if (unavailableReason) throw new Error(unavailableReason);
    const environmentWarning = await validateEnvironmentContext(this.options.environments, input.context);

    const mcpSetupPromise = connectPiMcpServers(this.options.mcp, input.signal);
    let mcpSetup: PiMcpSetup | null = null;
    let repositorySetup: Awaited<ReturnType<typeof preparePiRepositorySetup>>;
    try {
      [repositorySetup, mcpSetup] = await Promise.all([preparePiRepositorySetup(input, this.options), mcpSetupPromise]);
    } catch (error) {
      mcpSetup = await mcpSetupPromise.catch(() => null);
      if (mcpSetup) await closeMcpConnections(mcpSetup.connections);
      throw error;
    }
    const activeRepositorySetup = repositorySetup?.[0] ?? null;
    const cwd = activeRepositorySetup?.plan.workspacePath ?? input.sandbox.workspacePath;
    const [preparedSkills, availableAgentProfiles, availableDeputyProfiles] = await Promise.all([
      preparePiSkills({
        runnerInput: input,
        ...(this.options.skills ? { provider: this.options.skills } : {}),
        repositories: repositorySetup?.map((setup) => setup.plan) ?? [],
      }).catch((error: unknown) => {
        if (input.signal?.aborted) throw error;
        console.warn('Skill loading degraded during run preparation; affected skills were skipped.');
        return unavailablePiSkills(input, 'Skills could not be prepared for this run.');
      }),
      this.options.listAgentProfiles?.(),
      this.options.listDeputyProfiles?.(),
    ]);
    const { lease, modelRuntime, modelSelection, model, resourceLoader } = await (async () => {
      try {
        const lease = await this.getSessionLease(input.sessionId, cwd);
        const modelRuntime = await this.createModelRuntime();
        registerAmazonBedrockInferenceProfiles(modelRuntime, modelName);
        const modelSelection = parseModelSelection(modelName);
        const model = modelRuntime.getModel(modelSelection.provider, modelSelection.modelId);
        if (!model) throw new Error(`Pi model is not available: ${modelName}`);

        const resourceLoader = createPiResourceLoader(
          cwd,
          this.agentDir,
          deputiesSystemPrompt(input.sessionId, input.context),
          preparedSkills.skills,
        );
        await resourceLoader.reload();
        return { lease, modelRuntime, modelSelection, model, resourceLoader };
      } catch (error) {
        if (mcpSetup) {
          await closeMcpConnections(mcpSetup.connections);
          mcpSetup = null;
        }
        throw error;
      }
    })();

    const repositoryState = createRepositoryState(input.context, repositorySetup);
    const deputyRunState = { spawns: 0 };
    const eventEmitter = new SerialEmitter(input.emit);
    const enqueueEvent = (event: NormalizedEvent) => {
      eventEmitter.enqueue(event);
    };
    const skillInvocationRuntime = new SkillInvocationRuntime(preparedSkills.modelInvocable, (skill) => {
      enqueueEvent({
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        type: 'skill_invoked',
        payload: { ...skill, trigger: 'model' },
        createdAt: new Date(),
      });
    });
    const skillInvocationObserver = skillInvocationRuntime.createObserver(cwd);
    const runSubagent = (subagentInput: PiSubagentRunInput) =>
      runPiSubagent({
        input,
        options: this.options,
        agentDir: this.agentDir,
        modelRuntime,
        modelName,
        ...(input.reasoningLevel ? { parentReasoningLevel: input.reasoningLevel } : {}),
        modelSelection,
        repositoryState,
        deputyRunState,
        parentCwd: cwd,
        subagentDepth: 0,
        mcpTools: mcpSetup?.tools ?? [],
        skills: preparedSkills.skills,
        skillTraces: preparedSkills.modelInvocable,
        ...(availableAgentProfiles ? { availableAgentProfiles } : {}),
        ...(availableDeputyProfiles ? { availableDeputyProfiles } : {}),
        skillInvocationRuntime,
        emitEvent: enqueueEvent,
        subagentInput: {
          ...subagentInput,
          ...(subagentInput.parentToolCallId ? { parentActivityId: subagentInput.parentToolCallId } : {}),
        },
      });
    const { customTools } = createPiToolSet(input, this.options, repositoryState, cwd, {
      subagentDepth: 0,
      deputyRunState,
      runSubagent,
      ...(availableAgentProfiles ? { agentProfiles: availableAgentProfiles } : {}),
      ...(availableDeputyProfiles ? { deputyProfiles: availableDeputyProfiles } : {}),
      mcpTools: mcpSetup?.tools ?? [],
    });

    let sawTextDelta = false;
    let responseText = '';
    let session: AgentSession | undefined;
    let unsubscribe: (() => void) | undefined;
    let unsubscribeActiveMessages: (() => Promise<void>) | undefined;
    const activeMessagePrompts = new Set<Promise<void>>();
    let completed = false;

    const activateActiveMessages = () => {
      if (unsubscribeActiveMessages || !session) return;
      unsubscribeActiveMessages = input.activeMessageDelivery?.((message) => {
        const delivery = (async () => {
          const steeringSkills = await preparePiSkills({
            runnerInput: {
              ...input,
              messageId: message.messageId ?? input.messageId,
              prompt: message.prompt,
              messages: [message],
              skillInvocations: message.skillInvocations ?? [],
            },
            ...(this.options.skills ? { provider: this.options.skills } : {}),
            repositories: repositorySetup?.map((setup) => setup.plan) ?? [],
          }).catch((error: unknown) => {
            if (input.signal?.aborted) throw error;
            console.warn('Skill loading degraded during steering preparation; affected skills were skipped.');
            return unavailablePiSkills(
              {
                ...input,
                messageId: message.messageId ?? input.messageId,
                prompt: message.prompt,
                messages: [message],
              },
              'Skills could not be prepared for this steering message.',
            );
          });
          enqueueEvent({
            sessionId: input.sessionId,
            runId: input.runId,
            messageId: message.messageId ?? input.messageId,
            type: 'skills_loaded',
            payload: steeringSkills.event,
            createdAt: new Date(),
          });
          for (const invocation of steeringSkills.userInvocations) {
            enqueueEvent({
              sessionId: input.sessionId,
              runId: input.runId,
              messageId: invocation.messageId,
              type: 'skill_invoked',
              payload: { ...invocation.skill, trigger: 'user' },
              createdAt: new Date(),
            });
          }
          if (input.signal?.aborted) throw new Error('Operation aborted');
          if (input.shouldPersist && !(await input.shouldPersist())) {
            throw new Error('Run ownership lost before steering prompt');
          }
          await session!.prompt(`${ACTIVE_MESSAGE_GUIDANCE}\n\n${steeringSkills.prompt}`, {
            streamingBehavior: 'steer',
            expandPromptTemplates: false,
          });
        })();
        activeMessagePrompts.add(delivery);
        const remove = () => activeMessagePrompts.delete(delivery);
        void delivery.then(remove, remove);
        return delivery;
      });
    };

    const emitEvent = (event: AgentSessionEvent) => {
      if (input.signal?.aborted) return;
      if (event.type === 'agent_start') activateActiveMessages();
      const normalized = normalizePiEvent(event, input);
      if (!normalized) return;
      if (normalized.type === 'agent_text_delta') {
        sawTextDelta = true;
        responseText += String(normalized.payload.text ?? '');
      }
      enqueueEvent(normalized);
      skillInvocationObserver.observe(normalized);
    };

    const abortSession = () => {
      void session?.abort();
    };

    try {
      const thinkingLevel = resolveReasoningLevel(model, input.reasoningLevel ?? this.options.reasoningLevelDefault);
      const created = await createAgentSession({
        cwd,
        agentDir: this.agentDir,
        modelRuntime,
        model,
        ...(thinkingLevel ? { thinkingLevel } : {}),
        sessionManager: lease.manager,
        resourceLoader,
        noTools: PI_NO_TOOLS,
        customTools,
      });
      session = created.session;
      unsubscribe = session.subscribe(emitEvent);
      input.signal?.addEventListener('abort', abortSession, { once: true });

      await input.emit({
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        type: 'run_started',
        payload: { runner: 'pi' },
        createdAt: new Date(),
      });
      const profileSnapshot = readAppliedAgentProfileSnapshot(input.context?.agentProfileSnapshot);
      if (profileSnapshot) {
        await input.emit({
          sessionId: input.sessionId,
          runId: input.runId,
          messageId: input.messageId,
          type: 'profile_loaded',
          payload: {
            profileId: profileSnapshot.profileId,
            source: profileSnapshot.source,
            revision: profileSnapshot.revision,
            hash: profileSnapshot.hash,
            defaults: {
              ...(profileSnapshot.model ? { model: profileSnapshot.model } : {}),
              ...(profileSnapshot.reasoningLevel ? { reasoningLevel: profileSnapshot.reasoningLevel } : {}),
            },
          },
          createdAt: new Date(),
        });
      }
      await input.emit({
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        type: 'skills_loaded',
        payload: preparedSkills.event,
        createdAt: new Date(),
      });
      for (const invocation of preparedSkills.userInvocations) {
        await input.emit({
          sessionId: input.sessionId,
          runId: input.runId,
          messageId: invocation.messageId,
          type: 'skill_invoked',
          payload: { ...invocation.skill, trigger: 'user' },
          createdAt: new Date(),
        });
      }

      const setupResults = repositorySetup ? await completePiRepositorySetup(input, this.options, repositorySetup) : [];
      repositoryState.preparedRepositories = setupResults;
      if (setupResults[0]) repositoryState.prepared = setupResults[0];
      const setupNote = combineSetupNotes(
        environmentWarning,
        mcpSetup?.note ?? null,
        ...setupResults.map((result) => result.setupFailureNote),
      );

      if (input.signal?.aborted) throw new Error('Operation aborted');
      let promptError: unknown;
      try {
        await session.prompt(withSetupNote(preparedSkills.prompt, setupNote), { expandPromptTemplates: false });
      } catch (error) {
        promptError = error;
      }
      await unsubscribeActiveMessages?.();
      unsubscribeActiveMessages = undefined;
      await Promise.allSettled([...activeMessagePrompts]);
      await eventEmitter.drain(promptError === undefined ? {} : { primaryError: promptError });
      if (input.signal?.aborted) throw new Error('Operation aborted');

      const assistantMessage = lastAssistantMessage(session.messages);
      if (assistantMessage?.stopReason === 'error') {
        throw new Error(assistantMessage.errorMessage ?? 'Pi agent failed');
      }
      if (!sawTextDelta) responseText = assistantMessageText(assistantMessage);
      const responseMetadata = assistantMessageMetadata(assistantMessage);

      await input.emit({
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        type: 'run_completed',
        payload: { runner: 'pi', ...responseMetadata },
        createdAt: new Date(),
      });

      completed = true;
      return { text: responseText, ...responseMetadata };
    } finally {
      input.signal?.removeEventListener('abort', abortSession);
      await unsubscribeActiveMessages?.();
      unsubscribe?.();
      session?.dispose();
      if (mcpSetup) await closeMcpConnections(mcpSetup.connections);
      await this.persistAndCleanup(input, lease, completed);
    }
  }

  private createModelRuntime(): Promise<ModelRuntime> {
    return this.runtimeAuth.then((auth) =>
      ModelRuntime.create({
        ...(auth.kind === 'credentials' ? { credentials: auth.credentials } : {}),
        ...(auth.kind === 'file' ? { authPath: auth.authPath } : {}),
        modelsPath: path.join(this.agentDir, 'models.json'),
      }),
    );
  }

  private async getSessionLease(sessionId: string, cwd: string): Promise<PiSessionLease> {
    if (this.options.sessionStore) return this.createStoredSessionLease(sessionId, cwd);
    return { manager: this.getMemorySessionManager(sessionId, cwd), cleanup: async () => {} };
  }

  private getMemorySessionManager(sessionId: string, cwd: string): SessionManager {
    const existing = this.sessions.get(sessionId);
    if (existing) return existing;
    const manager = SessionManager.inMemory(cwd);
    manager.newSession({ id: sessionId });
    this.sessions.set(sessionId, manager);
    return manager;
  }

  private async createStoredSessionLease(sessionId: string, cwd: string): Promise<PiSessionLease> {
    const store = this.options.sessionStore;
    if (!store) return { manager: createNewSessionManager(sessionId, cwd), cleanup: async () => {} };

    const stored = await store.load(sessionId);
    if (!stored) return { manager: createNewSessionManager(sessionId, cwd), cleanup: async () => {} };

    const tempDir = await mkdtemp(path.join(os.tmpdir(), 'deputies-pi-session-'));
    try {
      const sessionFile = path.join(tempDir, `${safeFileName(sessionId)}.jsonl`);
      await writeFile(sessionFile, serializePiSessionData(stored), 'utf8');
      return {
        manager: SessionManager.open(sessionFile, tempDir, cwd),
        cleanup: async () => {
          await rm(tempDir, { recursive: true, force: true });
        },
      };
    } catch (error) {
      await rm(tempDir, { recursive: true, force: true });
      throw error;
    }
  }

  private async persistAndCleanup(input: RunnerInput, lease: PiSessionLease, completed: boolean): Promise<void> {
    try {
      if (completed && (!input.shouldPersist || (await input.shouldPersist()))) {
        const store = this.options.sessionStore;
        if (store) await store.save(input.sessionId, sessionManagerData(lease.manager));
      }
    } finally {
      await lease.cleanup();
    }
  }
}

type PiRepositorySetup =
  | {
      plan: RepositoryPreparationPlan;
      checkout: RepositoryCheckoutResult;
    }[]
  | null;

function createPiToolSet(
  input: RunnerInput,
  options: PiRunnerOptions,
  repositoryState: RepositoryToolState,
  cwd: string,
  context: PiToolSetContext,
): PiToolSet {
  const customTools = createSandboxPiToolDefinitions(input.sandbox, cwd);

  const repositoryServices = options.repositoryAccess?.github
    ? createPiRepositoryServices(input, options.repositoryAccess.github, repositoryState, options.setupScript)
    : null;

  if (options.artifacts) {
    customTools.push(
      createPiArtifactToolDefinition({
        artifacts: options.artifacts,
        sandbox: input.sandbox,
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        maxBytes: options.artifactToolMaxBytes ?? 25 * 1024 * 1024,
      }),
    );
  }

  if (repositoryServices) {
    customTools.push(
      ...(options.environments
        ? [
            createPiEnvironmentToolDefinition({
              environments: options.environments,
              repository: repositoryServices,
            }),
          ]
        : []),
      createPiRepositoryToolDefinition(repositoryServices),
      createPiGitHubCliToolDefinition(repositoryServices, {
        ...(options.externalResources ? { externalResources: options.externalResources } : {}),
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
      }),
      createPiGitToolDefinition(repositoryServices),
    );
  }

  if (input.updateSessionContext) {
    customTools.push(
      createPiServiceToolDefinition({
        sessionId: input.sessionId,
        providerSandboxId: input.sandbox.providerSandboxId,
        sandboxMetadata: input.sandbox.metadata,
        launchService: (service) => startSandboxService(input.sandbox, service),
        updateSessionContext: input.updateSessionContext,
        getContext: () => repositoryState.context,
        setContext: (context) => {
          repositoryState.context = context;
        },
        ...(options.sandboxKeepalive ? { keepalive: options.sandboxKeepalive } : {}),
        ...(options.sandboxKeepaliveMaxExtensionMs
          ? { keepaliveMaxExtensionMs: options.sandboxKeepaliveMaxExtensionMs }
          : {}),
      }),
    );
  }

  if (options.webSearch) customTools.push(createPiWebSearchToolDefinition(options.webSearch));

  if (context.mcpTools?.length) customTools.push(...context.mcpTools);

  if (options.deputy) {
    customTools.push(
      createPiDeputyToolDefinition(
        {
          ...options.deputy,
          sessionId: input.sessionId,
          runId: input.runId,
          messageId: input.messageId,
          runState: context.deputyRunState,
          ...(input.shouldPersist ? { shouldPersist: input.shouldPersist } : {}),
        },
        context.deputyProfiles,
      ),
    );
  }

  if (options.scheduledFollowUps && context.subagentDepth === 0) {
    customTools.push(
      createPiScheduledFollowUpsToolDefinition({
        ...options.scheduledFollowUps,
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
        ...(input.shouldPersist ? { shouldPersist: input.shouldPersist } : {}),
      }),
    );
  }

  // Pi subagents do not have their own durable Session principal, so this is
  // deliberately available only to the top-level durable run.
  if (options.notepad && context.subagentDepth === 0) {
    customTools.push(
      createPiNotepadToolDefinition({
        ...options.notepad,
        sessionId: input.sessionId,
        runId: input.runId,
        messageId: input.messageId,
      }),
    );
  }

  if (context.subagentDepth < PI_SUBAGENT_MAX_DEPTH) {
    if (!context.runSubagent) throw new Error('Pi subagent runner is not configured');
    customTools.push(createPiSubagentToolDefinition({ run: context.runSubagent }, context.agentProfiles));
  }

  return { customTools };
}

type RunPiSubagentInput = {
  input: RunnerInput;
  options: PiRunnerOptions;
  agentDir: string;
  modelRuntime: ModelRuntime;
  modelName: string;
  parentReasoningLevel?: ReasoningLevel;
  modelSelection: PiModelSelection;
  repositoryState: RepositoryToolState;
  deputyRunState: { spawns: number };
  parentCwd: string;
  subagentDepth: number;
  mcpTools: ToolDefinition[];
  skills: Skill[];
  skillTraces: PreparedSkillTrace[];
  availableAgentProfiles?: PiSubagentProfile[];
  availableDeputyProfiles?: PiSubagentProfile[];
  skillInvocationRuntime: SkillInvocationRuntime;
  emitEvent: (event: NormalizedEvent) => void;
  subagentInput: PiSubagentRunInput;
};

async function runPiSubagent(params: RunPiSubagentInput): Promise<PiSubagentRunResult> {
  if (params.subagentDepth >= PI_SUBAGENT_MAX_DEPTH) {
    throw new Error(`Maximum Pi subagent depth (${PI_SUBAGENT_MAX_DEPTH}) exceeded.`);
  }
  const childDepth = params.subagentDepth + 1;
  const profile = params.options.resolveAgentProfile
    ? await params.options.resolveAgentProfile(params.subagentInput.profileId)
    : resolvePiSubagentProfile(params.subagentInput.profileId);
  const cwd = resolveSubagentCwd(params.parentCwd, params.subagentInput.cwd);
  const modelName = profile.model ?? params.modelName;
  const unavailableReason = params.options.modelUnavailableReason?.(modelName);
  if (unavailableReason) throw new Error(unavailableReason);
  registerAmazonBedrockInferenceProfiles(params.modelRuntime, modelName);
  const modelSelection = parseModelSelection(modelName);
  const model = params.modelRuntime.getModel(modelSelection.provider, modelSelection.modelId);
  if (!model) throw new Error(`Pi model is not available: ${modelName}`);
  const inheritedSkills = params.skills;
  const inheritedTraces = params.skillTraces;

  const resourceLoader = createPiResourceLoader(
    cwd,
    params.agentDir,
    piSubagentSystemPrompt(deputiesSystemPrompt(params.input.sessionId), profile),
    inheritedSkills,
  );
  await resourceLoader.reload();

  const activityNamespace = `subagent-${randomUUID()}`;
  const activityId = (toolCallId: string) => `${activityNamespace}:${toolCallId}`;
  const sessionManager = createNewSessionManager(activityNamespace, cwd);
  const runSubagent = (subagentInput: PiSubagentRunInput) =>
    runPiSubagent({
      ...params,
      modelName,
      ...(effectiveReasoningLevel ? { parentReasoningLevel: effectiveReasoningLevel } : {}),
      parentCwd: cwd,
      subagentDepth: childDepth,
      skills: inheritedSkills,
      skillTraces: inheritedTraces,
      ...(params.availableAgentProfiles ? { availableAgentProfiles: params.availableAgentProfiles } : {}),
      ...(params.availableDeputyProfiles ? { availableDeputyProfiles: params.availableDeputyProfiles } : {}),
      subagentInput: {
        ...subagentInput,
        ...(subagentInput.parentToolCallId ? { parentActivityId: activityId(subagentInput.parentToolCallId) } : {}),
      },
    });
  const { customTools } = createPiToolSet(params.input, params.options, params.repositoryState, cwd, {
    subagentDepth: childDepth,
    deputyRunState: params.deputyRunState,
    runSubagent,
    ...(params.availableAgentProfiles ? { agentProfiles: params.availableAgentProfiles } : {}),
    ...(params.availableDeputyProfiles ? { deputyProfiles: params.availableDeputyProfiles } : {}),
    mcpTools: params.mcpTools,
  });
  const profileReasoning = profile.reasoningLevel;
  if (profileReasoning && !REASONING_LEVELS.includes(profileReasoning as ReasoningLevel))
    throw new Error(`Invalid profile reasoning level: ${profileReasoning}`);
  const effectiveReasoningLevel =
    (profileReasoning as ReasoningLevel | undefined) ??
    params.parentReasoningLevel ??
    params.options.reasoningLevelDefault;
  const thinkingLevel = resolveReasoningLevel(model, effectiveReasoningLevel);
  const created = await createAgentSession({
    cwd,
    agentDir: params.agentDir,
    modelRuntime: params.modelRuntime,
    model,
    ...(thinkingLevel ? { thinkingLevel } : {}),
    sessionManager,
    resourceLoader,
    noTools: PI_NO_TOOLS,
    customTools,
  });
  const session = created.session;
  const skillInvocationObserver = params.skillInvocationRuntime.createObserver(cwd);
  const unsubscribe = session.subscribe((event) => {
    const normalized = normalizePiEvent(event, params.input);
    if (!normalized) return;
    skillInvocationObserver.observe(normalized);
    if (
      params.subagentInput.parentActivityId &&
      (normalized.type === 'tool_started' || normalized.type === 'tool_finished')
    ) {
      if (normalized.type === 'tool_started') {
        params.emitEvent({
          ...normalized,
          payload: {
            ...normalized.payload,
            ...(normalized.payload.toolCallId ? { activityId: activityId(normalized.payload.toolCallId) } : {}),
            parentActivityId: params.subagentInput.parentActivityId,
            subagentDepth: childDepth,
            subagentProfileId: profile.id,
            subagentProfileSource: profile.source,
            subagentProfileRevision: profile.revision,
            ...(profile.hash ? { subagentProfileHash: profile.hash } : {}),
          },
        });
      } else {
        params.emitEvent({
          ...normalized,
          payload: {
            ...normalized.payload,
            ...(normalized.payload.toolCallId ? { activityId: activityId(normalized.payload.toolCallId) } : {}),
            parentActivityId: params.subagentInput.parentActivityId,
            subagentDepth: childDepth,
            subagentProfileId: profile.id,
            subagentProfileSource: profile.source,
            subagentProfileRevision: profile.revision,
            ...(profile.hash ? { subagentProfileHash: profile.hash } : {}),
          },
        });
      }
    }
  });
  const abortSession = () => {
    void session.abort();
  };
  params.subagentInput.signal?.addEventListener('abort', abortSession, { once: true });

  try {
    if (params.subagentInput.signal?.aborted) throw new Error('Operation aborted');
    await session.prompt(params.subagentInput.task, { expandPromptTemplates: false });
    if (params.subagentInput.signal?.aborted) throw new Error('Operation aborted');
    const assistantMessage = lastAssistantMessage(session.messages);
    if (assistantMessage?.stopReason === 'error') {
      throw new Error(assistantMessage.errorMessage ?? 'Pi subagent failed');
    }
    return {
      profileId: profile.id,
      task: params.subagentInput.task,
      cwd,
      depth: childDepth,
      text: assistantMessageText(assistantMessage),
      model: modelName,
      profileSource: profile.source,
      profileRevision: profile.revision,
      ...(profile.hash ? { profileHash: profile.hash } : {}),
      ...assistantMessageMetadata(assistantMessage),
    };
  } finally {
    params.subagentInput.signal?.removeEventListener('abort', abortSession);
    unsubscribe();
    session.dispose();
  }
}

function registerAmazonBedrockInferenceProfiles(modelRuntime: ModelRuntime, modelName: string): void {
  if (!modelName.startsWith(`${AMAZON_BEDROCK_PROVIDER}/`)) return;
  const models = modelRuntime
    .getModels()
    .filter((model) => model.provider === AMAZON_BEDROCK_PROVIDER)
    .map((model) => ({
      id: model.id,
      name: model.name,
      api: model.api,
      baseUrl: model.baseUrl,
      reasoning: model.reasoning,
      ...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
      input: model.input,
      cost: model.cost,
      contextWindow: model.contextWindow,
      maxTokens: model.maxTokens,
      ...(model.headers ? { headers: model.headers } : {}),
      ...(model.compat ? { compat: model.compat } : {}),
    }));
  const modelIds = new Set(models.map((model) => model.id));
  for (const model of amazonBedrockInferenceProfileModels()) {
    if (!modelIds.has(model.id)) models.push(model);
  }
  modelRuntime.registerProvider(AMAZON_BEDROCK_PROVIDER, {
    api: BEDROCK_CONVERSE_STREAM_API,
    baseUrl: resolveBedrockRuntimeBaseUrl(),
    apiKey: BEDROCK_AUTHENTICATED_SENTINEL,
    models,
  });
}

function amazonBedrockInferenceProfileModels() {
  const catalog = getModels(AMAZON_BEDROCK_PROVIDER);
  return AMAZON_BEDROCK_INFERENCE_PROFILE_MODELS.flatMap((profile) => {
    const base = catalog.find((model) => model.id === profile.baseModelId);
    return base ? [amazonBedrockInferenceProfileModel(profile.id, base)] : [];
  });
}

function amazonBedrockInferenceProfileModel(id: string, base: Model<Api>) {
  return {
    id,
    name: id,
    api: base.api,
    baseUrl: base.baseUrl,
    reasoning: base.reasoning,
    input: base.input,
    cost: base.cost,
    contextWindow: base.contextWindow,
    maxTokens: base.maxTokens,
    ...(base.headers ? { headers: base.headers } : {}),
    ...(base.compat ? { compat: base.compat } : {}),
  };
}

function createPiResourceLoader(
  cwd: string,
  agentDir: string,
  systemPrompt: string,
  skills: Skill[],
): DefaultResourceLoader {
  return new DefaultResourceLoader({
    cwd,
    agentDir,
    noExtensions: true,
    noSkills: true,
    noPromptTemplates: true,
    noThemes: true,
    noContextFiles: true,
    systemPrompt,
    skillsOverride: ({ diagnostics }) => ({ skills, diagnostics }),
  });
}

function resolveSubagentCwd(parentCwd: string, cwd: string | undefined): string {
  if (!cwd) return parentCwd;
  return path.posix.isAbsolute(cwd) ? cwd : path.posix.resolve(parentCwd, cwd);
}

function createRepositoryState(context: Record<string, unknown>, setup: PiRepositorySetup): RepositoryToolState {
  const state: RepositoryToolState = { context: structuredClone(context) };
  if (setup?.length) {
    state.preparedRepositories = setup.map((item) => preparedRepositoryFromPlan(item.plan));
    state.prepared = state.preparedRepositories[0]!;
  }
  return state;
}

function createPiRepositoryServices(
  input: RunnerInput,
  github: RepositoryAccessProvider,
  state: RepositoryToolState,
  setupScript: PiRunnerOptions['setupScript'],
): RepositoryToolServices {
  return {
    github,
    sandbox: input.sandbox,
    shell: () => sandboxRepositoryShell(input.sandbox),
    state,
    emit: input.emit,
    eventBase: { sessionId: input.sessionId, runId: input.runId, messageId: input.messageId },
    ...(setupScript ? { setupScript } : {}),
    ...(input.updateSessionContext ? { updateSessionContext: input.updateSessionContext } : {}),
  };
}

async function preparePiRepositorySetup(input: RunnerInput, options: PiRunnerOptions) {
  const repositorySetupInput: Parameters<typeof planActiveFirstRepositoryPreparations>[0] = {
    context: input.context,
    sandbox: input.sandbox,
  };
  if (options.repositoryAccess?.github) repositorySetupInput.github = options.repositoryAccess.github;
  const plans = await planActiveFirstRepositoryPreparations(repositorySetupInput);
  if (!plans.length) return null;
  const setups = [];
  for (const plan of plans) {
    const checkout = await checkoutRepositoryPreparation({
      plan,
      workspaceRoot: input.sandbox.workspacePath,
      shell: sandboxRepositoryShell(input.sandbox),
      ...(input.signal ? { signal: input.signal } : {}),
    });
    setups.push({ plan, checkout });
  }
  return setups;
}

async function completePiRepositorySetup(
  input: RunnerInput,
  options: PiRunnerOptions,
  setup: NonNullable<PiRepositorySetup>,
): Promise<RepositoryPreparationResult[]> {
  const results = [];
  for (const item of setup) {
    results.push(
      await completeRepositoryPreparation({
        plan: item.plan,
        repositoryWasCloned: item.checkout.repositoryWasCloned,
        emit: input.emit,
        eventBase: { sessionId: input.sessionId, runId: input.runId, messageId: input.messageId },
        setupShell: sandboxRepositoryShell(input.sandbox),
        ...(options.setupScript ? { setupScript: options.setupScript } : {}),
        ...(input.signal ? { signal: input.signal } : {}),
      }),
    );
  }
  return results;
}

async function connectPiMcpServers(mcp: PiRunnerOptions['mcp'], signal: AbortSignal | undefined): Promise<PiMcpSetup> {
  if (!mcp?.servers.length) return { connections: [], tools: [], note: null };
  const connect = mcp.connect ?? connectMcpServer;
  const results = await Promise.all(
    mcp.servers.map(async (server) => {
      try {
        const options: ConnectMcpServerOptions = {
          connectTimeoutMs: mcp.connectTimeoutMs,
          toolTimeoutMs: mcp.toolTimeoutMs,
          toolResultMaxChars: mcp.toolResultMaxChars,
          responseMaxBytes: mcp.responseMaxBytes,
        };
        if (signal) options.signal = signal;
        return { serverName: server.name, connection: await connect(server, options) };
      } catch (error) {
        logMcpUnavailable(server.name, error);
        return { serverName: server.name, error };
      }
    }),
  );
  const connections = results.flatMap((result) => ('connection' in result ? [result.connection] : []));
  const tools = connections.flatMap(createPiMcpToolDefinitions);
  const note = results
    .filter((result) => !('connection' in result))
    .map((result) => mcpUnavailableNote(result.serverName))
    .join('\n');
  return { connections, tools, note: note || null };
}

function withSetupNote(prompt: string, setupNote: string | null): string {
  return setupNote ? `${setupNote}\n\n${prompt}` : prompt;
}

function combineSetupNotes(...notes: Array<string | null>): string | null {
  const present = notes.filter((note): note is string => Boolean(note));
  return present.length ? present.join('\n\n') : null;
}

function createNewSessionManager(sessionId: string, cwd: string): SessionManager {
  const manager = SessionManager.inMemory(cwd);
  manager.newSession({ id: sessionId });
  return manager;
}

function sessionManagerData(manager: SessionManager): PiSessionData {
  const header = manager.getHeader();
  if (!header) throw new Error('Pi session manager has no session header');
  return { version: PI_SESSION_DATA_VERSION, header, entries: manager.getEntries() };
}

function serializePiSessionData(data: { header: SessionHeader; entries: SessionEntry[] }): string {
  return `${[data.header, ...data.entries].map((entry) => JSON.stringify(entry)).join('\n')}\n`;
}

function safeFileName(value: string): string {
  return value.replace(/[^A-Za-z0-9._-]/g, '_') || 'session';
}

function resolveRuntimeAuth(
  options: Pick<PiRunnerOptions, 'authFile' | 'authBase64' | 'credentials'>,
): PiRuntimeAuth | Promise<PiRuntimeAuth> {
  const configured = [options.authFile, options.authBase64, options.credentials].filter(Boolean).length;
  if (configured > 1) throw new Error('PiRunner accepts only one credential source');
  if (options.credentials) return { kind: 'credentials', credentials: options.credentials };
  if (options.authFile) return { kind: 'file', authPath: options.authFile };
  if (options.authBase64) {
    const parsed = JSON.parse(Buffer.from(options.authBase64, 'base64').toString('utf8')) as Record<string, Credential>;
    return createInMemoryRuntimeAuth(parsed);
  }
  return { kind: 'default' };
}

async function createInMemoryRuntimeAuth(parsed: Record<string, Credential>): Promise<PiRuntimeAuth> {
  const store = new InMemoryCredentialStore();
  for (const [providerId, credential] of Object.entries(parsed)) {
    await store.modify(providerId, async () => credential);
  }
  return { kind: 'credentials', credentials: store };
}

function parseModelSelection(model: string): PiModelSelection {
  const slash = model.indexOf('/');
  if (slash <= 0 || slash === model.length - 1) {
    throw new Error(`Pi model must use provider/model format, received: ${model}`);
  }

  return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}

function resolveReasoningLevel(model: Model<Api>, requested: ReasoningLevel | undefined): ReasoningLevel | undefined {
  if (!requested) return undefined;
  const supported = new Set(getSupportedThinkingLevels(model));
  for (let index = REASONING_LEVELS.indexOf(requested); index >= 0; index -= 1) {
    const candidate = REASONING_LEVELS[index];
    if (candidate && supported.has(candidate)) return candidate;
  }
  return 'off';
}

function normalizePiEvent(event: AgentSessionEvent, input: RunnerInput): NormalizedEvent | null {
  const base = {
    sessionId: input.sessionId,
    runId: input.runId,
    messageId: input.messageId,
    createdAt: new Date(),
  };

  switch (event.type) {
    case 'message_update':
      if (event.assistantMessageEvent.type !== 'text_delta') return null;
      return {
        ...base,
        type: 'agent_text_delta',
        payload: { text: event.assistantMessageEvent.delta },
      };
    case 'tool_execution_start':
      return {
        ...base,
        type: 'tool_started',
        payload: { toolName: event.toolName, toolCallId: event.toolCallId, args: event.args },
      };
    case 'tool_execution_end':
      return {
        ...base,
        type: 'tool_finished',
        payload: {
          toolName: event.toolName,
          toolCallId: event.toolCallId,
          isError: event.isError,
          result: event.result,
        },
      };
    default:
      return null;
  }
}

function lastAssistantMessage(messages: AgentSession['messages']): AssistantMessageLike | undefined {
  for (let index = messages.length - 1; index >= 0; index--) {
    const message = messages[index] as AssistantMessageLike | undefined;
    if (message?.role === 'assistant') return message;
  }
  return undefined;
}

type AssistantMessageLike = {
  role: 'assistant';
  content?: Array<{ type?: string; text?: string }>;
  model?: string;
  responseModel?: string;
  usage?: NonNullable<RunnerResult['usage']> & { reasoning?: number };
  stopReason?: string;
  errorMessage?: string;
};

function assistantMessageText(message: AssistantMessageLike | undefined): string {
  return (
    message?.content
      ?.filter((item) => item.type === 'text' && typeof item.text === 'string')
      .map((item) => item.text)
      .join('') ?? ''
  );
}

function assistantMessageMetadata(message: AssistantMessageLike | undefined): Pick<RunnerResult, 'model' | 'usage'> {
  const metadata: Pick<RunnerResult, 'model' | 'usage'> = {};
  const model = message?.responseModel ?? message?.model;
  if (model) metadata.model = model;
  if (message?.usage) metadata.usage = message.usage;
  return metadata;
}
