/**
 * The agent layer.
 *
 * Three ideas here, all of which exist to make an agent safe to point at a
 * database that people make commercial decisions from.
 *
 * 1. **The API never calls the agent.** It writes a row to `agentTasks`. A
 *    worker leases rows and drains them. The queue therefore survives the agent
 *    being offline, a restart replays nothing that already finished, and no
 *    request thread is ever blocked on a model.
 *
 * 2. **Every derived claim carries its evidence.** Enrichment writes to `facts`
 *    with a score, a band, a source URL and a status — not directly to the
 *    record. Strong signals apply themselves; weak ones queue for a human. An
 *    agent permitted to write unattributed claims will eventually write a wrong
 *    one, and nobody will be able to tell which.
 *
 * 3. **Every outward action is idempotent.** `agentActions` carries a unique
 *    idempotency key, so a retried task cannot send the same message twice.
 *
 * 4. **The conversation is ours, not the harness's.** `piggyConversations` and
 *    `piggyMessages` hold Piggy's transcripts, including the tools it ran and
 *    the approvals it was given. The long note above those tables explains why
 *    the agent harness's own session store is deliberately unused.
 */
import {
  boolean,
  check,
  index,
  integer,
  jsonb,
  numeric,
  pgTable,
  text,
  timestamp,
  uniqueIndex,
  uuid,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import {
  PIGGY_MODES,
  READ_CAPABILITIES,
  type PiggyApprovalDecision,
  type PiggyChatContext,
  type PiggyProposedChange,
} from '@pig/core';
import {
  agentTaskKindEnum,
  agentTaskOutcomeEnum,
  factBandEnum,
  factStatusEnum,
} from './enums';
import { accounts, contacts } from './crm';
import { users } from './identity';

/**
 * Render a value set as a SQL `IN` list from the ontology constant. Same
 * reasoning as `learn.ts`: a vocabulary typed out again in a CHECK is a
 * vocabulary that drifts from the one the application validates against.
 */
const inList = (values: readonly string[]) =>
  sql.raw(values.map((value) => `'${value}'`).join(', '));

/**
 * The work queue. Leased rather than locked: a worker claims a row by stamping
 * `leasedUntil` into the future, and a crashed worker's rows become claimable
 * again when the lease lapses. No dead-letter babysitting, no stuck jobs.
 */
export const agentTasks = pgTable(
  'agent_tasks',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    kind: agentTaskKindEnum('kind').notNull(),

    /**
     * What the task is about — an account id, a contact id, a commitment id.
     * Free text because the referent varies by kind, and a foreign key per kind
     * would mean a column per kind.
     */
    subject: text('subject').notNull(),
    /** Why this was queued. Shown to the user; keeps agent work explicable. */
    reason: text('reason'),
    payload: jsonb('payload').$type<Record<string, unknown>>(),

    /** Higher runs first. */
    priority: integer('priority').notNull().default(0),
    /** Model calls this task may spend before giving up. */
    budget: integer('budget').notNull().default(4),
    attempts: integer('attempts').notNull().default(0),
    maxAttempts: integer('max_attempts').notNull().default(3),

    /** Not eligible to run before this time. Used for backoff and scheduling. */
    dueAt: timestamp('due_at', { withTimezone: true }).notNull().defaultNow(),
    /** Claimed until. Null means unclaimed; past means the claim has lapsed. */
    leasedUntil: timestamp('leased_until', { withTimezone: true }),
    leasedBy: text('leased_by'),

    startedAt: timestamp('started_at', { withTimezone: true }),
    finishedAt: timestamp('finished_at', { withTimezone: true }),
    outcome: agentTaskOutcomeEnum('outcome'),
    error: text('error'),

    requestedByUserId: uuid('requested_by_user_id').references(() => users.id, {
      onDelete: 'set null',
    }),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    /**
     * At most one *outstanding* task per (kind, subject).
     *
     * Partial on `finished_at IS NULL`, so re-enriching an account next month is
     * fine while queueing the same enrichment twice today is not. Without this,
     * a UI that queues work on page view will melt the agent.
     */
    uniqueIndex('agent_tasks_pending_key')
      .on(t.kind, t.subject)
      .where(sql`${t.finishedAt} IS NULL`),
    index('agent_tasks_claimable_idx').on(t.dueAt, t.priority),
    index('agent_tasks_lease_idx').on(t.leasedUntil),
  ],
);

