/**
 * The motion page tools, and the one rule none of them may relax.
 *
 * Motion is the first feature in PIG with a row-level access rule: a `private`
 * template belongs to its owner and to a platform admin, and to nobody else.
 * Every other read in this process is book-wide, so the habit of the codebase
 * is against this clause rather than for it — which is exactly why it is pinned
 * here rather than left to a review.
 *
 * Piggy cannot enforce ownership because it does not reliably know who is
 * asking: the dock publishes a route and the relay checks a capability, and
 * neither reaches the query. So the query is closed instead. The tests below
 * assert the closed form under every filter combination a model can send,
 * because the plausible-but-wrong version of this code is one where the clause
 * is present in the unfiltered read and lost in a branch.
 *
 * The unit suite runs in CI BEFORE the migration step, against a database with
 * no tables, so nothing here may execute a query. The WHERE clause is rendered
 * with `PgDialect` rather than run, and the tools themselves are executed
 * against a stub handle — the second half of this file, which pins the figures
 * they report and the denominators those figures are drawn from.
 */
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOTION_KINDS } from '@pig/core';
import {
  engagementArtifacts,
  engagements,
  motionTemplates,
  qualificationScores,
  type Database,
} from '@pig/db';
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assertPigToolBoundary } from '../src/chat';
import { createPagePigTools, motionLibraryWhere, type ResultScope } from '../src/page-tools';

/** Schema and SQL-shape checks only: no query is executed. */
const db = {} as Database;
const dialect = new PgDialect();

function renderedWhere(filter: Parameters<typeof motionLibraryWhere>[0]): {
  text: string;
  params: unknown[];
} {
  const query = dialect.sqlToQuery(motionLibraryWhere(filter));
  return { text: query.sql, params: query.params };
}

function tool(route: '/motion' | '/motion/library' | '/motion/engagements') {
  const [only, ...rest] = createPagePigTools(db, route);
  assert.ok(only, `${route} has a tool`);
  // One tool per page: a second is a second thing to choose wrongly, and a
  // wrong choice costs one of four turns.
  assert.equal(rest.length, 0);
  return only;
}

// ---------------------------------------------------------------------------
// The visibility invariant. Do not relax this to "unless the user owns it".
// ---------------------------------------------------------------------------

test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => {
  const filters: Parameters<typeof motionLibraryWhere>[0][] = [
    {},
    { kind: 'proposal' },
    { stage: 'poc' },
    { query: 'sovereign' },
    { kind: 'playbook', stage: 'deployment', query: 'inference' },
    // The nulls a schema-abiding model sends for "no filter" must not read as
    // "no visibility filter either".
    { kind: null, stage: null, query: null },
  ];

  for (const filter of filters) {
    const { params } = renderedWhere(filter);
    assert.ok(
      params.includes('shared'),
      `visibility = 'shared' is missing for ${JSON.stringify(filter)}`,
    );
    // Bound to the column, not merely present somewhere in the statement.
    assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/);
  }
});

test('no filter a model can send widens the library beyond shared — private is not a parameter', () => {
  const library = tool('/motion/library');
  const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;

  // `.strict()`, so every one of these is refused rather than ignored. A tool
  // that silently drops an unknown key teaches a model to keep trying.
  assert.equal(accepts({ visibility: 'private' }), false);
  assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false);
  assert.equal(accepts({ includePrivate: true }), false);
  assert.equal(accepts({ all: '1' }), false);
  assert.equal(accepts({ kind: 'proposal' }), true);
});

test('a private template is invisible even when its title is the search term', () => {
  // The query filter is an AND alongside the visibility clause, never an OR
  // beside it: an `or(...)` at the top level would make any matching title
  // satisfy the whole WHERE and return the private row.
  const { text, params } = renderedWhere({ query: 'Halcyon' });
  const [visibility] = text.split('and');
  assert.ok(visibility?.includes('"visibility"'), text);
  assert.ok(text.startsWith('('), text);
  assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /);
  assert.equal(params[0], 'shared');
});

test('archived templates are excluded from every library read', () => {
  // Archiving is how deletion works here, so a query that ignores it hands the
  // model practice somebody deliberately withdrew.
  assert.match(renderedWhere({}).text, /"archived_at" is null/);
  assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/);
});

