/**
 * Tool registry - the agent's narrow, project-scoped tool surface.
 *
 * Unlike a general "Claude Code", the Hub agent operates on a curriculum
 * engineering-file tree (ADR-0007) and reads Feishu context on demand
 * (ADR-0003). Its tools are therefore bounded:
 *
 * - file-tree read/write against the project workspace dir,
 * - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
 *   coupled only via the stable CLI contract ADR-0016),
 * - Feishu context reads, which must honor ADR-0003's authorization invariant.
 *
 * The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
 * construction and per-role whitelisting (ADR-0017).
 */
import { tool, type Tool } from "ai";
import { z } from "zod";

/** Per-run execution context closed over by every tool execute function. */
export interface ToolContext {
  readonly runId: string;
  readonly projectId: string;
  /** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
  readonly boundChatId: string;
  /** Absolute path to the project's curriculum engineering-file workspace. */
  readonly workspaceDir: string;
  /** Optional sink for file-change capture (A-3). The writeFile tool calls
   *  this with before/after hash when it modifies a file; the runner injects
   *  a collector that flushes to AgentFileChange at run end. Undefined when
   *  the runner doesn't wire capture (e.g. unit tests). */
  readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
}

export type ToolDefinition = Tool;
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;

export class ToolRegistry {
  private readonly factories = new Map<string, ToolFactory>();

  register(name: string, factory: ToolFactory): void {
    if (this.factories.has(name)) {
      throw new Error(`duplicate tool: ${name}`);
    }
    this.factories.set(name, factory);
  }

  /**
   * Build the AI SDK `tools` record. If `names` is given, include only those
   * tools (per-role whitelist, ADR-0017). Names not registered are silently
   * dropped because role config may name tools a Hub instance does not provide.
   */
  build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
    const built: Record<string, ToolDefinition> = {};
    const selected = names ?? [...this.factories.keys()];
    for (const name of selected) {
      const factory = this.factories.get(name);
      if (factory !== undefined) {
        built[name] = factory(ctx);
      }
    }
    return built;
  }
}

/**
 * Thrown when a Feishu context read targets a chat other than the run's project's
 * bound chat. This is the runtime enforcement of ADR-0003's
 * `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
 */
export class UnauthorizedChatRead extends Error {
  constructor(
    readonly requestedChatId: string,
    readonly boundChatId: string,
  ) {
    super(
      `refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
    );
    this.name = "UnauthorizedChatRead";
  }
}

/** Schema for the Feishu context read tool's arguments. */
export interface FeishuContextArgs {
  readonly chat_id: string;
  readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
  readonly id: string;
}

/**
 * Build the Feishu context-read tool factory. The execute closure enforces
 * ADR-0003's invariant before any API call: the requested `chat_id` must equal
 * `ctx.boundChatId`.
 *
 * The actual Feishu API call (read message / reply chain / thread / recent
 * group messages) is injected as `read`, so the invariant check is separable
 * from the transport. In tests `read` can be a stub; in production it wraps
 * `@larksuiteoapi/node-sdk`.
 */
export function feishuContextTool(
  read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
  rt: unknown,
): ToolFactory {
  return (ctx) =>
    tool({
      description:
        "Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
      inputSchema: z.object({
        chat_id: z.string().describe("The Feishu chat id to read from."),
        anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
        id: z.string().describe("The anchor id (message id or run id)."),
      }),
      execute: async (args): Promise<string> => {
        if (args.chat_id !== ctx.boundChatId) {
          throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
        }
        return read(args, ctx, rt);
      },
    });
}
