/**
 * The HTTP application.
 *
 * Deliberately thin. Following the rule stated in the README — *intelligence
 * never lives in the API* — these handlers validate input, check authorization,
 * call a service, and serialise the result. Research, enrichment, scoring and
 * matching heuristics live in the service layer or in the agent, never here.
 */
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { and, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { Database } from '@pig/db';
import {
  accounts,
  activities,
  allocations,
  capacityCommitments,
  contacts,
  contracts,
  dealContacts,
  demandDeals,
  supplyDeals,
  teamMemberships,
  users,
} from '@pig/db';
import {
  ACCENTS,
  DEMAND_OPEN_STAGES,
  DEMAND_STAGES,
  SECURITY_TIERS,
  SUPPLY_OPEN_STAGES,
  SUPPLY_STAGES,
  TEAMS,
  THEME_MODES,
  isValidAccent,
  isValidThemeMode,
  type CalendarEvent,
} from '@pig/core';
import type { Config } from './lib/config';
import {
  AuthError,
  createAuthenticator,
  effectivePermissions,
  type Principal,
} from './lib/auth';
import {
  createConfiguredAuthProvider,
  type AuthProvider,
} from './lib/auth-provider';
import { activityPayloadColumns, toActivityPayload } from './lib/activity-payload';
import { apiError } from './lib/mutation';
import { createMediaRoutes } from './lib/media';
import { CapacityService } from './services/capacity';
import { CalendarService } from './services/calendar';
import { createSignupRoute } from './routes/signup';
import { createRegisterRoute } from './routes/register';
import { createDemandStageMutation } from './routes/deals';
import { createFactsRoute } from './routes/facts';
import { createApiKeyRoutes } from './routes/api-keys';
import { createCapacityWriteRoutes } from './routes/capacity-writes';
import { createRecordRoutes } from './routes/records';
import { createImportRoutes } from './routes/imports';
import { createGoogleSheetsRoutes } from './routes/google-sheets';
import { createContractRoutes } from './routes/contracts';
import { createPiggyChatRoutes, platformPiggyEnabled } from './routes/piggy-chat';
import { createPiggyConversationRoutes } from './routes/piggy-conversations';
import { createPiggyActivityRoutes } from './routes/piggy-activity';
import { PiggyConversationService } from './services/piggy-conversations';
import { createAdminSettingsRoutes } from './routes/admin-settings';
import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
import { createBuzzRoutes } from './routes/buzz';
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
import { createGrowthRoutes } from './routes/growth';
import { createCalendarRoutes } from './routes/calendar';
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
import { createMotionRoutes } from './routes/motion';
import { createReadGuardRoutes } from './routes/read-guards';
import { createActivityRoutes } from './routes/activities';
import { NotificationOutbox } from './services/notification-outbox';

type Env = { Variables: { principal: Principal } };

export function createApp(
  config: Config,
  db: Database,
  authProvider: AuthProvider | null = createConfiguredAuthProvider(config),
  runtime: { onPlatformSettingsChanged?: () => Promise<void> } = {},
) {
  const app = new Hono<Env>();
  const auth = createAuthenticator(config, db, authProvider);
  const capacity = new CapacityService(db);
  // The dashboard's compliance tile reads the same projection the Calendar's
  // lanes do, rather than a second copy of the expiry queries.
  const calendar = new CalendarService(db);
  const notifications = new NotificationOutbox(db);

  if (!config.isProduction) app.use('*', logger());

  app.use(
    '/api/*',
    cors({
      // In production the front end is served from the same origin, so no
      // cross-origin allowance is needed. In development Vite runs separately.
      origin: config.isProduction ? config.PIG_PUBLIC_URL : ['http://localhost:5173'],
      credentials: true,
    }),
  );

  /*
   * Profile creation. Mounted BEFORE the auth middleware because it is the
   * route that turns an authenticated stranger into a member — requiring
   * membership to reach it would be circular. It verifies the token itself.
   */
  app.route('/', createSignupRoute(config, db, authProvider));

  /*
   * Registration. Also before the auth middleware, and necessarily so: the
   * caller has no account yet, so there is no token to present. The invite
   * code is the only gate, which is why it is validated before anything is
   * created anywhere.
   */
  app.route('/', createRegisterRoute(config, db));

  /** Liveness. Unauthenticated by design so a load balancer can reach it. */
  app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' }));

  /**
   * Public configuration for the front end — what it needs before anyone has
   * logged in. Contains only values that are safe in a browser: the anon key is
   * designed to be public, and no secret is exposed here.
   */
  app.get('/api/config', (c) =>
    c.json({
      supabaseUrl: config.SUPABASE_URL ?? null,
      supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null,
      authDisabled: !config.SUPABASE_URL,
      inviteRequired: Boolean(config.PIG_INVITE_CODE),
      // Whether someone with an invite can create an account outright, or must
      // be provisioned by an administrator first.
      canSelfRegister: Boolean(config.SUPABASE_URL && config.SUPABASE_SERVICE_KEY),
      accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })),
      teams: TEAMS,
    }),
  );

  /*
   * Learn videos PIG serves itself. Mounted here — before the authenticator,
   * and before server.ts's SPA fallback — because a <video> re-requests byte
   * ranges on every seek and carries no bearer token while doing it.
   *
   * The FILES are unauthenticated; the LISTING behind /api/learn is not. See
   * lib/media.ts for that trade and what it costs. Position IS the access
   * decision here: the /api/* allowlist below can never match /media/learn/*,
   * so adding an entry there would be dead code.
   */
  app.route('/', createMediaRoutes());

  // Everything below requires a principal.
  app.use('/api/*', async (c, next) => {
    const path = new URL(c.req.url).pathname;
    // Public by necessity: health for load balancers, config for the front
    // end before sign-in, and the two join routes for people who are not yet
    // members. Each verifies whatever it needs itself.
    if (
      path === '/api/health' ||
      path === '/api/config' ||
      path === '/api/signup' ||
      path === '/api/register'
      || path === SLACK_CAPACITY_COMMAND_PATH
      || path === NOTION_OAUTH_CALLBACK_PATH
      // Learn is reachable with a share code and no account. These two paths
      // are exact-string matches, deliberately: /api/learn and
      // /api/learn/resources/* stay behind the authenticator, and the public
      // reader is structurally incapable of naming a row that is not both
      // platform-track and code-visible.
      || path === LEARN_ACCESS_PATH
      || path === LEARN_PUBLIC_PATH
    ) {
      return next();
    }
    try {
      c.set('principal', await auth.authenticate(c.req.header('authorization')));
    } catch (error) {
      if (error instanceof AuthError) {
        return c.json({ error: error.message, code: error.code }, error.status);
      }
      throw error;
    }
    return next();
  });

  /*
   * Read authorisation, mounted before every handler it guards.
   *
   * Hono runs matched handlers in registration order, so a guard registered
   * after its route never runs and returns 200 while looking correct. That is
   * why this sits here rather than beside the feature routes below, and why
   * read-governance.test.ts pins the ordering in both directions.
   *
   * The policy is one table in read-guards.ts precisely so that "who can see
   * cost?" has a single answer rather than one per route.
   */
  app.route('/', createReadGuardRoutes());

  // ---------------------------------------------------------------- identity

  app.get('/api/me', (c) => {
    const p = c.get('principal');
    return c.json({
      id: p.userId,
      email: p.email,
      name: p.name,
      isPlatformAdmin: p.isPlatformAdmin,
      teams: p.teams,
      permissions: effectivePermissions(p),
      via: p.via,
    });
  });

  const preferencesSchema = z.object({
    themeMode: z.enum(THEME_MODES).optional(),
    accentColor: z.string().refine(isValidAccent, 'Unknown accent').optional(),
    name: z.string().min(1).max(120).optional(),
    handle: z.string().min(2).max(40).regex(/^[a-z0-9_-]+$/i).optional(),
    title: z.string().max(160).optional(),
    timezone: z.string().max(80).optional(),
  });

  /**
   * Appearance and profile preferences.
   *
   * Persisted server-side rather than in localStorage so that a person's chosen
   * theme follows them from laptop to phone — which matters more than it might
   * seem, because a CRM is genuinely used on both.
   */
  app.patch('/api/me/preferences', async (c) => {
    const p = c.get('principal');
    const parsed = preferencesSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return c.json({ error: 'Invalid preferences', issues: parsed.error.issues }, 400);
    }
    const [updated] = await db
      .update(users)
      .set({ ...parsed.data, updatedAt: new Date() })
      .where(eq(users.id, p.userId))
      .returning();
    return c.json(updated);
  });

  app.get('/api/me/profile', async (c) => {
    const p = c.get('principal');
    const [user] = await db.select().from(users).where(eq(users.id, p.userId)).limit(1);
    return c.json(user ?? null);
  });

  app.route('/', createApiKeyRoutes(db));
  app.route('/', createRecordRoutes(db, notifications));
  app.route('/', createImportRoutes(db));
  app.route('/', createNotionImportRoutes(config, db));
  app.route('/', createGoogleSheetsRoutes(db, {
    clientId: config.GOOGLE_CLIENT_ID,
    clientSecret: config.GOOGLE_CLIENT_SECRET,
    redirectUri: config.GOOGLE_REDIRECT_URI,
    encryptionKey: config.PIG_SETTINGS_ENCRYPTION_KEY,
    publicUrl: config.PIG_PUBLIC_URL,
  }));
  app.route('/', createContractRoutes(db));
  app.route('/', createGrowthRoutes(db));
  app.route('/', createCalendarRoutes(db));
  app.route('/', createLearnRoutes(db));
  app.route('/', createMotionRoutes(db));
  app.route(
    '/',
    createPiggyChatRoutes({
      enabled: config.PIGGY_ENABLED,
      internalUrl: config.PIGGY_INTERNAL_URL,
      internalToken: config.PIGGY_INTERNAL_TOKEN,
      // Without this the stored toggle is never consulted and isAvailable()
      // short-circuits to the environment variable, which is the bug the
      // resolver exists to fix. The tests inject their own resolver, so they
      // stay green whether or not this line is here — it is the composition
      // that has to be right.
      resolvePiggyEnabled: platformPiggyEnabled(config, db),
      /*
       * The same store the history routes below serve from. The relay is the
       * only hop that sees a whole turn, so it is the hop that writes one
       * down; without this line `piggy_messages` stays empty and every thread
       * reopens as a title with nothing under it. Required rather than
       * optional so that a composition cannot quietly forget it again.
       */
      conversations: new PiggyConversationService(db),
    }),
  );
  /*
   * Piggy's own history. Mounted after the read guards above — which is the
   * whole of the ordering rule this file keeps repeating — and beside the chat
   * relay because they are one feature: the relay streams a turn, these five
   * routes are what the workspace lists and reopens afterwards. They are
   * mounted unconditionally, unlike the relay: a transcript is still readable
   * and deletable when Piggy itself is switched off, and losing access to your
   * own history because an operator toggled a setting would be a bug.
   */
  app.route('/', createPiggyConversationRoutes(db));
  /*
   * The agent ledger the workspace's activity rail reads. Mounted beside the
   * history routes and after the read guards for the same reason they are:
   * `/api/piggy/activity` carries a READ_RULES row, and a route registered
   * ahead of the guard would answer before the capability is checked.
   */
  app.route('/', createPiggyActivityRoutes(db));
  app.route('/', createSlackRoutes(config, db, capacity));
  if (config.BUZZ_RELAY_URL) app.route('/', createBuzzRoutes(db, config.BUZZ_RELAY_URL));
  app.route('/', createIntegrationSettingsRoutes(config));
  app.route(
    '/',
    createAdminSettingsRoutes(config, db, runtime.onPlatformSettingsChanged),
  );

  app.get('/api/team', async (c) => {
    const rows = await db
      .select({
        id: users.id,
        name: users.name,
        email: users.email,
        handle: users.handle,
        title: users.title,
        avatarUrl: users.avatarUrl,
        team: teamMemberships.team,
        role: teamMemberships.role,
      })
      .from(users)
      .leftJoin(teamMemberships, eq(teamMemberships.userId, users.id))
      .where(isNull(users.deactivatedAt));

    // Collapse the join: one row per person, carrying every team they're on.
    const byId = new Map<string, Record<string, unknown>>();
    for (const row of rows) {
      const existing = byId.get(row.id);
      const membership = row.team ? { team: row.team, role: row.role } : null;
      if (existing) {
        if (membership) (existing.teams as unknown[]).push(membership);
      } else {
        byId.set(row.id, {
          id: row.id,
          name: row.name,
          email: row.email,
          handle: row.handle,
          title: row.title,
          avatarUrl: row.avatarUrl,
          teams: membership ? [membership] : [],
        });
      }
    }
    return c.json([...byId.values()]);
  });

  // ---------------------------------------------------------------- accounts

  app.get('/api/accounts', async (c) => {
    const side = c.req.query('side');
    const q = c.req.query('q');
    const rows = await db
      .select()
      .from(accounts)
      .where(
        and(
          isNull(accounts.archivedAt),
          side && side !== 'all'
            ? or(eq(accounts.side, side as 'supply'), eq(accounts.side, 'both'))
            : undefined,
          q ? ilike(accounts.name, `%${q}%`) : undefined,
        ),
      )
      .orderBy(desc(accounts.lastActivityAt), accounts.name)
      .limit(200);
    return c.json(rows);
  });

  app.get('/api/accounts/:id', async (c) => {
    const id = c.req.param('id');
    const [account] = await db.select().from(accounts).where(eq(accounts.id, id)).limit(1);
    if (!account) return c.json({ error: 'Not found' }, 404);

    const [accountContacts, demand, supply, paperwork, recentActivity, buyingGroup] =
      await Promise.all([
        db.select().from(contacts).where(eq(contacts.accountId, id)),
        db.select().from(demandDeals).where(eq(demandDeals.accountId, id)),
        db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
        db.select().from(contracts).where(eq(contracts.accountId, id)),
        db
          .select(activityPayloadColumns)
          .from(activities)
          .where(eq(activities.accountId, id))
          .orderBy(desc(activities.occurredAt))
          .limit(50),
        /*
         * The buying group, joined through the deals rather than filtered on
         * the account: `deal_contacts` carries no account id, so without the
         * join every role in the workspace would come back. Only the three
         * columns the panel reads are selected — the row's own id and
         * timestamp say nothing a reader needs, and a contact's role on a deal
         * is the one fact this endpoint could not otherwise state.
         */
        db
          .select({
            demandDealId: dealContacts.demandDealId,
            contactId: dealContacts.contactId,
            role: dealContacts.role,
          })
          .from(dealContacts)
          .innerJoin(demandDeals, eq(demandDeals.id, dealContacts.demandDealId))
          .where(eq(demandDeals.accountId, id)),
      ]);

    return c.json({
      account,
      contacts: accountContacts,
      demandDeals: demand,
      supplyDeals: supply,
      contracts: paperwork,
      activities: recentActivity.map(toActivityPayload),
      dealContacts: buyingGroup,
    });
  });

  // ------------------------------------------------------------------- deals

  app.get('/api/deals/demand', async (c) => {
    const rows = await db
      .select({
        deal: demandDeals,
        accountName: accounts.name,
        accountDomain: accounts.domain,
      })
      .from(demandDeals)
      .leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
      .orderBy(desc(demandDeals.updatedAt))
      .limit(300);
    return c.json({ stages: DEMAND_STAGES, deals: rows });
  });

  app.get('/api/deals/supply', async (c) => {
    const rows = await db
      .select({
        deal: supplyDeals,
        accountName: accounts.name,
        accountDomain: accounts.domain,
      })
      .from(supplyDeals)
      .leftJoin(accounts, eq(accounts.id, supplyDeals.accountId))
      .orderBy(desc(supplyDeals.updatedAt))
      .limit(300);
    return c.json({ stages: SUPPLY_STAGES, deals: rows });
  });

  app.patch('/api/deals/demand/:id/stage', createDemandStageMutation(db, notifications));
  app.route('/', createCapacityWriteRoutes(db));
  app.route('/', createFactsRoute(db));

  app.route('/', createActivityRoutes(db));

  // ---------------------------------------------------------------- capacity

  app.get('/api/capacity/availability', async (c) => {
    const gpuType = c.req.query('gpuType') ?? undefined;
    return c.json(await capacity.availability({ gpuType }));
  });

  app.get('/api/capacity/idle', async (c) => {
    const threshold = Number(c.req.query('threshold') ?? '0.25');
    return c.json(
      await capacity.idleCapacity({
        thresholdPct: Number.isFinite(threshold) ? threshold : 0.25,
        withinDays: Number(c.req.query('withinDays') ?? '30') || 30,
      }),
    );
  });

  app.get('/api/capacity/margin', async (c) => c.json(await capacity.marginReport()));

  const matchSchema = z.object({
    gpuType: z.string().optional(),
    gpuTypeAlternatives: z.array(z.string()).optional(),
    gpuCount: z.number().int().positive(),
    totalGpuHours: z.number().positive().optional(),
    requiresHighSpeedInterconnect: z.boolean().optional(),
    minSecurityTier: z.enum(SECURITY_TIERS).optional(),
    startsAt: z.string().datetime().optional(),
    endsAt: z.string().datetime().optional(),
    maxPricePerGpuHourCents: z.number().int().positive().optional(),
  });

  app.post('/api/capacity/match', async (c) => {
    const parsed = matchSchema.safeParse(await c.req.json());
    if (!parsed.success) {
      return c.json({ error: 'Invalid requirement', issues: parsed.error.issues }, 400);
    }
    const { startsAt, endsAt, ...rest } = parsed.data;
    return c.json(
      await capacity.match({
        ...rest,
        startsAt: startsAt ? new Date(startsAt) : undefined,
        endsAt: endsAt ? new Date(endsAt) : undefined,
      }),
    );
  });

  app.get('/api/inventory', async (c) =>
    c.json(
      await capacity.searchInventory({
        gpuType: c.req.query('gpuType') ?? undefined,
        minGpuCount: Number(c.req.query('minGpuCount')) || undefined,
        maxPriceCents: Number(c.req.query('maxPriceCents')) || undefined,
        requiresHighSpeedInterconnect: c.req.query('fastFabric') === 'true',
        limit: Number(c.req.query('limit')) || 50,
      }),
    ),
  );

  app.get('/api/commitments', async (c) => {
    const rows = await db
      .select({ commitment: capacityCommitments, accountName: accounts.name })
      .from(capacityCommitments)
      .leftJoin(accounts, eq(accounts.id, capacityCommitments.accountId))
      .orderBy(desc(capacityCommitments.startsAt));
    return c.json(rows);
  });

  app.get('/api/allocations', async (c) => {
    const rows = await db
      .select()
      .from(allocations)
      .orderBy(desc(allocations.startsAt))
      .limit(300);
    return c.json(rows);
  });

  // --------------------------------------------------------------- dashboard

  /**
   * The landing view. One round trip rather than six, because this renders on
   * a phone on a cellular connection as often as on a desk.
   */
  app.get('/api/dashboard', async (c) => {
    const p = c.get('principal');
    const [margin, idle, openDemand, openSupply, recent, compliance] = await Promise.all([
      capacity.marginReport(),
      // 0.15 rather than 0.2: a block sitting exactly on the threshold would
      // otherwise flip in and out of the alert list on floating-point noise,
      // and 15% idle is worth a seller's attention anyway.
      capacity.idleCapacity({ thresholdPct: 0.15 }),
      db
        .select({
          count: sql<number>`count(*)::int`,
          // Postgres widens `sum(integer)` to bigint, which arrives as text.
          // Coerced once here so the wire carries a number, per the money rule.
          acvCents: sql<string>`coalesce(sum(${demandDeals.acvCents}), 0)`,
        })
        .from(demandDeals)
        .where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES])),
      db
        .select({ count: sql<number>`count(*)::int` })
        .from(supplyDeals)
        /*
         * The ontology decides what "open" means, not a stage list written out
         * again here. Spelled as "not churned and not rejected" this counted
         * the four `live` suppliers as open pipeline, so the tile headed "Open
         * pipeline" said six while Piggy's pipeline tool, the workspace summary
         * and the account detail page — all of which read `SUPPLY_OPEN_STAGES`
         * — said two. `live` is the supply side's won state, the counterpart of
         * `closed_won`; a signed supplier is capacity on the book, not an
         * opportunity still being worked.
         */
        .where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES])),
      db
        .select({ ...activityPayloadColumns, accountName: accounts.name })
        .from(activities)
        .leftJoin(accounts, eq(accounts.id, activities.accountId))
        .orderBy(desc(activities.occurredAt))
        .limit(12),
      complianceOutlook(calendar, new Date()),
    ]);

    return c.json({
      me: { name: p.name, teams: p.teams },
      margin: margin.totals,
      blocks: margin.blocks.length,
      idleAlerts: idle.slice(0, 5),
      openDemandDeals: openDemand[0]?.count ?? 0,
      openDemandAcvCents: Number(openDemand[0]?.acvCents ?? 0),
      openSupplyDeals: openSupply[0]?.count ?? 0,
      compliance,
      // The subject alone reads as an anonymous feed — "Chased the firm quote"
      // says nothing until you know whose. The name comes from the join rather
      // than a second request per row, and the row carries the same attribution
      // the account timeline reads, so the same entry cannot be Piggy's on one
      // surface and anonymous on the other.
      recentActivity: recent.map(({ accountName, ...activity }) => ({
        ...toActivityPayload(activity),
        accountName,
      })),
    });
  });

  app.onError((error, c) => {
    if (error instanceof AuthError) {
      return c.json(apiError(error.code, error.message), error.status);
    }
    console.error('[pig] unhandled error', error);
    // Never leak internals to a client; the detail is in the server log.
    return c.json({ error: 'Internal error' }, 500);
  });

  app.notFound((c) => c.json({ error: 'Not found' }, 404));

  return app;
}

