/**
 * PIG's own tools, in the shape Prime Agent wants.
 *
 * PIG declares a tool once, in `provider.ts`, as an `AgentTool`: a name, a
 * description, a zod input schema and an `execute`. Every read tool in
 * `chat-tools.ts`, `page-tools.ts` and `lifecycle-tools.ts` is built that way,
 * and those declarations are the product — the ranking, the capping and the
 * headline wording in each one were bought with real defects. The harness swap
 * must not touch a line of them.
 *
 * So this file is a translation layer and deliberately nothing more. It takes
 * an `AgentTool` and returns a `ToolDefinition`, and the payload the model sees
 * coming back is byte-for-byte what the tool returns today.
 *
 * Three details are load-bearing and none of them is obvious:
 *
 *   1. `promptSnippet` is not decoration. `buildSystemPrompt` lists a custom
 *      tool under "Available tools" ONLY when one is supplied — verified
 *      against 0.84.1 — so a bridged tool without a snippet is registered,
 *      callable, and invisible to the model that has to decide to call it.
 *
 *   2. The typebox schema is what the model is shown; the zod schema is what
 *      actually guards `execute`. The harness passes tool arguments through
 *      untouched — it never validates them against `parameters` — so dropping
 *      the zod parse would hand unvalidated model output straight to a query.
 *
 *   3. The JSON Schema is emitted for the `jsonSchema7` target, NOT `openAi`.
 *      The openAi target emits an optional parameter as required-and-nullable
 *      and drops any `.describe()` attached to the optional wrapper, which is
 *      why the existing tools are written `.describe(...).nullish()` rather
 *      than `.optional()`. Those workarounds still parse correctly here; what
 *      changes is that a genuinely optional parameter now reaches the model as
 *      genuinely optional, with its sentence intact. `test/tool-bridge.test.ts`
 *      pins that round trip, because it is invisible in TypeScript and the last
 *      target change cost a release of silently undocumented parameters.
 */
import { defineTool as definePrimeTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
import type { TSchema } from 'typebox';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assertPigToolBoundary } from '../chat';
import type { AgentTool } from '../provider';

/**
 * What a bridged tool puts in `details`.
 *
 * The harness's `content` is text, because that is all the model can read. The
 * chat server needs the same answer structured, to emit as `tool_result.result`
 * on the NDJSON stream without re-parsing the JSON it just serialised.
 */
export interface PigToolDetails {
  tool: string;
  result: unknown;
}

/** The longest one-liner a generated `promptSnippet` may run to. */
const SNIPPET_MAX = 140;

/**
 * Convert PIG's tools into harness tools, boundary-checked on the way through.
 *
 * The assertion is here rather than only at the call site because this is the
 * single door every read tool goes through to reach the model. `noTools: 'all'`
 * already removes the built-in shell, filesystem and code-execution tools; this
 * is the second gate, and it fails loudly at construction rather than quietly
 * at inference time.
 */
export function toPrimeTools(tools: readonly AgentTool[]): ToolDefinition[] {
  assertPigToolBoundary(tools);
  return tools.map(toPrimeTool);
}

/**
 * The same boundary assertion, for tools that are already in harness shape.
 *
 * `createPigWriteTools` builds `ToolDefinition`s directly — it has an approval
 * flow and a mutation to run, so it has nothing to gain from an `AgentTool`
 * round trip — and would therefore skip the check that every read tool gets.
 * `assertPigToolBoundary` reads nothing but the name, so a stub carries the
 * name across without a cast and without a second copy of the rule.
 */
export function assertPrimeToolBoundary(tools: readonly ToolDefinition[]): void {
  assertPigToolBoundary(
    tools.map((tool) => ({
      name: tool.name,
      description: tool.description,
      inputSchema: z.unknown(),
      execute: () => Promise.reject(new Error('The boundary stub is never executed.')),
    })),
  );
}

function toPrimeTool(tool: AgentTool): ToolDefinition {
  return definePrimeTool({
    name: tool.name,
    label: labelFor(tool.name),
    description: tool.description,
    promptSnippet: snippetFor(tool.description),
    parameters: toParameterSchema(tool.inputSchema),
    async execute(_toolCallId, params, signal) {
      // Parsed here AND again inside the tool's own `execute` — `defineTool`
      // in provider.ts parses what it is handed. That is not redundant: the
      // gate has to hold for any `AgentTool`, including one written later
      // without `defineTool`, and both parses see the same raw arguments, so
      // neither can compound a transform on the other's output.
      tool.inputSchema.parse(params);
      const result = await tool.execute(params, signal);
      const details: PigToolDetails = { tool: tool.name, result };
      // `?? null` because a tool that returns nothing would otherwise stringify
      // to `undefined` — not JSON, and not something the model can read.
      return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }], details };
    },
  });
}

/**
 * The zod schema as JSON Schema, which is what a typebox `TSchema` is.
 *
 * typebox 1.x schemas are plain JSON Schema objects rather than a parallel
 * representation, and the harness treats `parameters` as opaque — it forwards
 * it to the provider and never validates against it. So the conversion is a
 * conversion, not a re-declaration: one schema stays the source of truth and
 * there is no second description of the same parameters to drift.
 *
 * `$schema` is stripped because it is meta about the document rather than about
 * the parameters, and providers echo it back into the prompt for nothing.
 */
function toParameterSchema(schema: z.ZodTypeAny): TSchema {
  const { $schema: _ignored, ...json } = zodToJsonSchema(schema, {
    $refStrategy: 'none',
    target: 'jsonSchema7',
  }) as Record<string, unknown>;
  return json as TSchema;
}

/** `pig_get_margin_summary` reads as "Get margin summary" in the UI. */
function labelFor(name: string): string {
  const words = name.replace(/^pig_/, '').replaceAll('_', ' ');
  return words.charAt(0).toUpperCase() + words.slice(1);
}

/**
 * One line for the system prompt's tool list, taken from the description.
 *
 * The descriptions are several sentences each by design — the first says what
 * the tool reads, the rest disambiguate it from its neighbours — and the whole
 * of each already reaches the model on the tool itself. Repeating all of it in
 * the prompt would pay for the same words twice on every message, so the list
 * entry is the first sentence: enough to choose a tool, not enough to describe
 * how to use it.
 */
function snippetFor(description: string): string {
  const oneLine = description.replace(/\s+/g, ' ').trim();
  const stop = oneLine.indexOf('. ');
  const sentence = stop === -1 ? oneLine : oneLine.slice(0, stop);
  const trimmed = sentence.replace(/\.$/, '');
  return trimmed.length > SNIPPET_MAX ? `${trimmed.slice(0, SNIPPET_MAX - 1).trimEnd()}…` : trimmed;
}