/**
 * Evidence-bearing claims produced by the agent.
 *
 * A fact never overwrites a record directly. It records what was claimed, about
 * which field, with what confidence, on what evidence — and only then, if the
 * band is `verified`, is it applied. Everything weaker waits for a person.
 */
export const facts = pgTable(
  'facts',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    accountId: uuid('account_id').references(() => accounts.id, { onDelete: 'cascade' }),
    contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),

    /** The field being claimed about, e.g. `title`, `supplierType`, `gpuCount`. */
    field: text('field').notNull(),
    value: text('value').notNull(),

    /** 0–1. Mapped to a band by `bandForScore` in @pig/core. */
    score: numeric('score', { precision: 4, scale: 3 }).notNull(),
    band: factBandEnum('band').notNull(),
    status: factStatusEnum('status').notNull().default('proposed'),

    /** What the claim rests on: quotes, URLs, the reasoning that produced it. */
    evidence: jsonb('evidence').$type<Record<string, unknown>>(),
    sourceUrl: text('source_url'),
    /** How it was derived — 'web_search', 'prime_api', 'inference'. */
    method: text('method'),

    /** The agent run that produced this, for tracing back. */
    agentRunId: uuid('agent_run_id'),

    decidedByUserId: uuid('decided_by_user_id').references(() => users.id, {
      onDelete: 'set null',
    }),
    decidedAt: timestamp('decided_at', { withTimezone: true }),

    observedAt: timestamp('observed_at', { withTimezone: true }).notNull().defaultNow(),
    supersededAt: timestamp('superseded_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('facts_account_idx').on(t.accountId),
    index('facts_contact_idx').on(t.contactId),
    index('facts_status_idx').on(t.status),
    index('facts_field_idx').on(t.field),
  ],
);

/**
 * What a transcript row can be.
 *
 * `tool` is a first-class role rather than an assistant message with metadata
 * hung off it, because the renderer draws it as its own card and because a
 * turn can call several tools between two sentences.
 */
export const PIGGY_MESSAGE_ROLES = ['user', 'assistant', 'tool'] as const;
export type PiggyMessageRole = (typeof PIGGY_MESSAGE_ROLES)[number];

/**
 * `PiggyApprovalDecision` as a value.
 *
 * @pig/core states it as a type union, and a CHECK constraint needs values.
 * `satisfies` stops this list drifting into something the protocol does not
 * admit. The other direction — a decision added to the union and not here —
 * the compiler cannot see, so it fails as a rejected insert the first time
 * that decision is recorded, which is loud rather than silent.
 */
const PIGGY_APPROVAL_DECISIONS = [
  'apply',
  'reject',
] as const satisfies readonly PiggyApprovalDecision[];

/**
 * Piggy's conversations, and why they are stored here rather than by the
 * harness.
 *
 * Prime Agent ships a `SessionManager` with file-backed sessions, and PIG
 * deliberately does not use it for storage. Three reasons, all of which look
 * like an unnecessary complication until the fourth week in production:
 *
 *   - PIG's source of truth is Postgres. A transcript is a record of what the
 *     company was told about its own book; it belongs with the book.
 *   - A session must be per-user and stay that way. Ownership here is a column
 *     and a predicate on every query, not a filename someone could guess.
 *   - The chat server runs in a container and will one day run in two. A file
 *     under the agent directory survives neither a restart nor a second
 *     replica, and the failure is silent: a user's history simply empties.
 *
 * So the harness is handed `SessionManager.inMemory()` for the length of one
 * turn and the history is rehydrated from these tables. If you are here to
 * "simplify" that by pointing the harness at its own store, this is the note
 * saying it was considered and refused.
 *
 * The transcript keeps evidence, not just prose. Reopening a conversation has
 * to show what Piggy DID — which tool ran, with what arguments, what came
 * back, what was proposed for approval and how the user answered — because the
 * product's whole claim is that an agent writing to a CRM is auditable. A
 * transcript that reloads as a wall of assistant text quietly withdraws it.
 * The authoritative record of a mutation is still the `activities` row written
 * by `executeMutation`; this is the record of the conversation that led to it.
 */
