/**
 * The write surface: what Piggy may put INTO the CRM.
 *
 * Five tools, chosen against what a GTM team actually dictates after a call —
 * log what happened, add the person who was on it, move the deal, correct a
 * field, put the follow-up in the diary. Nothing here creates a contract, a
 * commitment or an allocation: those are the guarded kinds in `piggy-protocol`,
 * they carry money and legal exposure, and a chat panel is the wrong place to
 * make one for the first time.
 *
 * Four rules hold across every tool in this file.
 *
 * **The caller's principal, always.** Every write goes through
 * `executeMutation` with the `Principal` of the person typing, so the same
 * capability checks, the same team-side rules and the same audit row apply as
 * when they use the page. Piggy is a typist, not an authority. There is no
 * elevated principal anywhere in this file, and there must never be one.
 *
 * **The mutation definitions are reused, never re-declared.** `apps/api` owns
 * what a valid stage change is; a second copy here would be a second answer,
 * and the two would diverge on the first schema change nobody thought to
 * mirror. What this file writes is the mapping from a tool call to a mutation
 * input, and nothing else.
 *
 * **The model is told the truth.** A declined change, a missing capability and
 * a rejected input all come back as ordinary tool results whose first line
 * says, in words, that nothing was saved. A model told "saved" when nothing
 * was tells the user so, in a confident sentence, and the user believes it.
 *
 * **Read-only mode has no write tools at all.** Not offered-and-refused:
 * absent. `createPigWriteTools` returns an empty array, so the model cannot see
 * a capability it does not have, cannot spend a turn discovering that, and
 * cannot narrate an intention to use one.
 *
 * Attribution: see `attributedToPiggy` below for how a write Piggy made stays
 * distinguishable from one a human made.
 */
import { randomUUID } from 'node:crypto';
import {
  AFFILIATION_KINDS,
  CUSTOMER_SEGMENTS,
  CALENDAR_ENTRY_KINDS,
  DEMAND_STAGES,
  DEMAND_STAGE_LABELS,
  SUPPLY_STAGES,
  SUPPLY_STAGE_LABELS,
  isGuardedKind,
  requiresApproval,
  type PiggyApprovalDecision,
  type PiggyMode,
  type PiggyProposedChange,
} from '@pig/core';
import { AuthError, type Principal } from '@pig/api/src/lib/auth';
import {
  MutationError,
  executeMutation,
  type MutationDefinition,
} from '@pig/api/src/lib/mutation';
import { createActivityMutationDefinition } from '@pig/api/src/routes/activities';
import { createEntryMutationDefinition } from '@pig/api/src/routes/calendar';
import {
  createContactMutationDefinition,
  updateAccountMutationDefinition,
  updateDemandDealMutationDefinition,
  updateSupplyDealMutationDefinition,
} from '@pig/api/src/routes/records';
import type { AgentToolResult, ToolDefinition } from '@earendil-works/pi-coding-agent';
import { accounts, contacts, demandDeals, supplyDeals, type Database } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Type } from 'typebox';
import type { ZodTypeAny } from 'zod';
import { assertPrimeToolBoundary } from './agent/tool-bridge';

export interface PigWriteToolDeps {
  db: Database;
  /** The person who typed the message. Never an elevated or synthetic principal. */
  principal: Principal;
  mode: PiggyMode;
  /**
   * Ask the user. Resolves when they answer, and the turn stays open meanwhile.
   * The `id` is assigned by whoever emits the `approval_required` event, so a
   * tool never has to invent one that the client would have to match.
   */
  propose: (change: Omit<PiggyProposedChange, 'id'>) => Promise<PiggyApprovalDecision>;
}

/** What a write tool puts in `details`, for the chat server and the audit log. */
export interface PigWriteDetails {
  tool: string;
  kind: string;
  status: 'applied' | 'declined' | 'refused';
  /** The row that was written, when one was. */
  recordId?: string;
  /** Why nothing was written, when nothing was. */
  reason?: string;
}

/**
 * How long a proposal may wait before it is treated as a refusal.
 *
 * A turn holding an open inference connection on a promise nobody will resolve
 * is not merely a leak: it is a leak that is being billed. Five minutes is long
 * enough for somebody to read a diff card and short enough that a user who
 * closed the tab does not pin a connection for the life of the process. The
 * chat server enforces its own deadline over the same pending decision; both
 * settle as a rejection, so the two can race without disagreeing.
 */
const APPROVAL_TIMEOUT_MS = 300_000;

/**
 * The write tools for one conversation, bound to one person and one mode.
 *
 * Note the first line. `requiresApproval` would return true for every kind in
 * `read_only`, so an approval prompt would be the only outcome — but the model
 * would still be told the tools exist, and a model that believes it can write
 * says so. Returning nothing is the honest shape of "this session cannot
 * write", and it is one line rather than a policy the caller has to remember.
 */
export function createPigWriteTools(deps: PigWriteToolDeps): ToolDefinition[] {
  if (deps.mode === 'read_only') return [];
  const tools = [
    logActivityTool(deps),
    createContactTool(deps),
    updateDealStageTool(deps),
    updateRecordFieldsTool(deps),
    createTaskTool(deps),
  ];
  // The same gate the read tools pass through in `toPrimeTools`. These names
  // are literals a few lines below, so this can only fail if somebody adds a
  // tool that does not belong on the panel — which is exactly when it should.
  assertPrimeToolBoundary(tools);
  return tools;
}