test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => {
  // Unescaped, `%` matches every shared template and the model is handed the
  // first eight as though they answered the question.
  assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']);
});

// ---------------------------------------------------------------------------
// The boundary
// ---------------------------------------------------------------------------

test('every motion page tool sits inside the PIG tool boundary', () => {
  const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')];
  assert.deepEqual(tools.map((entry) => entry.name), [
    'pig_get_motion_summary',
    'pig_search_motion_library',
    'pig_get_engagement',
  ]);
  // The assertion the chat provider runs on every request: a name that fails it
  // takes the whole conversation down rather than one tool.
  assert.doesNotThrow(() => assertPigToolBoundary(tools));
  for (const entry of tools) {
    assert.ok(entry.description.length > 40, `${entry.name} has a usable description`);
  }
});

test('the library and engagement tools say out loud that private drafts are not read', () => {
  // The description is the only place the model learns the limit, and "I found
  // nothing" is a materially different answer from "I cannot see private
  // drafts" to someone looking at their own.
  assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/);
  assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/);
});

// ---------------------------------------------------------------------------
// The input bounds
// ---------------------------------------------------------------------------

test('the motion summary takes no input at all', () => {
  const summary = tool('/motion');
  assert.equal(summary.inputSchema.safeParse({}).success, true);
  assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false);
});

test('the library filters accept only ontology values, and a bounded query', () => {
  const library = tool('/motion/library');
  const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;

  for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true);
  // A kind the model invented reaches the database as a cast error rather than
  // a miss, so it is refused at the schema.
  assert.equal(accepts({ kind: 'battlecard' }), false);
  assert.equal(accepts({ stage: 'closed_won' }), true);
  assert.equal(accepts({ stage: 'negotiation' }), false);
  // Trimmed before the length check, so trailing whitespace cannot smuggle a
  // one-character query past the floor and match the whole library.
  assert.equal(accepts({ query: ' a ' }), false);
  assert.equal(accepts({ query: 'x'.repeat(64) }), true);
  assert.equal(accepts({ query: 'x'.repeat(65) }), false);
  assert.equal(accepts({ query: 'x'.repeat(4000) }), false);
});

test('the engagement query is bounded and refuses anything it does not name', () => {
  const engagement = tool('/motion/engagements');
  const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success;

  assert.equal(accepts({}), true);
  assert.equal(accepts({ query: 'Halcyon' }), true);
  assert.equal(accepts({ query: 'a' }), false);
  assert.equal(accepts({ query: 'x'.repeat(65) }), false);
  assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false);
  assert.equal(accepts({ limit: 500 }), false);
});

/**
 * What the model is actually sent, rather than what the zod reads like.
 *
 * `zodToJsonSchema(..., { target: 'openAi' })` — the exact call both inference
 * paths make — emits an optional field as REQUIRED and nullable, so a
 * schema-abiding model sends `null` for every filter it does not want and
 * `.optional()` would reject the call. A `.describe()` applied after the
 * wrapper is dropped from the emitted schema entirely.
 */
test('an omitted motion filter arrives as the null the emitted schema asks for', () => {
  const library = tool('/motion/library');
  assert.equal(
    library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success,
    true,
  );
  assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true);

  const emitted = zodToJsonSchema(library.inputSchema, {
    $refStrategy: 'none',
    target: 'openAi',
  }) as { properties?: Record<string, { description?: string }>; required?: string[] };
  assert.deepEqual(emitted.required, ['kind', 'stage', 'query']);
  for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) {
    assert.ok(
      shape.description && shape.description.length > 10,
      `${parameter} reaches the model with no description`,
    );
  }
});

// ---------------------------------------------------------------------------
// The figures the tools report
// ---------------------------------------------------------------------------

/**
 * A stub handle, because the unit suite still may not execute a query.
 *
 * Drizzle's builder is a promise you can keep calling methods on, so this is
 * the same: every chaining method returns itself and `then` resolves the rows.
 * What is under test here is the shaping — which figure reaches the headline
 * and what denominator sits beside it — not the SQL, which
 * `e2e/page-tools.test.ts` covers against a real book.
 *
 * The where clauses are not evaluated: a stub that reimplemented them would be
 * testing itself, so a filtered read is fixtured as the rows it returned. They
 * ARE rendered, for the one assertion that has to see them — a denominator
 * taken over the whole table rather than the shared library would publish the
 * size of everybody's private drafts, and it would look exactly like this from
 * the outside.
 */