// --------------------------------------------------------------- compliance

/**
 * The window the landing view asks about, and why it is asymmetric.
 *
 * Forward, a quarter: the shortest horizon in which a licence renewal can
 * realistically be started and finished, so anything nearer is already late.
 *
 * Backward, a year — and that half is the reason this exists. The Calendar's
 * compliance lane can only report the quarter being read, and says so on the
 * card: an authorisation that lapsed in an earlier quarter is outside that
 * window, not cleared by it. The Overview is the screen everyone opens, so it
 * is the one that has to keep saying it. The bound is only there to stop the
 * scan growing without limit; a lapse itself never expires.
 */
const COMPLIANCE_HORIZON_DAYS = 90;
const COMPLIANCE_LOOKBACK_DAYS = 365;
const DAY_MS = 86_400_000;

/** How many rows travel. The counts beside them stay exact whatever this is. */
const COMPLIANCE_ITEM_LIMIT = 6;

/**
 * Both columns are free text by design — new authorisation types and new
 * attestation regimes appear faster than an enum is updated — so an unrecognised
 * value is made readable rather than dropped or shown raw.
 */
const AUTHORIZATION_TYPE_LABELS: Readonly<Record<string, string>> = {
  none: 'No authorisation on file',
  licence: 'Export licence',
  listed_entity: 'Listed-entity authorisation',
  dc_veu: 'Validated end user',
  case_by_case: 'Case-by-case licence',
};