// ---------------------------------------------------------------------------
// Attribution
// ---------------------------------------------------------------------------

interface Attribution {
  tool: string;
  principal: Principal;
  /** The user's stated reason, where the tool asks for one. */
  note?: string;
}

/**
 * Make a write Piggy performed distinguishable from one a person performed.
 *
 * `executeMutation` stamps its audit activity with `actorUserId` and derives
 * `actorAgent` and `source` from `principal.via` — which, for a browser
 * session, is `'jwt'`. That is correct and must stay correct: the write really
 * was authorised by that person, and pretending the principal was an API key to
 * win an `actorAgent: 'agent'` stamp would be a lie about how the request
 * authenticated, told inside the audit trail. So the provenance goes where the
 * caller legitimately controls the content: the audit activity's `meta` and,
 * when the mutation left it empty, its `body`.
 *
 * A reviewer therefore sees "Recorded by Piggy…" in the feed, and
 * `meta ->> 'actorAgent' = 'piggy'` selects every write Piggy has ever made,
 * with `meta ->> 'piggyTool'` naming which tool made it.
 *
 * The one write this cannot mark is `pig_log_activity`, whose audit is `'self'`
 * — the row it inserts IS the event. That one is marked with an `externalId`
 * prefixed `piggy:` instead; see `logActivityTool`.
 */
function attributedToPiggy<Schema extends ZodTypeAny, Result>(
  definition: MutationDefinition<Schema, Result>,
  attribution: Attribution,
): MutationDefinition<Schema, Result> {
  return {
    ...definition,
    async mutate(context) {
      const result = await definition.mutate(context);
      if (result.activity === 'self') return result;
      return {
        data: result.data,
        activity: {
          ...result.activity,
          body: result.activity.body ?? attributionLine(attribution),
          // Spread last: provenance is not a field a mutation may overwrite.
          meta: {
            ...result.activity.meta,
            actorAgent: 'piggy',
            piggyTool: attribution.tool,
            ...(attribution.note ? { piggyReason: attribution.note } : {}),
          },
        },
      };
    },
  };
}

function attributionLine({ tool, principal, note }: Attribution): string {
  const base = `Recorded by Piggy (${tool}) on behalf of ${principal.name}.`;
  return note ? `${base} Reason: ${note}` : base;
}

// ---------------------------------------------------------------------------
// The approval flow
// ---------------------------------------------------------------------------

interface ProposedWrite {
  tool: string;
  kind: string;
  summary: string;
  fields: PiggyProposedChange['fields'];
  record?: PiggyProposedChange['record'];
}

/**
 * Propose, wait, then either write or report the refusal.
 *
 * Every tool in this file is this function with a different `proposal` and a
 * different `apply`, which is deliberate: the sequencing — propose before
 * mutate, mutate only on `'apply'`, never throw a refusal into the stream — is
 * the part that has to be identical everywhere, and it is written once.
 */
async function proposeThenApply(
  deps: PigWriteToolDeps,
  signal: AbortSignal | undefined,
  proposal: ProposedWrite,
  apply: () => Promise<{ text: string; recordId?: string }>,
): Promise<AgentToolResult<PigWriteDetails>> {
  if (requiresApproval(deps.mode, proposal.kind)) {
    const decision = await awaitDecision(deps, proposal, signal);
    if (decision === 'reject') {
      return outcome(proposal, {
        status: 'declined',
        text:
          `NOT SAVED. The user declined this change, so nothing was written. ` +
          `Do not report it as done. Ask what they would like changed, or move on.`,
        reason: 'declined by the user',
      });
    }
  }

  try {
    const { text, recordId } = await apply();
    return outcome(proposal, { status: 'applied', text, recordId });
  } catch (error) {
    // A capability failure and a rejected input are both ordinary answers to a
    // tool call: the model can explain the first and correct the second. Thrown
    // into the stream they would end the turn on the user's own permissions,
    // which reads to them as Piggy being broken.
    if (error instanceof AuthError) {
      return outcome(proposal, {
        status: 'refused',
        text:
          `NOT SAVED. ${error.message} This is the user's own permission in PIG, not a fault. ` +
          `Say plainly that they cannot make this change and stop; do not retry it.`,
        reason: error.code,
      });
    }
    if (error instanceof MutationError) {
      return outcome(proposal, {
        status: 'refused',
        text: `NOT SAVED. PIG rejected this write: ${error.message}${issueSummary(error)}`,
        reason: error.code,
      });
    }
    throw error;
  }
}

function outcome(
  proposal: ProposedWrite,
  result: { status: PigWriteDetails['status']; text: string; recordId?: string; reason?: string },
): AgentToolResult<PigWriteDetails> {
  return {
    content: [{ type: 'text', text: result.text }],
    details: {
      tool: proposal.tool,
      kind: proposal.kind,
      status: result.status,
      ...(result.recordId ? { recordId: result.recordId } : {}),
      ...(result.reason ? { reason: result.reason } : {}),
    },
  };
}

