/**
 * GET /api/calendar and the CRUD for the one table it owns.
 *
 * The read endpoint is the first in this API to accept a date range and filter
 * on it server-side. Every other list route is `order by updated_at desc limit
 * 300` with the browser filtering afterwards, which means the records dated
 * inside a quarter are not guaranteed to be in the response — the failure this
 * route exists to remove. Nothing here computes anything; the projection lives
 * in the service, per the rule that intelligence never lives in the API.
 */
import { and, eq } from 'drizzle-orm';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import {
  CALENDAR_ENTRY_KINDS,
  CALENDAR_EVENT_KINDS,
  isCalendarEventKind,
  isValidTimeZone,
  parseQuarter,
  permissionGranted,
  quarterBounds,
  quarterBoundsFor,
  type CalendarEventKind,
} from '@pig/core';
import { accounts, calendarEntries, demandDeals, supplyDeals, users } from '@pig/db';
import type { CalendarEntry, Database } from '@pig/db';
import { effectivePermissions, requireCapability, type Principal } from '../lib/auth';
import {
  MutationError,
  apiError,
  bodylessMutation,
  mutation,
  type ApiEnv,
  type MutationDefinition,
} from '../lib/mutation';
import { CalendarService } from '../services/calendar';

/**
 * A dated item belongs to whoever runs the motion, so either pipeline's
 * write-capable members may keep the calendar. Mirrors the treatment contracts
 * already give a capability that is meaningful on both sides.
 */
export function requireCalendarWrite(principal: Principal): void {
  const grants = effectivePermissions(principal);
  if (
    permissionGranted(grants, 'deal:write', 'supply') ||
    permissionGranted(grants, 'deal:write', 'demand')
  ) {
    return;
  }
  // Re-run the check so the caller gets the standard 403 envelope rather than
  // a bespoke one, and so a scopeless credential is reported as such.
  requireCapability(principal, 'deal:write', 'demand');
}

export function calendarReadAllowed(scopes: readonly string[]): boolean {
  return scopes.includes('read');
}

/**
 * Comma-separated, and an unknown kind is an error rather than a silent empty
 * result — a typo in `kinds` that returns nothing looks exactly like a quiet
 * quarter.
 */
export function parseKinds(raw: string | undefined): CalendarEventKind[] | undefined {
  if (!raw) return undefined;
  const requested = raw
    .split(',')
    .map((kind) => kind.trim())
    .filter(Boolean);
  if (!requested.length) return undefined;
  const unknown = requested.filter((kind) => !isCalendarEventKind(kind));
  if (unknown.length) {
    throw new MutationError(
      'invalid_kinds',
      `Unknown calendar event kind(s): ${unknown.join(', ')}. Known kinds: ${CALENDAR_EVENT_KINDS.join(', ')}.`,
      400,
    );
  }
  return requested as CalendarEventKind[];
}

const isoDate = z.string().datetime();
const nullableId = z.string().uuid().nullable();

const entryFields = {
  title: z.string().min(1).max(240).optional(),
  description: z.string().max(8000).nullable().optional(),
  kind: z.enum(CALENDAR_ENTRY_KINDS).optional(),
  startsAt: isoDate.optional(),
  endsAt: isoDate.nullable().optional(),
  allDay: z.boolean().optional(),
  ownerUserId: nullableId.optional(),
  accountId: nullableId.optional(),
  demandDealId: nullableId.optional(),
  supplyDealId: nullableId.optional(),
  completedAt: isoDate.nullable().optional(),
};

const createEntrySchema = z.object({
  ...entryFields,
  title: z.string().min(1).max(240),
  startsAt: isoDate,
});
const updateEntrySchema = z.object(entryFields);

function date(value: string | null | undefined): Date | null | undefined {
  return value === undefined ? undefined : value === null ? null : new Date(value);
}

function requiredRouteParam(params: Readonly<Record<string, string>>): string {
  const value = params.id;
  if (!value) {
    throw new MutationError('invalid_route_parameter', "Route parameter 'id' is required.", 400);
  }
  return value;
}

function writtenRow<Row>(row: Row | undefined): Row {
  if (row === undefined) {
    throw new Error('Calendar entry write completed without returning a row.');
  }
  return row;
}

