/**
 * Piggy's conversation history over HTTP.
 *
 * Five routes, and the only interesting thing about them is what they refuse.
 * Every one is scoped to the calling principal by `PiggyConversationService`,
 * which puts `user_id = $me` into the statement itself — so a conversation
 * belonging to somebody else and a UUID that was never issued produce the same
 * 404, and no handler here has to remember to compare an owner.
 *
 * The two GETs also carry a `book:read` row in READ_RULES. That is the floor,
 * not the whole answer: what a particular transcript may contain is a property
 * of the conversation, not of the path, so `detail` re-checks the capability
 * stored on the row. Both halves are needed — the table denies the stranger
 * and the write-only credential, the row denies the demoted member their own
 * old margin figures.
 *
 * Writes do not use the `mutation` helper. See the service for why: an audit
 * activity per message would bury the activity log this convention exists to
 * keep readable, and there is no team capability to enforce on a record whose
 * only relationship is ownership. The `write` scope is still required, so a
 * read-only credential cannot rename or delete anything.
 */
import { PIGGY_MODES, PIGGY_PAGE_ROUTES, PIGGY_RECORD_TYPES } from '@pig/core';
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireScope } from '../lib/auth';
import { apiError, type ApiEnv } from '../lib/mutation';
import {
  PIGGY_TITLE_MAX,
  PiggyConversationService,
} from '../services/piggy-conversations';

/**
 * Spelled here as well as in `piggy-chat.ts` because both hops validate what
 * crosses them; `.strict()` on each means a context arm added in one place and
 * missed in the other is a 400 rather than a silently dropped field.
 */
const contextSchema = z.discriminatedUnion('type', [
  z
    .object({
      type: z.enum(PIGGY_RECORD_TYPES),
      id: z.string().uuid(),
      label: z.string().max(240).optional(),
    })
    .strict(),
  z
    .object({
      type: z.literal('page'),
      route: z.enum(PIGGY_PAGE_ROUTES),
      label: z.string().max(240).optional(),
    })
    .strict(),
]);

const createSchema = z
  .object({
    title: z.string().trim().min(1).max(PIGGY_TITLE_MAX).optional(),
    /** The opening question, when the client had one. Names the thread. */
    firstMessage: z.string().trim().min(1).max(4_000).optional(),
    model: z.string().min(1).max(200).optional(),
    mode: z.enum(PIGGY_MODES).optional(),
    context: contextSchema.optional(),
  })
  .strict();

const renameSchema = z
  .object({ title: z.string().trim().min(1).max(PIGGY_TITLE_MAX) })
  .strict();

/**
 * A malformed id is answered as a missing one, not as a 400.
 *
 * Two reasons, one of them practical: Postgres raises `invalid input syntax
 * for type uuid` on a non-UUID parameter, which would leave the handler
 * throwing a 500 on any typed URL. The other is that "not a valid id" and "not
 * your id" should be indistinguishable from outside.
 */
const idSchema = z.string().uuid();

export function createPiggyConversationRoutes(db: Database): Hono<ApiEnv> {
  const routes = new Hono<ApiEnv>();
  const conversations = new PiggyConversationService(db);

  routes.get('/api/piggy/conversations', async (c) => {
    return c.json(await conversations.list(c.get('principal')));
  });

  routes.post('/api/piggy/conversations', async (c) => {
    const principal = c.get('principal');
    requireScope(principal, 'write');

    let raw: unknown = {};
    // An empty body is the ordinary case — the composer opens a thread before
    // anyone has typed — so it must not be a 400.
    try {
      const text = await c.req.text();
      raw = text.length > 0 ? JSON.parse(text) : {};
    } catch {
      return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
    }

    const parsed = createSchema.safeParse(raw);
    if (!parsed.success) {
      return c.json(
        apiError('invalid_request', 'Invalid conversation.', parsed.error.issues),
        400,
      );
    }

    const created = await conversations.create(principal, parsed.data);
    return c.json(created, 201);
  });

  routes.get('/api/piggy/conversations/:id', async (c) => {
    const id = idSchema.safeParse(c.req.param('id'));
    if (!id.success) return c.json(apiError('not_found', 'Conversation not found.'), 404);

    const detail = await conversations.detail(c.get('principal'), id.data);
    if (!detail) return c.json(apiError('not_found', 'Conversation not found.'), 404);
    return c.json(detail);
  });

  routes.patch('/api/piggy/conversations/:id', async (c) => {
    const principal = c.get('principal');
    requireScope(principal, 'write');

    const id = idSchema.safeParse(c.req.param('id'));
    if (!id.success) return c.json(apiError('not_found', 'Conversation not found.'), 404);

    let raw: unknown;
    try {
      raw = await c.req.json();
    } catch {
      return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
    }
    const parsed = renameSchema.safeParse(raw);
    if (!parsed.success) {
      return c.json(apiError('invalid_request', 'Invalid title.', parsed.error.issues), 400);
    }

    const renamed = await conversations.rename(principal, id.data, parsed.data.title);
    if (!renamed) return c.json(apiError('not_found', 'Conversation not found.'), 404);
    return c.json(renamed);
  });

  routes.delete('/api/piggy/conversations/:id', async (c) => {
    const principal = c.get('principal');
    requireScope(principal, 'write');

    const id = idSchema.safeParse(c.req.param('id'));
    if (!id.success) return c.json(apiError('not_found', 'Conversation not found.'), 404);

    const removed = await conversations.remove(principal, id.data);
    if (!removed) return c.json(apiError('not_found', 'Conversation not found.'), 404);
    // The messages went with it, by the foreign key rather than by a second
    // statement here. See `piggy_messages.conversation_id`.
    return c.json({ id: id.data, deleted: true });
  });

  return routes;
}