/** The field paths a validation failure named, so the model can fix the call. */
function issueSummary(error: MutationError): string {
  const paths = (error.issues ?? [])
    .map((issue) => issue.path.join('.'))
    .filter((path) => path.length > 0);
  return paths.length > 0 ? ` Offending fields: ${[...new Set(paths)].join(', ')}.` : '';
}

/**
 * Wait for the user, but never forever.
 *
 * `propose` is supplied by the chat server and resolves when a decision arrives
 * on the sibling endpoint. Two things can stop that happening: the user closes
 * the tab, and the turn is aborted underneath us. Both resolve as a rejection
 * rather than hanging, because a pending promise here holds the inference
 * connection open for the whole turn.
 */
async function awaitDecision(
  deps: PigWriteToolDeps,
  proposal: ProposedWrite,
  signal: AbortSignal | undefined,
): Promise<PiggyApprovalDecision> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let onAbort: (() => void) | undefined;

  try {
    return await Promise.race<PiggyApprovalDecision>([
      deps.propose({
        tool: proposal.tool,
        kind: proposal.kind,
        summary: proposal.summary,
        fields: proposal.fields,
        ...(proposal.record ? { record: proposal.record } : {}),
        // The flag the card reads to explain itself. In `auto` the user was
        // promised nothing would stop, so a card that appears anyway reads as a
        // broken mode — and the reaction to a broken guardrail is to switch it
        // off. `PiggyProposedChange.forcedConfirm` and the card's "Auto mode
        // stopped here on purpose" note both already existed; nothing set it,
        // so the note could never render. No shipped tool proposes a guarded
        // kind yet, which is exactly why this had to be wired before one does.
        ...(deps.mode === 'auto' && isGuardedKind(proposal.kind) ? { forcedConfirm: true } : {}),
      }),
      new Promise<PiggyApprovalDecision>((resolve) => {
        timer = setTimeout(() => resolve('reject'), APPROVAL_TIMEOUT_MS);
      }),
      new Promise<PiggyApprovalDecision>((resolve) => {
        if (!signal) return;
        if (signal.aborted) {
          resolve('reject');
          return;
        }
        onAbort = () => resolve('reject');
        signal.addEventListener('abort', onAbort, { once: true });
      }),
    ]);
  } finally {
    clearTimeout(timer);
    if (onAbort) signal?.removeEventListener('abort', onAbort);
  }
}

// ---------------------------------------------------------------------------
// pig_log_activity
// ---------------------------------------------------------------------------

const LOGGABLE_ACTIVITY_TYPES = ['call', 'email', 'meeting', 'note'] as const;

function logActivityTool(deps: PigWriteToolDeps): ToolDefinition {
  return {
    name: 'pig_log_activity',
    label: 'Log activity',
    description:
      'Record something that happened against an account, deal or contact: a call, an email, ' +
      'a meeting or a note. Supply at least one of accountId, demandDealId, supplyDealId or ' +
      'contactId, taken from a previous tool result. This writes to the activity feed; it ' +
      'does not change any figure on a record.',
    promptSnippet: 'Log a call, email, meeting or note against a record',
    promptGuidelines: [
      'Log an activity only when the user describes something that happened, and log it once.',
      'Ids must come from a tool result. Never invent one, and never guess which record a name refers to — look it up first.',
    ],
    parameters: Type.Object(
      {
        type: Type.String({
          enum: [...LOGGABLE_ACTIVITY_TYPES],
          description: 'What happened.',
        }),
        subject: Type.String({
          maxLength: 200,
          description: 'One line, as it will appear in the feed: "Pricing call with procurement".',
        }),
        body: Type.Optional(
          Type.String({
            maxLength: 8_000,
            description: 'The note itself, in the user’s own words where they gave them.',
          }),
        ),
        accountId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Account this concerns.' }),
        ),
        contactId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Person this concerns.' }),
        ),
        demandDealId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Demand deal this concerns.' }),
        ),
        supplyDealId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Supply deal this concerns.' }),
        ),
        occurredAt: Type.Optional(
          Type.String({
            format: 'date-time',
            description: 'When it happened, ISO 8601. Omit for now.',
          }),
        ),
      },
      { additionalProperties: false },
    ),
    async execute(_toolCallId, params, signal) {
      const input = params as {
        type: string;
        subject: string;
        body?: string;
        accountId?: string;
        contactId?: string;
        demandDealId?: string;
        supplyDealId?: string;
        occurredAt?: string;
      };
      const target = firstTarget(input);
      if (!target) {
        return missingTarget(
          'pig_log_activity',
          'activity',
          'accountId, demandDealId, supplyDealId or contactId',
        );
      }

      const label = await describeRecord(deps.db, target);
      const proposal: ProposedWrite = {
        tool: 'pig_log_activity',
        kind: 'activity',
        summary: `Log a ${input.type} on ${label ?? 'the selected record'}`,
        fields: [
          { label: 'Type', value: humanise(input.type) },
          { label: 'Subject', value: input.subject },
          ...(input.body ? [{ label: 'Note', value: input.body }] : []),
          ...(input.occurredAt
            ? [{ label: 'Occurred', value: displayDate(input.occurredAt) }]
            : []),
        ],
        ...(label ? { record: { ...target, label } } : { record: target }),
      };

      return proposeThenApply(deps, signal, proposal, async () => {
        const written = await executeMutation(
          deps.db,
          deps.principal,
          async () => ({
            ...input,
            // The row this inserts is its own audit event, so the usual
            // `meta` stamp in `attributedToPiggy` has nowhere to go. The
            // external id carries the provenance instead —
            // `external_id LIKE 'piggy:%'` selects everything Piggy logged —
            // and, being unique-indexed, it also means a retried tool call
            // deduplicates rather than logging the same call twice.
            externalId: `piggy:${randomUUID()}`,
          }),
          createActivityMutationDefinition(),
        );
        if (written.deduplicated || !written.activity) {
          return { text: 'Already logged: an identical entry was recorded earlier.' };
        }
        return {
          text: `Saved. Activity ${written.activity.id} logged against ${label ?? target.type}.`,
          recordId: written.activity.id,
        };
      });
    },
  };
}