interface StubMotion {
  /** What the library read returned. Shared, non-archived, versions included. */
  templates: readonly Record<string, unknown>[];
  /** `count(distinct slug)`: lineages, not rows. Fixtured, not derived. */
  lineageTotal: number;
  /** The promotion exemplars, capped by the tool, and their exact total. */
  promotionRows?: readonly Record<string, unknown>[];
  promotionTotal?: number;
  /** The open engagements the summary counts by stage. */
  openEngagements?: readonly Record<string, unknown>[];
  /** What the engagement search matched, against every engagement on the book. */
  engagementRows?: readonly Record<string, unknown>[];
  engagementTotal?: number;
  artifacts?: readonly Record<string, unknown>[];
  scores?: readonly Record<string, unknown>[];
}

function stubBook(book: StubMotion): { handle: Database; denominators: string[] } {
  const denominators: string[] = [];

  const rowsFor = (
    table: unknown,
    projection: Record<string, unknown>,
    where: string,
  ): readonly unknown[] => {
    const counting = Object.hasOwn(projection, 'value');
    if (table === motionTemplates) {
      if (counting) {
        denominators.push(where);
        // Both counts are taken over `motion_templates`; only the promotion one
        // asks for an origin artefact, which is what tells them apart here.
        return where.includes('origin_artifact_id')
          ? [{ value: book.promotionTotal ?? 0 }]
          : [{ value: book.lineageTotal }];
      }
      // The promotion exemplars are the only template read carrying a date.
      return Object.hasOwn(projection, 'createdAt') ? (book.promotionRows ?? []) : book.templates;
    }
    if (table === engagements) {
      if (counting) {
        denominators.push(where);
        return [{ value: book.engagementTotal ?? 0 }];
      }
      // The summary asks for a stage per open engagement; the search asks for
      // the row.
      return Object.hasOwn(projection, 'id')
        ? (book.engagementRows ?? [])
        : (book.openEngagements ?? []);
    }
    if (table === engagementArtifacts) return book.artifacts ?? [];
    if (table === qualificationScores) return book.scores ?? [];
    throw new Error('the stub was asked for a table this suite does not fixture');
  };

  const select = (projection: Record<string, unknown>) => ({
    from: (table: unknown) => {
      let where = '';
      const builder: Record<string, unknown> = {
        where: (clause: SQL | undefined) => {
          if (clause) where = dialect.sqlToQuery(clause).sql;
          return builder;
        },
      };
      for (const method of ['limit', 'orderBy', 'groupBy', 'innerJoin', 'leftJoin']) {
        builder[method] = () => builder;
      }
      builder.then = (resolve: (value: readonly unknown[]) => unknown) =>
        resolve(rowsFor(table, projection, where));
      return builder;
    },
  });
  return { handle: { select } as unknown as Database, denominators };
}

type Reading = Record<string, unknown> & { headline?: string; scope?: ResultScope };

async function read(
  route: '/motion' | '/motion/library' | '/motion/engagements',
  handle: Database,
  input: Record<string, unknown> = {},
): Promise<Reading> {
  const [only] = createPagePigTools(handle, route);
  assert.ok(only, `no tool for ${route}`);
  return (await only.execute(input)) as Reading;
}

function template(slug: string, stage: string, version = 1) {
  return {
    slug,
    kind: 'playbook',
    stage,
    version,
    title: `Template ${slug} v${version}`,
    summary: 'A shared template.',
    usageCount: 3,
  };
}

/**
 * Twelve lineages in thirteen rows, covering six of the eight open stages.
 *
 * The thirteenth row is a second version of the first lineage, and it is there
 * because the two figures differ: a library reported by rows says 13 when the
 * number of pieces of practice anybody can choose from is 12.
 */
const COVERED_STAGES = ['qualification', 'legal', 'scoping', 'proposal', 'poc', 'expansion'];
const SHARED_ROWS = [
  ...Array.from({ length: 12 }, (_, i) =>
    template(`starter-${i}`, COVERED_STAGES[i % COVERED_STAGES.length]!),
  ),
  template('starter-0', 'qualification', 2),
];
const LINEAGES = 12;

