/**
 * Logging an activity.
 *
 * This was the one write in PIG that never went through `executeMutation`: it
 * lived inline in `app.ts`, checked no capability at all, and would insert an
 * activity against any `accountId` a caller cared to name — then move that
 * account's `lastActivityAt`, which is what the account list sorts on. Any
 * member, and any write-scoped API key, could therefore reorder somebody
 * else's book and plant a fabricated call in the audit trail of an account
 * they have no relationship with.
 *
 * Two things are checked, in two places, deliberately:
 *
 *   1. Up front, before the body is read: does this principal hold
 *      `activity:write` on *any* team? A caller with none must not get to probe
 *      validation rules for a write they can never perform.
 *   2. Inside the transaction, once the referenced account has been read: do
 *      they hold it on a team that account is actually on? The side is a
 *      property of the row, so it cannot be known before the row is fetched.
 *
 * That is the same shape as `ensureSidePermission` in contracts.ts, and for the
 * same reason.
 */
import { ACTIVITY_TYPES, type AccountSide, type Team } from '@pig/core';
import type { Activity, Database } from '@pig/db';
import { accounts, activities } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { AuthError, requireAnyTeamCapability, requireCapability, type Principal } from '../lib/auth';
import type { ApiEnv, MutationDefinition } from '../lib/mutation';
import { MutationError, mutation } from '../lib/mutation';

const activitySchema = z
  .object({
    accountId: z.string().uuid().optional(),
    contactId: z.string().uuid().optional(),
    demandDealId: z.string().uuid().optional(),
    supplyDealId: z.string().uuid().optional(),
    type: z.enum(ACTIVITY_TYPES),
    subject: z.string().min(1).max(200),
    body: z.string().max(8000).optional(),
    occurredAt: z.string().datetime().optional(),
    externalId: z.string().max(200).optional(),
  })
  .strict();

export interface LoggedActivity {
  activity: Activity | null;
  /** True when an `externalId` collision meant the event was already synced. */
  deduplicated: boolean;
}

/**
 * Research consumes capacity but keeps no commercial book, so an activity is
 * always a supply-side or demand-side event. A `both` account admits either.
 */
function requireSidePermission(principal: Principal, side: AccountSide): void {
  const sides: Team[] = side === 'both' ? ['supply', 'demand'] : [side];
  for (const team of sides) {
    try {
      requireCapability(principal, 'activity:write', team);
      return;
    } catch (error) {
      if (!(error instanceof AuthError)) throw error;
    }
  }
  throw new AuthError(
    `This principal cannot log activity against a ${side}-side account.`,
    403,
    'insufficient_permission',
  );
}

export function createActivityMutationDefinition(): MutationDefinition<
  typeof activitySchema,
  LoggedActivity
> {
  return {
    schema: activitySchema,
    permission: { authorize: (principal) => requireAnyTeamCapability(principal, 'activity:write') },
    invalidMessage: 'Invalid activity.',
    async mutate({ input, principal, tx, now }) {
      const { occurredAt, accountId, ...rest } = input;
      // A backdated entry is the normal case for sync, so the caller's
      // timestamp wins over `now` — unlike the audit rows this convention
      // usually writes, where `now` is the point.
      const when = occurredAt ? new Date(occurredAt) : now;

      if (accountId) {
        const [account] = await tx
          .select({ side: accounts.side })
          .from(accounts)
          .where(eq(accounts.id, accountId))
          .limit(1);
        if (!account) throw MutationError.notFound('Account');
        requireSidePermission(principal, account.side as AccountSide);
      }

      const [created] = await tx
        .insert(activities)
        .values({
          ...rest,
          accountId,
          occurredAt: when,
          actorUserId: principal.userId,
          // An agent acting for someone is recorded as such, so the log
          // distinguishes what a person did from what was done on their behalf.
          actorAgent: principal.via === 'api_key' ? 'agent' : null,
          source: principal.via === 'api_key' ? 'agent' : 'manual',
        })
        // An `externalId` collision means this event was already synced from
        // Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
        .onConflictDoNothing()
        .returning();

      // Only on a real insert. Bumping it on a deduplicated replay would let a
      // repeated sync keep an account at the top of the list forever.
      if (created && accountId) {
        await tx.update(accounts).set({ lastActivityAt: when }).where(eq(accounts.id, accountId));
      }

      return {
        data: { activity: created ?? null, deduplicated: !created },
        // The inserted row is the audit event. See `MutationAudit`.
        activity: 'self',
      };
    },
  };
}

export function createActivityRoutes(db: Database): Hono<ApiEnv> {
  const routes = new Hono<ApiEnv>();
  routes.post('/api/activities', mutation(db, createActivityMutationDefinition()));
  return routes;
}