type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];

/**
 * The nullable foreign keys are polymorphic, so nothing in the schema stops an
 * entry pointing at an account and a deal belonging to someone else. Checked
 * here, where the intent is known.
 */
async function requireRelationships(
  tx: Transaction,
  record: {
    /**
     * Only ever the client's own choice. The default — the author's own id —
     * is a user we have just authenticated, so re-reading it would be a query
     * per create to confirm something the request already proved.
     */
    ownerUserId?: string | null;
    accountId?: string | null;
    demandDealId?: string | null;
    supplyDealId?: string | null;
    startsAt: Date;
    endsAt?: Date | null;
  },
): Promise<void> {
  if (record.endsAt && record.endsAt < record.startsAt) {
    throw new MutationError('invalid_window', 'An entry cannot end before it starts.', 400);
  }
  if (record.ownerUserId) {
    // Assigning to someone who has since been removed is an ordinary client
    // mistake, and without this it surfaces as a 500 from the foreign key
    // rather than the 404 every other polymorphic reference here returns.
    const [owner] = await tx
      .select({ id: users.id })
      .from(users)
      .where(eq(users.id, record.ownerUserId))
      .limit(1);
    if (!owner) throw MutationError.notFound('User');
  }
  if (record.accountId) {
    const [account] = await tx
      .select({ id: accounts.id })
      .from(accounts)
      .where(eq(accounts.id, record.accountId))
      .limit(1);
    if (!account) throw MutationError.notFound('Account');
  }
  if (record.demandDealId) {
    const [deal] = await tx
      .select({ accountId: demandDeals.accountId })
      .from(demandDeals)
      .where(eq(demandDeals.id, record.demandDealId))
      .limit(1);
    if (!deal) throw MutationError.notFound('Demand deal');
    if (record.accountId && deal.accountId !== record.accountId) {
      throw new MutationError(
        'relationship_mismatch',
        'Demand deal belongs to a different account.',
        409,
      );
    }
  }
  if (record.supplyDealId) {
    const [deal] = await tx
      .select({ accountId: supplyDeals.accountId })
      .from(supplyDeals)
      .where(eq(supplyDeals.id, record.supplyDealId))
      .limit(1);
    if (!deal) throw MutationError.notFound('Supply deal');
    if (record.accountId && deal.accountId !== record.accountId) {
      throw new MutationError(
        'relationship_mismatch',
        'Supply deal belongs to a different account.',
        409,
      );
    }
  }
}

export function createEntryMutationDefinition(): MutationDefinition<
  typeof createEntrySchema,
  CalendarEntry
> {
  return {
    schema: createEntrySchema,
    permission: { authorize: requireCalendarWrite },
    invalidMessage: 'Invalid calendar entry.',
    async mutate({ input, principal, tx, now }) {
      const values = {
        ...input,
        startsAt: new Date(input.startsAt),
        endsAt: date(input.endsAt) ?? null,
        completedAt: date(input.completedAt) ?? null,
        // Unassigned work is work nobody does, so an entry defaults to the
        // person creating it rather than to nobody.
        ownerUserId: input.ownerUserId === undefined ? principal.userId : input.ownerUserId,
        createdByUserId: principal.userId,
        updatedAt: now,
      };
      await requireRelationships(tx, { ...values, ownerUserId: input.ownerUserId ?? null });
      const created = writtenRow(
        (await tx.insert(calendarEntries).values(values).returning())[0],
      );
      return {
        data: created,
        activity: {
          type: created.kind === 'meeting' || created.kind === 'qbr' ? 'meeting' : 'task',
          subject: `Scheduled ${created.title}`,
          accountId: created.accountId ?? undefined,
          demandDealId: created.demandDealId ?? undefined,
          supplyDealId: created.supplyDealId ?? undefined,
          meta: {
            calendarEntryId: created.id,
            entryKind: created.kind,
            startsAt: created.startsAt,
          },
        },
      };
    },
  };
}

export function updateEntryMutationDefinition(): MutationDefinition<
  typeof updateEntrySchema,
  CalendarEntry