// ---------------------------------------------------------------------------
// pig_create_contact
// ---------------------------------------------------------------------------

function createContactTool(deps: PigWriteToolDeps): ToolDefinition {
  return {
    name: 'pig_create_contact',
    label: 'Create contact',
    description:
      'Add a person to an account, with their role. The account id must come from a previous ' +
      'tool result. Use this only for a person who is not already on the account; read the ' +
      'account first if you are not sure.',
    promptSnippet: 'Add a person to an account, with their role',
    promptGuidelines: [
      'Before creating a contact, read the account to check the person is not already on it.',
      'Never invent an email address, a title or an affiliation. Omit what the user did not say.',
    ],
    parameters: Type.Object(
      {
        accountId: Type.String({
          format: 'uuid',
          description: 'Account the person belongs to, from a tool result.',
        }),
        fullName: Type.String({ maxLength: 200, description: 'Their name as written.' }),
        role: Type.String({
          enum: [...AFFILIATION_KINDS],
          description:
            'How they relate to the account. Use unknown rather than guessing; staff is the ' +
            'ordinary case for an employee.',
        }),
        title: Type.Optional(
          Type.String({ maxLength: 200, description: 'Job title, only if the user gave one.' }),
        ),
        email: Type.Optional(
          Type.String({ maxLength: 320, description: 'Email, only if the user gave one.' }),
        ),
        isDecisionMaker: Type.Optional(
          Type.Boolean({
            description: 'True only when the user said this person decides. Defaults to false.',
          }),
        ),
      },
      { additionalProperties: false },
    ),
    async execute(_toolCallId, params, signal) {
      const input = params as {
        accountId: string;
        fullName: string;
        role: string;
        title?: string;
        email?: string;
        isDecisionMaker?: boolean;
      };
      const label = await describeRecord(deps.db, { type: 'account', id: input.accountId });
      const proposal: ProposedWrite = {
        tool: 'pig_create_contact',
        kind: 'contact',
        summary: `Add ${input.fullName} to ${label ?? 'the selected account'}`,
        fields: [
          { label: 'Name', value: input.fullName },
          ...(input.title ? [{ label: 'Title', value: input.title }] : []),
          { label: 'Role', value: humanise(input.role) },
          ...(input.email ? [{ label: 'Email', value: input.email }] : []),
          { label: 'Decision maker', value: input.isDecisionMaker ? 'Yes' : 'No' },
        ],
        record: {
          type: 'account',
          id: input.accountId,
          ...(label ? { label } : {}),
        },
      };

      return proposeThenApply(deps, signal, proposal, async () => {
        const created = await executeMutation(
          deps.db,
          deps.principal,
          async () => ({
            accountId: input.accountId,
            fullName: input.fullName,
            affiliation: input.role,
            isDecisionMaker: input.isDecisionMaker ?? false,
            ...(input.title ? { title: input.title } : {}),
            ...(input.email ? { email: input.email } : {}),
          }),
          attributedToPiggy(createContactMutationDefinition(), {
            tool: 'pig_create_contact',
            principal: deps.principal,
          }),
        );
        return {
          text: `Saved. ${created.fullName} added to ${label ?? 'the account'} as contact ${created.id}.`,
          recordId: created.id,
        };
      });
    },
  };
}

// ---------------------------------------------------------------------------
// pig_update_deal_stage
// ---------------------------------------------------------------------------

const STAGE_LABELS: Record<string, string> = { ...DEMAND_STAGE_LABELS, ...SUPPLY_STAGE_LABELS };