export const piggyConversations = pgTable(
  'piggy_conversations',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    /**
     * The owner, and the only person who may read it. `cascade` rather than
     * the `set null` used for the audit-ish references above: an ownerless
     * transcript is not a historical record anyone can act on, it is a pile of
     * somebody's private questions with no one left to answer for them.
     */
    userId: uuid('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),

    /** Derived from the first user message, renameable. Never empty. */
    title: text('title').notNull(),

    /**
     * The last turn's model and mode, so reopening a conversation restores the
     * picker rather than silently answering the next question on a different
     * model from the one the rest of the thread was answered by.
     */
    model: text('model'),
    mode: text('mode', { enum: PIGGY_MODES }),

    /**
     * The capability the transcript itself requires, raised by the relay to
     * whatever the turn's context needed.
     *
     * Without this, a person demoted out of `economics:read` keeps a doorway
     * to supplier cost and break-even: they cannot ask for margin any more,
     * but yesterday's answer is still sitting in their history. The floor in
     * READ_RULES cannot see it, because a path-keyed table cannot know what a
     * particular conversation was told.
     */
    readCapability: text('read_capability', { enum: READ_CAPABILITIES })
      .notNull()
      .default('book:read'),

    /** Where the conversation was opened from, for the sidebar's subtitle. */
    context: jsonb('context').$type<PiggyChatContext>(),

    /**
     * When the transcript last gained a message — the sidebar's sort key.
     * Separate from `updatedAt` on purpose: renaming a conversation is not
     * activity, and should not shuffle it to the top of somebody's history.
     */
    lastMessageAt: timestamp('last_message_at', { withTimezone: true }).notNull().defaultNow(),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    /** The only listing query there is: my conversations, most recent first. */
    index('piggy_conversations_user_recent_idx').on(t.userId, t.lastMessageAt.desc()),
    check('piggy_conversations_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
    check(
      'piggy_conversations_read_capability_check',
      sql`${t.readCapability} IN (${inList(READ_CAPABILITIES)})`,
    ),
    check('piggy_conversations_title_check', sql`length(${t.title}) > 0`),
  ],
);

/**
 * One row per entry the transcript renders: a user message, an assistant
 * message, or a tool call together with its approval record.
 *
 * The tool call and its approval share a row deliberately. They are one event
 * to a reader — "Piggy offered to log a call, you approved it, it wrote" — and
 * splitting them across rows means a reload can show the offer without the
 * answer, which is the one rendering that misleads.
 *
 * Persistence is best-effort with respect to the mutation: a crash between the
 * write and this row loses the transcript entry, never the audit trail.
 */
export const piggyMessages = pgTable(
  'piggy_messages',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    conversationId: uuid('conversation_id')
      .notNull()
      .references(() => piggyConversations.id, { onDelete: 'cascade' }),

    /**
     * Position in the transcript. `created_at` is not enough: a tool call and
     * its result are written in the same millisecond, and a Date carries no
     * more precision than that, so ordering on the timestamp alone renders
     * them in an arbitrary order about a third of the time.
     */
    seq: integer('seq').notNull(),

    role: text('role', { enum: PIGGY_MESSAGE_ROLES }).notNull(),
    /** Empty for a tool row, whose content is the call and its result. */
    content: text('content').notNull().default(''),
    /** Kept for reasoning models: what it thought, as the transcript showed it. */
    reasoning: text('reasoning'),

    /** Which model answered. It varies per turn now that the user chooses. */
    model: text('model'),
    /** The mode the turn ran in — an approval card means nothing without it. */
    mode: text('mode', { enum: PIGGY_MODES }),

    inputTokens: integer('input_tokens'),
    outputTokens: integer('output_tokens'),
    /** Micro-cents, matching `agent_runs.cost_micro_cents`. */
    costMicroCents: integer('cost_micro_cents'),
    /** `length` when the answer was cut short by the token budget. */
    finishReason: text('finish_reason'),

    toolCallId: text('tool_call_id'),
    toolName: text('tool_name'),
    toolArguments: jsonb('tool_arguments').$type<Record<string, unknown>>(),
    toolResult: jsonb('tool_result').$type<Record<string, unknown>>(),
    toolOk: boolean('tool_ok'),

    /**
     * The approval record, stored as it was shown rather than as a pointer to
     * the record it touched. The summary and field values a user actually
     * agreed to are the evidence; re-deriving them from the row later would
     * show what the record says now, which is a different claim.
     */
    approvalId: text('approval_id'),
    approvalChange: jsonb('approval_change').$type<PiggyProposedChange>(),
    /** Null means never answered — a turn that timed out or was abandoned. */
    approvalDecision: text('approval_decision', { enum: PIGGY_APPROVAL_DECISIONS }),
    approvalDecidedAt: timestamp('approval_decided_at', { withTimezone: true }),

    /** A failed turn or a failed tool. Shown, not swallowed. */
    error: text('error'),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    /**
     * Ordering and the append guard in one index. A conversation is always
     * read whole and in order, so this is also the only index it needs — its
     * leading column serves the `conversation_id` lookups on its own.
     */
    uniqueIndex('piggy_messages_conversation_seq_key').on(t.conversationId, t.seq),
    check('piggy_messages_role_check', sql`${t.role} IN (${inList(PIGGY_MESSAGE_ROLES)})`),
    check('piggy_messages_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
    check(
      'piggy_messages_approval_decision_check',
      sql`${t.approvalDecision} IS NULL OR ${t.approvalDecision} IN (${inList(PIGGY_APPROVAL_DECISIONS)})`,
    ),
    check('piggy_messages_seq_check', sql`${t.seq} >= 0`),
  ],
);

/** One execution of the agent, for cost accounting and debugging. */
export const agentRuns = pgTable(
  'agent_runs',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    agentTaskId: uuid('agent_task_id').references(() => agentTasks.id, {
      onDelete: 'set null',
    }),

    /**
     * The Piggy conversation this run answered, when it was one — which makes
     * "what has this thread cost?" a sum over this column rather than a join
     * through the transcript. `set null` because the spend happened whatever
     * became of the conversation: deleting a thread must not delete the record
     * of the credit it burned.
     */
    piggyConversationId: uuid('piggy_conversation_id').references(
      () => piggyConversations.id,
      { onDelete: 'set null' },
    ),

    /** Which agent — 'piggy', or a user's own connected client. */
    agent: text('agent').notNull().default('piggy'),
    /** The person on whose behalf this ran. */
    principalUserId: uuid('principal_user_id').references(() => users.id, {
      onDelete: 'set null',
    }),

    status: text('status').notNull().default('running'),
    model: text('model'),
    inputTokens: integer('input_tokens'),
    outputTokens: integer('output_tokens'),
    costMicroCents: integer('cost_micro_cents'),

    input: jsonb('input').$type<Record<string, unknown>>(),
    result: jsonb('result').$type<Record<string, unknown>>(),
    summary: text('summary'),
    error: text('error'),

    startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
    finishedAt: timestamp('finished_at', { withTimezone: true }),
  },
  (t) => [
    index('agent_runs_task_idx').on(t.agentTaskId),
    index('agent_runs_principal_idx').on(t.principalUserId),
    /** Per-conversation spend is one indexed scan, not a table sweep. */
    index('agent_runs_piggy_conversation_idx').on(t.piggyConversationId),
  ],
);