> {
  return {
    schema: updateEntrySchema,
    permission: { authorize: requireCalendarWrite },
    invalidMessage: 'Invalid calendar entry update.',
    async mutate({ input, params, tx, now }) {
      const id = requiredRouteParam(params);
      const [before] = await tx
        .select()
        .from(calendarEntries)
        .where(eq(calendarEntries.id, id))
        .limit(1);
      if (!before) throw MutationError.notFound('Calendar entry');

      const changes = {
        ...input,
        startsAt: input.startsAt ? new Date(input.startsAt) : undefined,
        endsAt: date(input.endsAt),
        completedAt: date(input.completedAt),
        updatedAt: now,
      };
      // `undefined` means "leave alone" and `null` means "clear", so the row
      // being validated has to be the merge, not the patch.
      await requireRelationships(tx, {
        // Unlike the others this is the patch, not the merge: the stored owner
        // was checked when it was written and may since have been deleted, and
        // failing an unrelated edit over that helps nobody.
        ownerUserId: changes.ownerUserId ?? null,
        accountId: changes.accountId === undefined ? before.accountId : changes.accountId,
        demandDealId:
          changes.demandDealId === undefined ? before.demandDealId : changes.demandDealId,
        supplyDealId:
          changes.supplyDealId === undefined ? before.supplyDealId : changes.supplyDealId,
        startsAt: changes.startsAt ?? before.startsAt,
        endsAt: changes.endsAt === undefined ? before.endsAt : changes.endsAt,
      });
      const updated = writtenRow(
        (
          await tx
            .update(calendarEntries)
            .set(changes)
            .where(eq(calendarEntries.id, before.id))
            .returning()
        )[0],
      );
      return {
        data: updated,
        activity: {
          type: 'task',
          subject: `${updated.completedAt ? 'Completed' : 'Updated'} ${updated.title}`,
          accountId: updated.accountId ?? undefined,
          demandDealId: updated.demandDealId ?? undefined,
          supplyDealId: updated.supplyDealId ?? undefined,
          meta: {
            calendarEntryId: updated.id,
            completedAt: updated.completedAt,
            startsAt: updated.startsAt,
          },
        },
      };
    },
  };
}

export function deleteEntryMutationDefinition(): MutationDefinition<
  z.ZodObject<Record<string, never>>,
  { id: string }
> {
  return {
    schema: z.object({}),
    permission: { authorize: requireCalendarWrite },
    invalidMessage: 'Invalid calendar entry deletion.',
    async mutate({ params, tx }) {
      const id = requiredRouteParam(params);
      const [deleted] = await tx
        .delete(calendarEntries)
        .where(eq(calendarEntries.id, id))
        .returning();
      if (!deleted) throw MutationError.notFound('Calendar entry');
      return {
        data: { id: deleted.id },
        activity: {
          type: 'task',
          subject: `Removed ${deleted.title}`,
          accountId: deleted.accountId ?? undefined,
          demandDealId: deleted.demandDealId ?? undefined,
          supplyDealId: deleted.supplyDealId ?? undefined,
          meta: { calendarEntryId: deleted.id, entryKind: deleted.kind },
        },
      };
    },
  };
}

export const querySchema = z.object({
  from: isoDate.optional(),
  to: isoDate.optional(),
  quarter: z.string().optional(),
  kinds: z.string().optional(),
  accountId: z.string().uuid().optional(),
  ownerUserId: z.string().uuid().optional(),
  /**
   * Zero-based month index the fiscal year starts on. Passed per request
   * because PIG has nowhere to store an organisation-wide fiscal calendar yet,
   * and inventing a settings column here would be a second place for the
   * answer to live. Calendar quarters remain the default.
   */
  fiscalYearStartMonth: z.coerce.number().int().min(0).max(11).optional(),
  /**
   * Checked against ICU here rather than left to the UTC fallback in
   * `@pig/core`: the fallback exists for `users.timezone`, which is already
   * stored and cannot be argued with, whereas a caller who asked for
   * `Mars/Olympus` can be told. It also keeps the formatter cache — keyed on
   * this string — from being fed arbitrary values by a caller in a loop.
   */
  timezone: z
    .string()
    .max(80)
    .refine(isValidTimeZone, { message: 'Unknown IANA time zone.' })
    .optional(),
});