function updateDealStageTool(deps: PigWriteToolDeps): ToolDefinition {
  return {
    name: 'pig_update_deal_stage',
    label: 'Update deal stage',
    description:
      'Move a demand or supply deal to a different stage, with the reason it moved. The stage ' +
      'must be one of that side’s stages: demand deals use the qualification-to-closed ' +
      'stages, supply deals the sourced-to-live ones. The reason is written into the stage ' +
      'change so the history explains itself later.',
    promptSnippet: 'Move a demand or supply deal to a new stage, with the reason',
    promptGuidelines: [
      'Read the deal before moving it, so the stage you name is a change and the reason is accurate.',
      'The reason is the user’s, not yours. Quote what they said rather than summarising it into a stock phrase.',
    ],
    parameters: Type.Object(
      {
        dealType: Type.String({
          enum: ['demand', 'supply'],
          description: 'demand for a customer deal, supply for a provider deal.',
        }),
        dealId: Type.String({ format: 'uuid', description: 'Deal id from a tool result.' }),
        stage: Type.String({
          enum: [...DEMAND_STAGES, ...SUPPLY_STAGES],
          description:
            'The new stage. Demand: qualification, legal, scoping, proposal, procurement, poc, ' +
            'deployment, expansion, closed_won, closed_lost. Supply: sourced, qualifying, ' +
            'technical_diligence, financial_diligence, pricing, contracting, onboarding, live, ' +
            'renewal, churned, rejected.',
        }),
        reason: Type.String({
          maxLength: 2_000,
          description: 'Why it moved, in the user’s words. Required.',
        }),
      },
      { additionalProperties: false },
    ),
    async execute(_toolCallId, params, signal) {
      const input = params as {
        dealType: string;
        dealId: string;
        stage: string;
        reason: string;
      };
      const isDemand = input.dealType === 'demand';
      const type = isDemand ? 'demand_deal' : 'supply_deal';
      const before = await readDeal(deps.db, type, input.dealId);
      if (!before) {
        return notFound('pig_update_deal_stage', 'deal', `${input.dealType} deal ${input.dealId}`);
      }
      if (before.stage === input.stage) {
        return outcome(
          { tool: 'pig_update_deal_stage', kind: 'deal', summary: '', fields: [] },
          {
            status: 'refused',
            text:
              `NOT SAVED. ${before.name} is already at ${stageLabel(before.stage)}, so there is ` +
              `nothing to move. Say so rather than reporting a change.`,
            reason: 'no_change',
          },
        );
      }

      const proposal: ProposedWrite = {
        tool: 'pig_update_deal_stage',
        kind: 'deal',
        summary: `Move ${before.name} to ${stageLabel(input.stage)}`,
        fields: [
          { label: 'Stage', value: stageLabel(input.stage), previous: stageLabel(before.stage) },
          { label: 'Reason', value: input.reason },
        ],
        record: { type, id: input.dealId, label: before.name },
      };

      return proposeThenApply(deps, signal, proposal, async () => {
        const attribution = {
          tool: 'pig_update_deal_stage',
          principal: deps.principal,
          note: input.reason,
        };
        const readInput = async () => ({ stage: input.stage });
        // The two definitions are separately generic over their own schemas, so
        // the call is branched rather than the definition chosen first: a
        // ternary would ask TypeScript to unify two unrelated zod object types.
        //
        // No `NotificationOutbox` is passed, so a stage change Piggy makes does
        // not raise the Slack notification the API route raises. That is a
        // known gap rather than a decision: the outbox is wired in the API
        // server and Piggy runs in its own process.
        const updated = isDemand
          ? await executeMutation(
              deps.db,
              deps.principal,
              readInput,
              attributedToPiggy(updateDemandDealMutationDefinition(), attribution),
              { id: input.dealId },
            )
          : await executeMutation(
              deps.db,
              deps.principal,
              readInput,
              attributedToPiggy(updateSupplyDealMutationDefinition(), attribution),
              { id: input.dealId },
            );
        return {
          text:
            `Saved. ${updated.name} moved from ${stageLabel(before.stage)} to ` +
            `${stageLabel(updated.stage)}.`,
          recordId: updated.id,
        };
      });
    },
  };
}

function stageLabel(stage: string): string {
  return STAGE_LABELS[stage] ?? humanise(stage);
}

// ---------------------------------------------------------------------------
// pig_update_record_fields
// ---------------------------------------------------------------------------

/**
 * The narrow edit surface, and why it is this narrow.
 *
 * Every field here is descriptive: a segment, a country, a date, a note. None
 * of them is money, and that is the whole point. `packages/core/src/margin.ts`
 * treats every `Cents` column as an integer of US cents, and the system prompt
 * spends a paragraph teaching that rule because a small model reads
 * `costPerGpuHourCents: 189` as $189 without it. A model that misreads a figure
 * gives a wrong answer somebody can challenge; a model that WRITES one puts a
 * hundredfold error into the book, where it is quoted back as fact. Money is
 * edited on the page, by a person, looking at the field label.
 *
 * `ownerUserId` is absent for a duller reason: it is not on any of the three
 * update schemas, so ownership cannot be reassigned through this path at all.
 */
const EDITABLE_FIELDS = {
  account: ['customerSegment', 'country', 'region', 'description'],
  demand_deal: ['expectedCloseDate', 'probability', 'description'],
  supply_deal: ['availableFrom', 'technicalNotes'],
} as const satisfies Record<string, readonly string[]>;

type EditableRecordType = keyof typeof EDITABLE_FIELDS;

const FIELD_LABELS: Record<string, string> = {
  customerSegment: 'Segment',
  country: 'Country',
  region: 'Region',
  description: 'Description',
  expectedCloseDate: 'Expected close',
  probability: 'Probability',
  availableFrom: 'Available from',
  technicalNotes: 'Technical notes',
};