/**
 * Side effects the agent performed. Every row that touches the world outside
 * PIG lands here first, keyed by an idempotency key, so a retry is a no-op
 * rather than a second message to a customer.
 */
export const agentActions = pgTable(
  'agent_actions',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    agentRunId: uuid('agent_run_id').references(() => agentRuns.id, { onDelete: 'cascade' }),

    /** 'update_record' | 'send_slack' | 'post_buzz' | 'create_task' */
    type: text('type').notNull(),
    targetType: text('target_type'),
    targetId: text('target_id'),
    summary: text('summary'),

    /**
     * Unique. This is the whole safety mechanism: an action is attempted at
     * most once, no matter how many times its task is retried.
     */
    idempotencyKey: text('idempotency_key').notNull(),

    status: text('status').notNull().default('pending'),
    externalId: text('external_id'),
    error: text('error'),
    metadata: jsonb('metadata').$type<Record<string, unknown>>(),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    uniqueIndex('agent_actions_idempotency_key').on(t.idempotencyKey),
    index('agent_actions_run_idx').on(t.agentRunId),
  ],
);

export type AgentTask = typeof agentTasks.$inferSelect;
export type NewAgentTask = typeof agentTasks.$inferInsert;
export type Fact = typeof facts.$inferSelect;
export type NewFact = typeof facts.$inferInsert;
export type AgentRun = typeof agentRuns.$inferSelect;
export type PiggyConversation = typeof piggyConversations.$inferSelect;
export type NewPiggyConversation = typeof piggyConversations.$inferInsert;
export type PiggyMessage = typeof piggyMessages.$inferSelect;
export type NewPiggyMessage = typeof piggyMessages.$inferInsert;