/** Twelve promotions, of which the tool may show eight. The gap is the point. */
const PROMOTION_TOTAL = 12;
const PROMOTION_ROWS = Array.from({ length: 8 }, (_, i) => ({
  title: `Promoted ${i}`,
  kind: 'case_study',
  version: 2,
  createdAt: new Date(Date.UTC(2026, 0, i + 1)),
}));

const LIBRARY = stubBook({
  templates: SHARED_ROWS,
  lineageTotal: LINEAGES,
  promotionRows: PROMOTION_ROWS,
  promotionTotal: PROMOTION_TOTAL,
  openEngagements: Array.from({ length: 5 }, () => ({ stage: 'poc', dealName: 'DEMO — Halcyon' })),
});

test('the promotion figure is the exact total, not the length of the list beside it', async () => {
  const reading = await read('/motion', LIBRARY.handle);

  // The /motion tile counts promotions in SQL for exactly this reason. Piggy
  // reading the length of its own capped list would peg the answer at 8 the
  // moment the loop started working, and disagree with the tile on screen.
  assert.equal(reading.promotions, PROMOTION_TOTAL);
  assert.match(String(reading.headline), /12 artifact\(s\) promoted back into the library/);
  assert.doesNotMatch(String(reading.headline), /8 artifact\(s\) promoted/);

  const scope = reading.recentPromotionsScope as ResultScope;
  assert.equal(scope.total, PROMOTION_TOTAL);
  assert.equal(scope.listed, PROMOTION_ROWS.length);
  assert.equal((reading.recentPromotions as unknown[]).length, PROMOTION_ROWS.length);
});

test('the motion summary counts lineages, not versions', async () => {
  const reading = await read('/motion', LIBRARY.handle);
  const scope = reading.scope;
  assert.ok(scope);

  // 13 rows, 12 lineages. A denominator of 13 would be a library four times the
  // size of the one anybody can choose from, in miniature.
  assert.equal(scope.total, LINEAGES);
  assert.equal(reading.sharedTemplates, LINEAGES);
  assert.notEqual(scope.total, SHARED_ROWS.length);
  assert.match(scope.totalLabel, /shared template lineage\(s\)/);
});

test('no motion denominator is taken over the whole template table', async () => {
  await read('/motion', LIBRARY.handle);
  await read('/motion/library', LIBRARY.handle, {});
  assert.ok(LIBRARY.denominators.length >= 2);
  for (const where of LIBRARY.denominators) {
    // A total that counted private rows would publish the existence and the
    // size of colleagues' drafts through a figure nobody thinks of as a read.
    assert.match(where, /"visibility" = \$\d+/, where);
    assert.match(where, /"archived_at" is null/, where);
  }
});

test('the stages the library misses are named when the whole library was read', async () => {
  const reading = await read('/motion', LIBRARY.handle);
  // Six stages are covered by the fixture, so the two that are not are a piece
  // of work somebody can act on — which is why this is a list and not a count.
  assert.deepEqual(reading.uncoveredStages, ['procurement', 'deployment']);
  assert.match(String(reading.headline), /No shared template covers procurement, deployment\./);
});

test('stage coverage is not asserted from a capped scan', async () => {
  // SCAN_LIMIT is 500 and the read asks for one more, so 501 rows is a library
  // that certainly continues past the cap.
  const capped = stubBook({
    templates: Array.from({ length: 501 }, (_, i) => template(`over-${i}`, 'qualification')),
    lineageTotal: 501,
    promotionRows: PROMOTION_ROWS,
    promotionTotal: PROMOTION_TOTAL,
  });
  const reading = await read('/motion', capped.handle);

  // "No shared template covers deployment" is a definite negative, and the rows
  // that would refute it are precisely the ones the cap dropped. Null says the
  // question was not answered; an empty list would say every stage is covered
  // and a full list would say seven are not, and both are inventions.
  assert.equal(reading.uncoveredStages, null);
  assert.doesNotMatch(String(reading.headline), /No shared template covers/);
  assert.doesNotMatch(String(reading.headline), /Every live demand stage/);
  assert.match(String(reading.headline), /row cap/);
});