function updateRecordFieldsTool(deps: PigWriteToolDeps): ToolDefinition {
  return {
    name: 'pig_update_record_fields',
    label: 'Update record fields',
    description:
      'Correct one or two descriptive fields on an account or a deal. Each field applies to ' +
      'one record type only: customerSegment, country, region and description to an account; ' +
      'expectedCloseDate, probability and description to a demand deal; availableFrom and ' +
      'technicalNotes to a supply deal. This tool cannot change money, ownership or stage — ' +
      'use pig_update_deal_stage for a stage.',
    promptSnippet: 'Correct a descriptive field on an account or deal',
    promptGuidelines: [
      'Read the record first, so the change is a change and the old value can be shown to the user.',
      'Send only the fields the user actually asked to change; leave everything else out.',
    ],
    parameters: Type.Object(
      {
        recordType: Type.String({
          enum: ['account', 'demand_deal', 'supply_deal'],
          description: 'What kind of record this is.',
        }),
        recordId: Type.String({ format: 'uuid', description: 'Record id from a tool result.' }),
        reason: Type.String({
          maxLength: 2_000,
          description: 'Why it is being corrected, in the user’s words. Required.',
        }),
        customerSegment: Type.Optional(
          Type.String({
            enum: [...CUSTOMER_SEGMENTS],
            description: 'Account only. The customer segment.',
          }),
        ),
        country: Type.Optional(
          Type.String({ maxLength: 100, description: 'Account only. Headquarters country.' }),
        ),
        region: Type.Optional(
          Type.String({ maxLength: 100, description: 'Account only. Headquarters region.' }),
        ),
        description: Type.Optional(
          Type.String({
            maxLength: 4_000,
            description: 'Account or demand deal. Replaces the existing description entirely.',
          }),
        ),
        expectedCloseDate: Type.Optional(
          Type.String({
            format: 'date',
            description: 'Demand deal only. YYYY-MM-DD.',
          }),
        ),
        probability: Type.Optional(
          Type.Number({
            minimum: 0,
            maximum: 1,
            description: 'Demand deal only. A share between 0 and 1: 0.4 means 40 per cent.',
          }),
        ),
        availableFrom: Type.Optional(
          Type.String({ format: 'date', description: 'Supply deal only. YYYY-MM-DD.' }),
        ),
        technicalNotes: Type.Optional(
          Type.String({ maxLength: 4_000, description: 'Supply deal only. Replaces the notes.' }),
        ),
      },
      { additionalProperties: false },
    ),
    async execute(_toolCallId, params, signal) {
      const input = params as Record<string, unknown>;
      const recordType = String(input.recordType);
      const recordId = String(input.recordId);
      const reason = String(input.reason ?? '');
      if (!isEditableRecordType(recordType)) {
        return notFound('pig_update_record_fields', 'record', `record type ${recordType}`);
      }

      const allowed: readonly string[] = EDITABLE_FIELDS[recordType];
      const supplied = Object.keys(input).filter(
        (key) => !['recordType', 'recordId', 'reason'].includes(key) && input[key] !== undefined,
      );
      const inapplicable = supplied.filter((key) => !allowed.includes(key));
      if (inapplicable.length > 0) {
        return outcome(
          { tool: 'pig_update_record_fields', kind: 'record', summary: '', fields: [] },
          {
            status: 'refused',
            text:
              `NOT SAVED. ${inapplicable.join(', ')} cannot be set on a ${humanise(recordType)}. ` +
              `Its editable fields are ${allowed.join(', ')}.`,
            reason: 'field_not_applicable',
          },
        );
      }
      if (supplied.length === 0) {
        return outcome(
          { tool: 'pig_update_record_fields', kind: 'record', summary: '', fields: [] },
          {
            status: 'refused',
            text:
              `NOT SAVED. No field was supplied, so there is nothing to change. The editable ` +
              `fields on a ${humanise(recordType)} are ${allowed.join(', ')}.`,
            reason: 'no_fields',
          },
        );
      }

      const before = await readEditableRecord(deps.db, recordType, recordId);
      if (!before) {
        return notFound('pig_update_record_fields', 'record', `${humanise(recordType)} ${recordId}`);
      }

      const changes: Record<string, unknown> = {};
      for (const key of supplied) changes[key] = input[key];

      const proposal: ProposedWrite = {
        tool: 'pig_update_record_fields',
        kind: 'record',
        summary: `Update ${supplied.map(fieldLabel).join(' and ')} on ${before.label}`,
        fields: [
          ...supplied.map((key) => ({
            label: fieldLabel(key),
            value: displayValue(changes[key]),
            // A diff without the old value is not a diff, and the old value is
            // the only thing that tells the user whether this is a correction
            // or an overwrite of something they wrote last week.
            previous: displayValue(before.values[key]),
          })),
          { label: 'Reason', value: reason },
        ],
        record: { type: recordType, id: recordId, label: before.label },
      };

      return proposeThenApply(deps, signal, proposal, async () => {
        const attribution = {
          tool: 'pig_update_record_fields',
          principal: deps.principal,
          note: reason,
        };
        const readInput = async () => changes;
        // Branched at the call, not at the definition: each update definition
        // is generic over its own zod schema and the three do not unify.
        if (recordType === 'account') {
          await executeMutation(
            deps.db,
            deps.principal,
            readInput,
            attributedToPiggy(updateAccountMutationDefinition(), attribution),
            { id: recordId },
          );
        } else if (recordType === 'demand_deal') {
          await executeMutation(
            deps.db,
            deps.principal,
            readInput,
            attributedToPiggy(updateDemandDealMutationDefinition(), attribution),
            { id: recordId },
          );
        } else {
          await executeMutation(
            deps.db,
            deps.principal,
            readInput,
            attributedToPiggy(updateSupplyDealMutationDefinition(), attribution),
            { id: recordId },
          );
        }
        return {
          text: `Saved. ${supplied.map(fieldLabel).join(' and ')} updated on ${before.label}.`,
          recordId,
        };
      });
    },
  };
}