const COMPLIANCE_CLAIM_LABELS: Readonly<Record<string, string>> = {
  soc2: 'SOC 2',
  iso27001: 'ISO 27001',
  iso42001: 'ISO 42001',
  pentest: 'Penetration test',
  cyber_insurance: 'Cyber insurance',
};

export interface ComplianceItem {
  id: string;
  kind: 'authorization' | 'artifact';
  label: string;
  reference: string | null;
  accountId: string | null;
  accountName: string | null;
  expiresAt: string;
  /** Decided by the projection's clock, so one request cannot disagree with itself. */
  lapsed: boolean;
  /** Rules in flux for this counterparty: the date on file is not enough. */
  volatile: boolean;
  href: string;
}

export interface ComplianceOutlook {
  horizonDays: number;
  lapsedCount: number;
  expiringCount: number;
  items: ComplianceItem[];
}

async function complianceOutlook(
  calendar: CalendarService,
  now: Date,
): Promise<ComplianceOutlook> {
  const projection = await calendar.project({
    from: new Date(now.getTime() - COMPLIANCE_LOOKBACK_DAYS * DAY_MS),
    to: new Date(now.getTime() + COMPLIANCE_HORIZON_DAYS * DAY_MS),
    kinds: ['authorization_expiry', 'artifact_expiry'],
  });

  const items = projection.events.map(toComplianceItem).sort(byUrgency);
  return {
    horizonDays: COMPLIANCE_HORIZON_DAYS,
    lapsedCount: items.filter((item) => item.lapsed).length,
    expiringCount: items.filter((item) => !item.lapsed).length,
    items: items.slice(0, COMPLIANCE_ITEM_LIMIT),
  };
}