test('a filtered library search is counted against the whole shared library', async () => {
  // The stub evaluates no where clause, so what a kind filter matched is
  // fixtured: three lineages, out of a library that still holds twelve.
  const filtered = stubBook({
    templates: [
      template('proposal-blocks', 'proposal'),
      template('proposal-terms', 'procurement'),
      template('proposal-exec', 'proposal'),
    ],
    lineageTotal: LINEAGES,
  });
  const reading = await read('/motion/library', filtered.handle, { kind: 'proposal' });
  const scope = reading.scope;
  assert.ok(scope);

  assert.equal(scope.matched, 3);
  assert.equal(scope.total, LINEAGES);
  assert.equal(reading.count, 3);
  assert.equal(reading.sharedTemplates, LINEAGES);
  // The denominator has to reach the headline, because the headline is the
  // field a small model quotes: "3 shared templates" alone is the size of a
  // filter presented as the size of the library.
  assert.match(String(reading.headline), /3 of 12 shared template lineage\(s\)/);
  assert.match(scope.summary, /the total is 12/);
  assert.equal(scope.filters.kind, 'proposal');
});

test('an unfiltered library search says so rather than hedging an exact figure', async () => {
  const reading = await read('/motion/library', LIBRARY.handle, {
    kind: null,
    stage: null,
    query: null,
  });
  const scope = reading.scope;
  assert.ok(scope);

  // Nothing was filtered out, so `matched` IS the total. Reporting the nulls a
  // schema-abiding model sends as filters would teach it to distrust a figure
  // that is exact.
  assert.equal(scope.matched, scope.total);
  assert.deepEqual(scope.filters, {});
  assert.match(scope.summary, /All 12 shared template lineage\(s\) in the motion library/);
});

test('an engagement search is counted against every engagement on the book', async () => {
  const book = stubBook({
    templates: [],
    lineageTotal: LINEAGES,
    engagementTotal: 9,
    engagementRows: [
      {
        id: 'engagement-1',
        status: 'open',
        summary: 'Mid POC.',
        openedAt: new Date(Date.UTC(2026, 1, 1)),
        stage: 'poc',
        dealName: 'DEMO — Halcyon Research',
        accountName: 'DEMO — Halcyon Research',
      },
      {
        id: 'engagement-2',
        status: 'closed',
        summary: 'Won.',
        openedAt: new Date(Date.UTC(2025, 10, 1)),
        stage: 'expansion',
        dealName: 'DEMO — Halcyon Expansion',
        accountName: 'DEMO — Halcyon Research',
      },
    ],
    artifacts: [{ engagementId: 'engagement-1', status: 'final' }],
    scores: [],
  });
  const reading = await read('/motion/engagements', book.handle, { query: 'Halcyon' });
  const scope = reading.scope;
  assert.ok(scope);

  assert.equal(scope.matched, 2);
  assert.equal(scope.total, 9);
  assert.equal(reading.totalEngagements, 9);
  assert.match(String(reading.headline), /2 of 9 engagement\(s\) on the book match "Halcyon"/);
  assert.equal(scope.filters.query, 'Halcyon');
});

test('every motion result carries a complete scope, and no scope outruns its own total', async () => {
  const readings = [
    await read('/motion', LIBRARY.handle),
    await read('/motion/library', LIBRARY.handle, {}),
    await read('/motion/engagements', stubBook({ templates: [], lineageTotal: 0 }).handle),
  ];

  for (const reading of readings) {
    const found = Object.entries(reading)
      .filter(([key]) => key === 'scope' || key.endsWith('Scope'))
      .map(([, value]) => value as ResultScope);
    assert.ok(found.length > 0, `a motion result carries no scope at all: ${reading.headline}`);
    for (const scope of found) {
      assert.ok(scope.summary.length > 0);
      assert.ok(scope.totalLabel.length > 0);
      // The denominator has to reach the sentence, because the sentence is what
      // gets quoted.
      assert.match(scope.summary, new RegExp(`\\b${scope.total}\\b`));
      assert.ok(scope.matched <= scope.total, 'matched exceeds its own denominator');
      assert.ok(scope.listed <= scope.matched, 'more rows listed than matched');
    }
  }
});