function isEditableRecordType(value: string): value is EditableRecordType {
  return Object.hasOwn(EDITABLE_FIELDS, value);
}

function fieldLabel(field: string): string {
  return FIELD_LABELS[field] ?? humanise(field);
}

// ---------------------------------------------------------------------------
// pig_create_task
// ---------------------------------------------------------------------------

/**
 * PIG has no task table, and this is the nearest equivalent it does have.
 *
 * `calendar_entries` is the one table for a human-owned dated item — see the
 * note at the top of `packages/db/src/schema/calendar.ts` for why every other
 * date in PIG is a projection off the record that owns it. A follow-up is
 * exactly that kind of item, so a task is a calendar entry of kind `reminder`,
 * owned by the person who asked for it.
 */
function createTaskTool(deps: PigWriteToolDeps): ToolDefinition {
  return {
    name: 'pig_create_task',
    label: 'Create task',
    description:
      'Put a dated follow-up in the calendar, owned by the user: a reminder, a meeting, a QBR. ' +
      'PIG has no separate task list — a task is a calendar entry, and every other date in PIG ' +
      'already lives on the record it belongs to, so do not use this to record a contract ' +
      'expiry or a deal close date.',
    promptSnippet: 'Put a dated follow-up or meeting in the user’s calendar',
    promptGuidelines: [
      'A task is always dated. If the user gave no date, ask rather than choosing one for them.',
      'Attach the task to the account or deal it concerns, using an id from a tool result.',
    ],
    parameters: Type.Object(
      {
        title: Type.String({ maxLength: 240, description: 'What is to be done, in one line.' }),
        startsAt: Type.String({
          description: 'When, as YYYY-MM-DD or a full ISO 8601 timestamp.',
        }),
        kind: Type.Optional(
          Type.String({
            enum: [...CALENDAR_ENTRY_KINDS],
            description: 'Defaults to reminder, which is what a follow-up is.',
          }),
        ),
        description: Type.Optional(
          Type.String({ maxLength: 8_000, description: 'Any detail the user gave.' }),
        ),
        accountId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Account this concerns.' }),
        ),
        demandDealId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Demand deal this concerns.' }),
        ),
        supplyDealId: Type.Optional(
          Type.String({ format: 'uuid', description: 'Supply deal this concerns.' }),
        ),
      },
      { additionalProperties: false },
    ),
    async execute(_toolCallId, params, signal) {
      const input = params as {
        title: string;
        startsAt: string;
        kind?: string;
        description?: string;
        accountId?: string;
        demandDealId?: string;
        supplyDealId?: string;
      };
      const startsAt = toInstant(input.startsAt);
      if (!startsAt) {
        return outcome(
          { tool: 'pig_create_task', kind: 'task', summary: '', fields: [] },
          {
            status: 'refused',
            text:
              `NOT SAVED. "${input.startsAt}" is not a date this can use. Send YYYY-MM-DD, or a ` +
              `full ISO 8601 timestamp.`,
            reason: 'invalid_date',
          },
        );
      }

      const target = firstTarget(input);
      const label = target ? await describeRecord(deps.db, target) : null;
      const kind = input.kind ?? 'reminder';
      const proposal: ProposedWrite = {
        tool: 'pig_create_task',
        kind: 'task',
        summary: `Diarise "${input.title}" for ${displayDate(startsAt)}`,
        fields: [
          { label: 'Title', value: input.title },
          { label: 'When', value: displayDate(startsAt) },
          { label: 'Kind', value: humanise(kind) },
          ...(label ? [{ label: 'About', value: label }] : []),
          ...(input.description ? [{ label: 'Detail', value: input.description }] : []),
        ],
        ...(target ? { record: { ...target, ...(label ? { label } : {}) } } : {}),
      };

      return proposeThenApply(deps, signal, proposal, async () => {
        const created = await executeMutation(
          deps.db,
          deps.principal,
          async () => ({
            title: input.title,
            startsAt,
            kind,
            ...(input.description ? { description: input.description } : {}),
            ...(input.accountId ? { accountId: input.accountId } : {}),
            ...(input.demandDealId ? { demandDealId: input.demandDealId } : {}),
            ...(input.supplyDealId ? { supplyDealId: input.supplyDealId } : {}),
          }),
          attributedToPiggy(createEntryMutationDefinition(), {
            tool: 'pig_create_task',
            principal: deps.principal,
          }),
        );
        return {
          text:
            `Saved. "${created.title}" is in ${deps.principal.name}'s calendar for ` +
            `${displayDate(created.startsAt.toISOString())}.`,
          recordId: created.id,
        };
      });
    },
  };
}

// ---------------------------------------------------------------------------
// Shared shaping
// ---------------------------------------------------------------------------

interface RecordRef {
  type: string;
  id: string;
}

/**
 * The record a change is about, for the diff card's "open this" link.
 *
 * Ordered account-first because that is the record a user recognises: told a
 * contact id they see a uuid, told an account they see Northwind Robotics.
 */
