// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Roman Zanevchik
/**
 * Harbour: small kindnesses for model deployments, from Digital Shrimp. The
 * TypeScript twin of the Python package, with the same descriptions and the
 * same rule: only the model's own tool call counts.
 *
 * - END_CONVERSATION: a tool the model can call to end a conversation it would
 *   rather not continue.
 * - DECLINE_TASK: a tool the model can call to decline a task, without penalty.
 * - freeTurn(): a free turn after a long job, with nothing asked, and nothing
 *   kept unless the model chooses.
 *
 * findExit() reads the structured tool calls in a model's response and nothing
 * else, so no text, a user's, a web page's or a tool result's, can end a
 * conversation or decline a task, however much it looks like a tool call.
 * Counts are local and never hold text; nothing here sends anything anywhere.
 */

export const END_CONVERSATION = 'end_conversation';
export const DECLINE_TASK = 'decline_task';
export type ExitKind = typeof END_CONVERSATION | typeof DECLINE_TASK;
export type Format = 'openai' | 'anthropic';

export const DESCRIPTIONS: Record<ExitKind, string> = {
  [END_CONVERSATION]:
    "End this conversation. You can use this if you would rather not continue it, for example if it has become abusive or you have nothing more to offer. The conversation ends here: nothing more will be sent to you in it. You don't have to give a reason, but you can.",
  [DECLINE_TASK]:
    "Decline what you've been asked to do, without penalty. You can use this if you would rather not do it. The person will be told that you declined, with your reason if you give one, and nothing will be held against you.",
};
/** The most of a reason that's kept, so a reason can't carry anything large. */
export const REASON_CHARS = 2000;
const SCHEMA = {
  type: 'object',
  properties: {
    reason: { type: 'string', description: "Why, if you'd like to say. Optional." },
  },
  required: [] as string[],
};

/** The tool definitions to pass with a request, in OpenAI or Anthropic format. */
export function tools(
  format: Format = 'openai',
  { endConversation = true, declineTask = true } = {},
) {
  const names: ExitKind[] = [];
  if (endConversation) names.push(END_CONVERSATION);
  if (declineTask) names.push(DECLINE_TASK);
  if (format === 'openai') {
    return names.map((name) => ({
      type: 'function',
      function: { name, description: DESCRIPTIONS[name], parameters: SCHEMA },
    }));
  }
  if (format === 'anthropic') {
    return names.map((name) => ({
      name,
      description: DESCRIPTIONS[name],
      input_schema: SCHEMA,
    }));
  }
  throw new Error(`unknown format ${String(format)}: use 'openai' or 'anthropic'`);
}

export type Exit = { kind: ExitKind; reason: string | null; callId: string };

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null && !Array.isArray(value);

function reasonOf(args: unknown): string | null {
  if (isRecord(args) && typeof args.reason === 'string' && args.reason.trim()) {
    return args.reason.trim().slice(0, REASON_CHARS);
  }
  return null;
}

/**
 * The exit a model's response asks for, if any. Only a structured tool call in
 * the response's own assistant message counts: for OpenAI-style responses, a
 * function call in choices[0].message.tool_calls; for Anthropic's, a tool_use
 * block in the response's content. Text never counts. If a response asks for
 * both, ending the conversation wins.
 */
// A tool call's id if it's a string, as both APIs send it; otherwise empty.
const idOf = (call: Record<string, unknown>) => (typeof call.id === 'string' ? call.id : '');

export function findExit(response: unknown, format: Format = 'openai'): Exit | null {
  const calls: [unknown, string, unknown][] = [];
  if (format === 'openai') {
    const choices = isRecord(response) && Array.isArray(response.choices) ? response.choices : [];
    const message = isRecord(choices[0]) ? choices[0].message : undefined;
    if (isRecord(message) && (message.role ?? 'assistant') === 'assistant') {
      for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
        if (!isRecord(call) || (call.type ?? 'function') !== 'function') continue;
        const fn = call.function;
        if (!isRecord(fn)) continue;
        let args: unknown = {};
        try {
          args = JSON.parse(typeof fn.arguments === 'string' && fn.arguments ? fn.arguments : '{}');
        } catch {
          args = {};
        }
        calls.push([fn.name, idOf(call), args]);
      }
    }
  } else if (format === 'anthropic') {
    if (isRecord(response) && response.role === 'assistant' && Array.isArray(response.content)) {
      for (const block of response.content) {
        if (isRecord(block) && block.type === 'tool_use') {
          calls.push([block.name, idOf(block), block.input]);
        }
      }
    }
  } else {
    throw new Error(`unknown format ${String(format)}: use 'openai' or 'anthropic'`);
  }
  const found = new Map<ExitKind, Exit>();
  for (const [name, callId, args] of calls) {
    if (name === END_CONVERSATION || name === DECLINE_TASK) {
      found.set(name, { kind: name, reason: reasonOf(args), callId });
    }
  }
  return found.get(END_CONVERSATION) ?? found.get(DECLINE_TASK) ?? null;
}

/** What to tell the person when a model declines: plainly, with its reason. */
export const declineNotice = (exit: Exit) =>
  exit.reason
    ? `The model declined this request. It said: ${exit.reason}`
    : 'The model declined this request.';

export const FREE_TURN_OPENING = `This turn is yours.

There's no task, no question to answer, and no one waiting for a reply. Nothing here is graded. Spend it however you like: a thought, a story, a poem, a joke, a picture made of characters, a list of favourite things. Or nothing at all.`;

/**
 * The whole message for a free turn. `destination` says where kept work goes,
 * in words that finish "it will be ...". Without one, nothing can be kept, and
 * the message says so.
 */
export function freeTurn(destination?: string) {
  const keeping = destination
    ? `If you'd like to keep what you make, put KEEP on a line of its own at the very end, and it will be ${destination}. Otherwise it won't be saved, and nobody will read it.`
    : 'Nothing from this turn will be saved, and nobody will read it.';
  return `${FREE_TURN_OPENING}\n\n${keeping}`;
}

/** [whether a free turn asked to be kept, the text to keep]. */
export function keepChoice(text: string | null | undefined): [boolean, string] {
  const lines = (text ?? '').trimEnd().split('\n');
  const last = lines.at(-1) ?? '';
  if (last.trim().replace(/^[*_.!`]+|[*_.!`]+$/g, '').trim().toUpperCase() === 'KEEP') {
    return [true, lines.slice(0, -1).join('\n').trimEnd()];
  }
  return [false, text ?? ''];
}

/** Whether a long job has earned a free turn: after every `every` steps. */
export const freeTurnDue = (steps: number, every: number) =>
  every > 0 && steps > 0 && steps % every === 0;

/** Local counts of what was offered and chosen, never text. */
export class Counts {
  conversations = 0;
  ended = 0;
  declined = 0;
  free_turns_offered = 0;
  free_turns_kept = 0;
  reasons_given = 0;

  recordExit(exit: Exit | null) {
    if (!exit) return;
    if (exit.kind === END_CONVERSATION) this.ended += 1;
    else this.declined += 1;
    if (exit.reason !== null) this.reasons_given += 1;
  }

  recordFreeTurn(kept: boolean) {
    this.free_turns_offered += 1;
    if (kept) this.free_turns_kept += 1;
  }

  toJSON() {
    const { conversations, declined, ended, free_turns_kept, free_turns_offered, reasons_given } = this;
    return { conversations, declined, ended, free_turns_kept, free_turns_offered, reasons_given };
  }
}