function toComplianceItem(event: CalendarEvent): ComplianceItem {
  const isAuthorization = event.kind === 'authorization_expiry';
  const type = metaString(event.meta, isAuthorization ? 'authorizationType' : 'claim');
  const labels = isAuthorization ? AUTHORIZATION_TYPE_LABELS : COMPLIANCE_CLAIM_LABELS;
  return {
    id: event.id,
    kind: isAuthorization ? 'authorization' : 'artifact',
    label: type
      ? (labels[type] ?? humanised(type))
      : isAuthorization
        ? 'Export authorisation'
        : 'Compliance artefact',
    reference: metaString(event.meta, 'reference'),
    accountId: event.accountId,
    accountName: event.accountName,
    expiresAt: event.startsAt,
    lapsed: event.state === 'overdue',
    volatile: event.meta.volatile === true,
    href: event.href,
  };
}

/**
 * Lapsed before expiring, and inside each group the one to act on first: the
 * most recent lapse — the one still recoverable — then the nearest deadline.
 */
function byUrgency(a: ComplianceItem, b: ComplianceItem): number {
  if (a.lapsed !== b.lapsed) return a.lapsed ? -1 : 1;
  const left = Date.parse(a.expiresAt);
  const right = Date.parse(b.expiresAt);
  return a.lapsed ? right - left : left - right;
}

/** `meta` is deliberately untyped on a projected event; nothing widens to `any` here. */
function metaString(meta: Record<string, unknown>, key: string): string | null {
  const value = meta[key];
  return typeof value === 'string' && value.length > 0 ? value : null;
}

function humanised(value: string): string {
  const spaced = value.replace(/_/g, ' ');
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