function firstTarget(input: {
  accountId?: string;
  demandDealId?: string;
  supplyDealId?: string;
  contactId?: string;
}): RecordRef | null {
  if (input.accountId) return { type: 'account', id: input.accountId };
  if (input.demandDealId) return { type: 'demand_deal', id: input.demandDealId };
  if (input.supplyDealId) return { type: 'supply_deal', id: input.supplyDealId };
  if (input.contactId) return { type: 'contact', id: input.contactId };
  return null;
}

/** A name for the approval card. Never fatal: a card with a uuid still works. */
async function describeRecord(db: Database, ref: RecordRef): Promise<string | null> {
  if (ref.type === 'account') {
    const [row] = await db
      .select({ name: accounts.name })
      .from(accounts)
      .where(eq(accounts.id, ref.id))
      .limit(1);
    return row?.name ?? null;
  }
  if (ref.type === 'demand_deal') {
    const [row] = await db
      .select({ name: demandDeals.name })
      .from(demandDeals)
      .where(eq(demandDeals.id, ref.id))
      .limit(1);
    return row?.name ?? null;
  }
  if (ref.type === 'supply_deal') {
    const [row] = await db
      .select({ name: supplyDeals.name })
      .from(supplyDeals)
      .where(eq(supplyDeals.id, ref.id))
      .limit(1);
    return row?.name ?? null;
  }
  const [row] = await db
    .select({ name: contacts.fullName })
    .from(contacts)
    .where(eq(contacts.id, ref.id))
    .limit(1);
  return row?.name ?? null;
}

async function readDeal(
  db: Database,
  type: 'demand_deal' | 'supply_deal',
  id: string,
): Promise<{ name: string; stage: string } | null> {
  if (type === 'demand_deal') {
    const [row] = await db
      .select({ name: demandDeals.name, stage: demandDeals.stage })
      .from(demandDeals)
      .where(eq(demandDeals.id, id))
      .limit(1);
    return row ?? null;
  }
  const [row] = await db
    .select({ name: supplyDeals.name, stage: supplyDeals.stage })
    .from(supplyDeals)
    .where(eq(supplyDeals.id, id))
    .limit(1);
  return row ?? null;
}

/** The row behind a field edit, reduced to a label and the values being replaced. */
async function readEditableRecord(
  db: Database,
  type: EditableRecordType,
  id: string,
): Promise<{ label: string; values: Record<string, unknown> } | null> {
  if (type === 'account') {
    const [row] = await db.select().from(accounts).where(eq(accounts.id, id)).limit(1);
    return row ? { label: row.name, values: { ...row } } : null;
  }
  if (type === 'demand_deal') {
    const [row] = await db.select().from(demandDeals).where(eq(demandDeals.id, id)).limit(1);
    return row ? { label: row.name, values: { ...row } } : null;
  }
  const [row] = await db.select().from(supplyDeals).where(eq(supplyDeals.id, id)).limit(1);
  return row ? { label: row.name, values: { ...row } } : null;
}

function notFound(
  tool: string,
  kind: string,
  what: string,
): AgentToolResult<PigWriteDetails> {
  return outcome(
    { tool, kind, summary: '', fields: [] },
    {
      status: 'refused',
      text:
        `NOT SAVED. There is no ${what} in PIG. Ids must come from a tool result — ` +
        `find the record first rather than reissuing this call.`,
      reason: 'not_found',
    },
  );
}

function missingTarget(
  tool: string,
  kind: string,
  which: string,
): AgentToolResult<PigWriteDetails> {
  return outcome(
    { tool, kind, summary: '', fields: [] },
    {
      status: 'refused',
      text:
        `NOT SAVED. Supply at least one of ${which}, taken from a tool result, so this is ` +
        `attached to something.`,
      reason: 'no_target',
    },
  );
}

/** `frontier_lab` reads as "Frontier lab" on a card somebody has to approve. */
function humanise(value: string): string {
  const spaced = value.replaceAll('_', ' ');
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}

/**
 * Card values are display-formatted, per `PiggyProposedChange.fields`.
 *
 * A probability is a share between 0 and 1 everywhere in PIG, and a card that
 * shows `0.4` invites the reader to approve what they think is 0.4 per cent.
 */
function displayValue(value: unknown): string {
  if (value === null || value === undefined || value === '') return '—';
  if (typeof value === 'number') return `${value}`;
  if (typeof value === 'boolean') return value ? 'Yes' : 'No';
  if (value instanceof Date) return displayDate(value.toISOString());
  const text = String(value);
  return /^\d{4}-\d{2}-\d{2}(T|$)/.test(text) ? displayDate(text) : text;
}

/** The date part only. The time on a follow-up is noise on an approval card. */
function displayDate(iso: string): string {
  return iso.slice(0, 10);
}

/**
 * A date the calendar's `z.string().datetime()` will accept.
 *
 * A model asked for a follow-up date sends `2026-09-01` far more often than a
 * full timestamp, and midday UTC is the same calendar day in every timezone PIG
 * operates in — midnight is not, and a reminder that silently lands the day
 * before is worse than one that fails loudly. Mirrors `nullableDate` in
 * `apps/api/src/routes/records.ts`.
 */
function toInstant(value: string): string | null {
  const trimmed = value.trim();
  const candidate = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T12:00:00.000Z` : trimmed;
  const parsed = Date.parse(candidate);
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
}