/** The one filter the entries listing takes; a uuid column cannot be asked about free text. */
export const entriesQuerySchema = z.object({
  accountId: z.string().uuid().optional(),
});

export function createCalendarRoutes(db: Database): Hono<ApiEnv> {
  const routes = new Hono<ApiEnv>();
  const service = new CalendarService(db);

  routes.get('/api/calendar', async (context: Context<ApiEnv>) => {
    const principal = context.get('principal');
    if (!calendarReadAllowed(principal.scopes)) {
      return context.json(
        apiError('insufficient_scope', "This credential lacks the 'read' scope."),
        403,
      );
    }

    const parsed = querySchema.safeParse(context.req.query());
    if (!parsed.success) {
      return context.json(
        apiError('invalid_query', 'Invalid calendar query.', parsed.error.issues),
        400,
      );
    }
    const query = parsed.data;
    const fiscalYearStartMonth = query.fiscalYearStartMonth ?? 0;
    // The reader's own zone decides where a quarter begins; an explicit
    // parameter wins so a shared link shows both people the same window.
    const timeZone = query.timezone ?? (await service.timeZoneFor(principal.userId));

    let from: Date;
    let to: Date;
    if (query.from && query.to) {
      from = new Date(query.from);
      to = new Date(query.to);
      if (to <= from) {
        return context.json(apiError('invalid_range', "'to' must be after 'from'."), 400);
      }
    } else if (query.quarter) {
      const label = parseQuarter(query.quarter);
      if (!label) {
        return context.json(
          apiError('invalid_quarter', "Expected a quarter label such as '2026-Q3'."),
          400,
        );
      }
      const bounds = quarterBounds(label.year, label.quarter, fiscalYearStartMonth, timeZone);
      from = bounds.from;
      to = bounds.to;
    } else if (query.from || query.to) {
      return context.json(
        apiError('invalid_range', "Provide both 'from' and 'to', or neither."),
        400,
      );
    } else {
      // No range at all is the common case — a GTM lead opening the page wants
      // the quarter they are standing in.
      const bounds = quarterBoundsFor(new Date(), fiscalYearStartMonth, timeZone);
      from = bounds.from;
      to = bounds.to;
    }

    let kinds: CalendarEventKind[] | undefined;
    try {
      kinds = parseKinds(query.kinds);
    } catch (error) {
      if (error instanceof MutationError) {
        return context.json(apiError(error.code, error.message), error.status);
      }
      throw error;
    }

    return context.json(
      await service.project({
        from,
        to,
        kinds,
        accountId: query.accountId,
        ownerUserId: query.ownerUserId,
        fiscalYearStartMonth,
        timeZone,
      }),
    );
  });

  /** The owned rows, listed on their own so the CRUD is inspectable. */
  routes.get('/api/calendar/entries', async (context: Context<ApiEnv>) => {
    const principal = context.get('principal');
    if (!calendarReadAllowed(principal.scopes)) {
      return context.json(
        apiError('insufficient_scope', "This credential lacks the 'read' scope."),
        403,
      );
    }
    // Postgres rejects a malformed uuid with 22P02, which surfaces as a 500;
    // the sibling read above already answers 400 for the same parameter.
    const parsed = entriesQuerySchema.safeParse(context.req.query());
    if (!parsed.success) {
      return context.json(
        apiError('invalid_query', 'Invalid calendar entries query.', parsed.error.issues),
        400,
      );
    }
    const accountId = parsed.data.accountId;
    const rows = await db
      .select({ entry: calendarEntries, accountName: accounts.name })
      .from(calendarEntries)
      .leftJoin(accounts, eq(accounts.id, calendarEntries.accountId))
      .where(and(accountId ? eq(calendarEntries.accountId, accountId) : undefined))
      .orderBy(calendarEntries.startsAt)
      .limit(500);
    return context.json({ kinds: CALENDAR_ENTRY_KINDS, entries: rows });
  });

  routes.post('/api/calendar/entries', mutation(db, createEntryMutationDefinition()));
  routes.patch('/api/calendar/entries/:id', mutation(db, updateEntryMutationDefinition()));
  routes.delete(
    '/api/calendar/entries/:id',
    bodylessMutation(db, deleteEntryMutationDefinition()),
  );

  return routes;
}
