/**
 * The GTM quarterly calendar.
 *
 * A month grid was tried on paper and abandoned. Most of what PIG dates is a
 * SPAN — capacity windows, allocation windows, contract terms — and a 7×N grid
 * either repeats a span in every cell it touches or hides it behind the day it
 * starts. So the primary view is a three-month timeline with one LANE PER KIND:
 * a span is a bar of its real length, a point is a marker, and the eye reads
 * "what overlaps what" in one pass, which is the question a GTM lead is
 * actually asking.
 *
 * The lane order is not alphabetical and not the enum's order. Export
 * authorisations and compliance artefacts sit at the top because an expiry
 * nobody noticed converts lawful business into unlawful business, and until
 * this page existed neither was visible anywhere in the product.
 *
 * The timeline is exactly the component the horizontal-scroll rule was written
 * for: it is wider than a phone by construction. It therefore scrolls inside
 * its own `.scroll-x` pane, every flex child on the way down carries `min-w-0`,
 * and the agenda beneath it — not the timeline — is the tappable, screen-reader
 * complete record of the same events.
 */
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
  AlertTriangle,
  CalendarDays,
  Check,
  ChevronLeft,
  ChevronRight,
  Plus,
  ShieldAlert,
  SlidersHorizontal,
  Trash2,
  User,
} from 'lucide-react';
import {
  CALENDAR_ENTRY_KINDS,
  CALENDAR_EVENT_KINDS,
  isValidTimeZone,
  parseQuarter,
  quarterOf,
  type CalendarEntryKind,
  type CalendarEvent,
  type CalendarEventKind,
  type CalendarEventState,
  type Quarter,
  type QuarterNumber,
  type StatusColor,
} from '@pig/core';
import { api, get, money, patch, post } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
import {
  Badge,
  Button,
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  EmptyState,
  Input,
  Label,
  Skeleton,
  Stat,
  cn,
} from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';

// ------------------------------------------------------------------- shapes

interface CalendarTotals {
  weightedPipelineCents: number;
  closingCount: number;
  renewalCount: number;
  obligationCount: number;
  expiringAuthorizationCount: number;
}

interface CalendarProjection {
  from: string;
  to: string;
  quarter: Quarter;
  events: CalendarEvent[];
  truncated: boolean;
  totals: CalendarTotals;
}

interface CalendarEntryRecord {
  id: string;
  title: string;
  completedAt: string | null;
}

// ------------------------------------------------------------------ mapping

/**
 * Kind to status colour.
 *
 * Written as a total `Record` rather than a lookup with a fallback so that
 * adding a kind to `CALENDAR_EVENT_KINDS` fails the typecheck here instead of
 * quietly rendering the new kind in the neutral grey nobody looks at.
 *
 * The values are `StatusColor` — the same five names `STATUS_COLORS` defines in
 * @pig/core — and each resolves through the `positive`/`warning`/`danger`/
 * `info` Tailwind tokens, which are those exact hues with a dark-mode variant.
 * Reading the raw HSL out of `STATUS_COLORS` here would pick one theme's value
 * and freeze it.
 */
const KIND_TONE: Record<CalendarEventKind, StatusColor> = {
  authorization_expiry: 'danger',
  artifact_expiry: 'danger',
  expected_close: 'positive',
  renewal_notice: 'warning',
  contract_expiry: 'warning',
  obligation_due: 'warning',
  calendar_entry: 'neutral',
  contract_effective: 'info',
  contract_executed: 'info',
  capacity_window: 'info',
  allocation_window: 'positive',
  supply_available_from: 'info',
  hold_expiry: 'warning',
};

/**
 * Lane order, top to bottom. Compliance first — see the note at the top of the
 * file. Also a total `Record`, for the same reason as `KIND_TONE`.
 */
const KIND_LANE_RANK: Record<CalendarEventKind, number> = {
  authorization_expiry: 0,
  artifact_expiry: 1,
  expected_close: 2,
  renewal_notice: 3,
  contract_expiry: 4,
  obligation_due: 5,
  calendar_entry: 6,
  contract_effective: 7,
  contract_executed: 8,
  capacity_window: 9,
  allocation_window: 10,
  supply_available_from: 11,
  hold_expiry: 12,
};

const KIND_LABELS: Record<CalendarEventKind, string> = {
  authorization_expiry: 'Export authorisation',
  artifact_expiry: 'Compliance artefact',
  expected_close: 'Expected close',
  renewal_notice: 'Renewal notice',
  contract_expiry: 'Contract expiry',
  obligation_due: 'Obligation due',
  calendar_entry: 'Entry',
  contract_effective: 'Contract effective',
  contract_executed: 'Contract executed',
  capacity_window: 'Capacity window',
  allocation_window: 'Allocation window',
  supply_available_from: 'Supply available',
  hold_expiry: 'Hold expiry',
};

const ENTRY_KIND_LABELS: Record<CalendarEntryKind, string> = {
  meeting: 'Meeting',
  qbr: 'QBR',
  reminder: 'Reminder',
  campaign: 'Campaign',
  internal: 'Internal',
};

/** The two kinds that are a licence to operate rather than a commercial date. */
const COMPLIANCE_KINDS: readonly CalendarEventKind[] = ['authorization_expiry', 'artifact_expiry'];

const SORTED_KINDS = [...CALENDAR_EVENT_KINDS].sort(
  (a, b) => KIND_LANE_RANK[a] - KIND_LANE_RANK[b],
);

const TONE_BAR: Record<StatusColor, string> = {
  positive: 'border-positive/50 bg-positive/15 text-positive',
  warning: 'border-warning/50 bg-warning/15 text-warning',
  danger: 'border-danger/60 bg-danger/20 text-danger',
  info: 'border-info/50 bg-info/15 text-info',
  neutral: 'border-fg/25 bg-fg/10 text-fg',
};

const TONE_DOT: Record<StatusColor, string> = {
  positive: 'bg-positive',
  warning: 'bg-warning',
  danger: 'bg-danger',
  info: 'bg-info',
  neutral: 'bg-muted',
};

/**
 * State beats kind. A missed obligation and a missed export authorisation are
 * the same emergency whatever lane they sit in, so `overdue` repaints the bar
 * in danger and adds a ring; `done` desaturates it so a finished window stops
 * competing for attention with a live one.
 */
function toneFor(event: CalendarEvent): StatusColor {
  if (event.state === 'overdue') return 'danger';
  return KIND_TONE[event.kind];
}

// -------------------------------------------------------------- date helpers

const DAY_MS = 86_400_000;

const MONTH_LABEL = new Intl.DateTimeFormat(undefined, { month: 'long' });
const DAY_LABEL = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
const DAY_YEAR_LABEL = new Intl.DateTimeFormat(undefined, {
  month: 'short',
  day: 'numeric',
  year: 'numeric',
});

/** Year is printed only when it is not the year the reader is looking at. */
function dayLabel(value: Date, contextYear: number): string {
  return (value.getFullYear() === contextYear ? DAY_LABEL : DAY_YEAR_LABEL).format(value);
}

function whenLabel(event: CalendarEvent, contextYear: number): string {
  const start = new Date(event.startsAt);
  if (!event.endsAt) return dayLabel(start, contextYear);
  return `${dayLabel(start, contextYear)} – ${dayLabel(new Date(event.endsAt), contextYear)}`;
}

function daysUntil(value: string, now: number): number {
  return Math.round((new Date(value).getTime() - now) / DAY_MS);
}

function countdownLabel(days: number): string {
  if (days < 0) return `${Math.abs(days)}d overdue`;
  if (days === 0) return 'today';
  return `in ${days}d`;
}

/** `YYYY-MM-DDTHH:mm` in local wall-clock time, which is what the input wants. */
function toLocalInputValue(value: Date): string {
  const pad = (part: number) => String(part).padStart(2, '0');
  return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
}

// ------------------------------------------------------------------- quarter

interface QuarterCursor {
  year: number;
  quarter: QuarterNumber;
}

function shiftQuarter(cursor: QuarterCursor, delta: number): QuarterCursor {
  const index = cursor.year * 4 + (cursor.quarter - 1) + delta;
  return {
    year: Math.floor(index / 4),
    quarter: ((index % 4) + 1) as QuarterNumber,
  };
}

function quarterLabel(cursor: QuarterCursor): Quarter {
  return `${cursor.year}-Q${cursor.quarter}`;
}

/**
 * The reader's own zone decides where a quarter begins, and the API validates
 * the parameter rather than falling back to UTC — so an exotic runtime whose
 * zone ICU does not recognise must send nothing rather than a 400 that blanks
 * the page.
 */
function browserTimeZone(): string | null {
  const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  return zone && isValidTimeZone(zone) ? zone : null;
}

function currentQuarter(timeZone: string | null): QuarterCursor {
  const parsed = parseQuarter(quarterOf(new Date(), 0, timeZone ?? 'UTC'));
  // `quarterOf` cannot emit an unparseable label; the fallback exists so this
  // never becomes the reason a page fails to render.
  return parsed ? { year: parsed.year, quarter: parsed.quarter } : { year: 1970, quarter: 1 };
}

// ---------------------------------------------------------------------- page

export function Calendar() {
  usePageTitle('Calendar');
  const identity = useIdentity();
  const navigate = useNavigate();
  const timeZone = useMemo(browserTimeZone, []);
  const [cursor, setCursor] = useState<QuarterCursor>(() => currentQuarter(timeZone));
  const [kinds, setKinds] = useState<readonly CalendarEventKind[]>([]);
  const [mine, setMine] = useState(false);
  const [filtersOpen, setFiltersOpen] = useState(false);
  const [creating, setCreating] = useState(false);
  const [openEntry, setOpenEntry] = useState<CalendarEvent | null>(null);

  const label = quarterLabel(cursor);
  const kindsParam = kinds.length ? [...kinds].sort().join(',') : null;
  const ownerParam = mine ? identity.id : null;

  usePiggyContext({ type: 'page', route: '/calendar', label: `Calendar — ${label}` });

  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['calendar', label, kindsParam, ownerParam, timeZone],
    queryFn: () =>
      get<CalendarProjection>(
        `/api/calendar?${new URLSearchParams({
          quarter: label,
          ...(kindsParam ? { kinds: kindsParam } : {}),
          ...(ownerParam ? { ownerUserId: ownerParam } : {}),
          ...(timeZone ? { timezone: timeZone } : {}),
        }).toString()}`,
      ),
  });

  const canWrite = canAny(identity, 'deal:write');

  const header = (
    <QuarterHeader
      cursor={cursor}
      onCursor={setCursor}
      onToday={() => setCursor(currentQuarter(timeZone))}
      canWrite={canWrite}
      onCreate={() => setCreating(true)}
    />
  );

  if (isLoading) {
    return (
      <div className="space-y-6">
        <Skeleton className="h-16" />
        <div className="grid grid-cols-2 gap-2 xl:grid-cols-5">
          {Array.from({ length: 5 }).map((_, index) => (
            <Skeleton key={index} className="h-28" />
          ))}
        </div>
        <Skeleton className="h-96" />
      </div>
    );
  }

  if (error || !data) {
    // The navigator stays: a quarter that fails to load is a quarter you must
    // still be able to step away from.
    return (
      <div className="space-y-6">
        {header}
        <Card>
          <CardContent className="flex flex-col items-center gap-4 pt-6">
            <EmptyState
              title="Could not load the calendar"
              description={
                error instanceof Error ? error.message : 'The quarterly projection is unavailable.'
              }
            />
            <Button variant="outline" onClick={() => void refetch()}>
              Try again
            </Button>
          </CardContent>
        </Card>
      </div>
    );
  }

  const from = new Date(data.from);
  const to = new Date(data.to);
  const contextYear = from.getFullYear();
  const compliance = data.events.filter((event) => COMPLIANCE_KINDS.includes(event.kind));
  const overdue = data.events.filter((event) => event.state === 'overdue');

  const onEvent = (event: CalendarEvent) => {
    // A calendar entry's href points back at this page, so routing to it would
    // be a no-op. It is also the only kind this page can edit, so it opens here.
    if (event.recordType === 'calendar_entry') setOpenEntry(event);
    else void navigate(event.href);
  };

  return (
    <div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
      {header}

      <section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-5">
        {/*
          Weighted pipeline leads because nothing else in PIG computes it, and
          it is the first number a GTM lead reads. The totals are counted in SQL
          over the whole quarter, so they do not move when the kind chips do.
        */}
        <Stat
          label="Weighted pipeline"
          value={money(data.totals.weightedPipelineCents)}
          hint="ACV × probability, closing this quarter"
        />
        <Stat label="Deals closing" value={data.totals.closingCount} hint="Excludes closed lost" />
        {/*
          One coloured figure in the row, and it is the one that is not merely
          work: a lapsed export authorisation is unlawful business. Renewals and
          obligations are counts of things to do, and a row where four numbers
          out of five are amber is a row nobody reads.
        */}
        <Stat label="Renewals" value={data.totals.renewalCount} hint="Notice date falls in quarter" />
        <Stat label="Obligations due" value={data.totals.obligationCount} hint="Outstanding only" />
        {/*
          Under a "mine" filter the API reports zero expiring authorisations —
          the table has no owner column, so an owner filter can only ever
          exclude it. Printing that zero would read as "nothing expires", which
          is the exact false negative this page exists to remove.
        */}
        <Stat
          label="Authorisations expiring"
          value={mine ? '—' : data.totals.expiringAuthorizationCount}
          hint={mine ? 'Not owner-filterable' : 'Licence to operate'}
          tone={!mine && data.totals.expiringAuthorizationCount ? 'danger' : 'default'}
        />
      </section>

      <Filters
        kinds={kinds}
        onKinds={setKinds}
        mine={mine}
        onMine={setMine}
        open={filtersOpen}
        onOpen={setFiltersOpen}
      />

      {data.truncated ? (
        <div
          className="rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-sm text-warning"
          role="status"
        >
          At least one source hit its per-quarter ceiling, so the timeline below is incomplete. The
          headline totals are counted separately and remain exact.
        </div>
      ) : null}

      <ComplianceCard
        events={compliance}
        mine={mine}
        /*
          A kind filter that excludes these two turns the card's reassurance
          into a lie, in exactly the way the "mine" filter does. Both have to be
          reported, not silently absorbed.
        */
        filteredOut={kinds.length > 0 && !kinds.some((kind) => COMPLIANCE_KINDS.includes(kind))}
        contextYear={contextYear}
        onEvent={onEvent}
      />

      {data.events.length === 0 ? (
        /*
          A bare EmptyState, per the page convention — but the quarter navigator
          above it stays mounted. Replacing the whole page would strand the
          reader in an empty quarter with no control to leave it by.
        */
        <EmptyState
          size="page"
          icon={<CalendarDays />}
          title="Nothing dated in this quarter"
          description={
            kinds.length || mine
              ? 'No event matches the current filters. Clear them, or step to another quarter.'
              : 'Contracts, deals, commitments and allocations appear here as soon as they carry a date.'
          }
        />
      ) : (
        <>
          <Timeline
            events={data.events}
            from={from}
            to={to}
            contextYear={contextYear}
            onEvent={onEvent}
          />
          <Agenda
            events={data.events}
            overdue={overdue}
            contextYear={contextYear}
            onEvent={onEvent}
          />
        </>
      )}

      {creating ? (
        <EntryDialog
          defaultStart={defaultStartFor(from, to)}
          onClose={() => setCreating(false)}
        />
      ) : null}
      {openEntry ? <EntryDetail event={openEntry} onClose={() => setOpenEntry(null)} /> : null}
    </div>
  );
}

/** Opening the form on a quarter you are not standing in should default into it. */
function defaultStartFor(from: Date, to: Date): Date {
  const now = new Date();
  if (now >= from && now < to) {
    const next = new Date(now);
    next.setMinutes(0, 0, 0);
    next.setHours(next.getHours() + 1);
    return next;
  }
  const start = new Date(from);
  start.setHours(9, 0, 0, 0);
  return start;
}

// -------------------------------------------------------------------- header

function QuarterHeader({
  cursor,
  onCursor,
  onToday,
  canWrite,
  onCreate,
}: {
  cursor: QuarterCursor;
  onCursor(next: QuarterCursor): void;
  onToday(): void;
  canWrite: boolean;
  onCreate(): void;
}) {
  return (
    <PageHeader
      title={<>Calendar <span className="nums text-muted">{quarterLabel(cursor)}</span></>}
      description="What closes, what renews, what expires, and when capacity lands — projected from the records that already carry the dates."
      actions={<>
        <div className="flex items-center rounded-lg border border-border">
          <Button
            variant="ghost"
            size="icon"
            className="rounded-r-none"
            aria-label="Previous quarter"
            onClick={() => onCursor(shiftQuarter(cursor, -1))}
          >
            <ChevronLeft className="size-4" aria-hidden />
          </Button>
          <Button variant="ghost" size="sm" className="rounded-none px-3" onClick={onToday}>
            Today
          </Button>
          <Button
            variant="ghost"
            size="icon"
            className="rounded-l-none"
            aria-label="Next quarter"
            onClick={() => onCursor(shiftQuarter(cursor, 1))}
          >
            <ChevronRight className="size-4" aria-hidden />
          </Button>
        </div>
        {canWrite ? (
          <Button variant="primary" size="sm" onClick={onCreate}>
            <Plus className="size-4" aria-hidden /> New entry
          </Button>
        ) : null}
      </>}
    />
  );
}

// ------------------------------------------------------------------- filters

function Filters({
  kinds,
  onKinds,
  mine,
  onMine,
  open,
  onOpen,
}: {
  kinds: readonly CalendarEventKind[];
  onKinds(next: readonly CalendarEventKind[]): void;
  mine: boolean;
  onMine(next: boolean): void;
  open: boolean;
  onOpen(next: boolean): void;
}) {
  const toggle = (kind: CalendarEventKind) =>
    onKinds(kinds.includes(kind) ? kinds.filter((value) => value !== kind) : [...kinds, kind]);

  return (
    <div className="flex flex-col gap-2">
      <div className="flex flex-wrap items-center gap-2">
        <Button
          variant={kinds.length ? 'secondary' : 'outline'}
          size="sm"
          aria-expanded={open}
          onClick={() => onOpen(!open)}
        >
          <SlidersHorizontal className="size-4" aria-hidden />
          {kinds.length ? `${kinds.length} of ${CALENDAR_EVENT_KINDS.length} kinds` : 'All kinds'}
        </Button>
        <Button
          variant={mine ? 'primary' : 'outline'}
          size="sm"
          aria-pressed={mine}
          onClick={() => onMine(!mine)}
        >
          <User className="size-4" aria-hidden /> Mine
        </Button>
        {kinds.length ? (
          <Button variant="ghost" size="sm" onClick={() => onKinds([])}>
            Clear kinds
          </Button>
        ) : null}
      </div>
      {open ? (
        <div className="flex flex-wrap gap-1.5 rounded-xl border border-border bg-surface p-3">
          {SORTED_KINDS.map((kind) => {
            const active = kinds.includes(kind);
            return (
              <button
                key={kind}
                type="button"
                aria-pressed={active}
                onClick={() => toggle(kind)}
                className={cn(
                  'tap inline-flex min-h-11 items-center gap-2 rounded-lg border px-3 text-xs font-medium transition-colors duration-1 ease-enter',
                  active
                    ? TONE_BAR[KIND_TONE[kind]]
                    : 'border-border text-muted hover:bg-surface-2',
                )}
              >
                <span className={cn('size-2 rounded-full', TONE_DOT[KIND_TONE[kind]])} aria-hidden />
                {KIND_LABELS[kind]}
              </button>
            );
          })}
        </div>
      ) : null}
      {mine ? (
        <p className="text-xs text-muted">
          Only kinds with an owner column can answer &ldquo;mine&rdquo;. Capacity windows,
          allocations, holds, export authorisations and compliance artefacts are hidden while this
          is on — they belong to the company, not to a person.
        </p>
      ) : null}
    </div>
  );
}

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

/**
 * Export authorisations and compliance artefacts, called out above everything
 * else and rendered even when the list is empty.
 *
 * The empty message is worded precisely. This feed only carries expiries that
 * fall INSIDE the viewed quarter, so "nothing expires this quarter" is true and
 * "you are compliant" would not be — something that lapsed in March is not here.
 */
function ComplianceCard({
  events,
  mine,
  filteredOut,
  contextYear,
  onEvent,
}: {
  events: CalendarEvent[];
  mine: boolean;
  filteredOut: boolean;
  contextYear: number;
  onEvent(event: CalendarEvent): void;
}) {
  const now = Date.now();
  const urgent = events.some((event) => event.state === 'overdue' || event.state === 'due');

  return (
    <Card className={cn(urgent && 'border-danger/50')}>
      <CardHeader className="flex-row items-center gap-3 space-y-0">
        <ShieldAlert
          className={cn('size-5 shrink-0', urgent ? 'text-danger' : 'text-muted')}
          aria-hidden
        />
        <div className="min-w-0">
          <CardTitle className="text-base">Licence to operate</CardTitle>
          <p className="mt-1 text-xs text-muted">
            An expired export authorisation converts lawful business into unlawful business.
          </p>
        </div>
      </CardHeader>
      <CardContent>
        {mine ? (
          <p className="text-sm text-muted">
            Hidden by the <span className="font-medium text-fg">Mine</span> filter — these records
            have no owner. Turn it off to check them.
          </p>
        ) : filteredOut ? (
          <p className="text-sm text-muted">
            Hidden by the <span className="font-medium text-fg">kind</span> filter. This card is not
            saying the quarter is clear.
          </p>
        ) : events.length === 0 ? (
          <p className="text-sm text-muted">
            <span className="text-fg">Nothing expires in this quarter.</span> An authorisation that
            already lapsed in an earlier one is outside this window, not cleared by it.
          </p>
        ) : (
          <ul className="flex flex-col gap-2">
            {events.map((event) => {
              const days = daysUntil(event.startsAt, now);
              return (
                <li key={event.id}>
                  <button
                    type="button"
                    onClick={() => onEvent(event)}
                    className={cn(
                      'tap flex w-full min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors duration-1 ease-enter',
                      event.state === 'overdue'
                        ? 'border-danger/60 bg-danger/10'
                        : 'border-border hover:bg-surface-2',
                    )}
                  >
                    <AlertTriangle
                      className={cn(
                        'size-4 shrink-0',
                        event.state === 'overdue' ? 'text-danger' : 'text-warning',
                      )}
                      aria-hidden
                    />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-sm font-medium">{event.title}</span>
                      <span className="block truncate text-xs text-muted">
                        {KIND_LABELS[event.kind]}
                        {event.accountName ? ` · ${event.accountName}` : ''}
                        {event.meta.volatile === true ? ' · rules in flux, re-verify' : ''}
                      </span>
                    </span>
                    <span
                      className={cn(
                        'nums shrink-0 text-xs font-semibold',
                        days < 0 ? 'text-danger' : 'text-warning',
                      )}
                    >
                      {dayLabel(new Date(event.startsAt), contextYear)} · {countdownLabel(days)}
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}

// ------------------------------------------------------------------ timeline

interface PlacedEvent {
  event: CalendarEvent;
  leftPct: number;
  widthPct: number;
  clippedStart: boolean;
  clippedEnd: boolean;
  row: number;
}

interface Lane {
  kind: CalendarEventKind;
  placed: PlacedEvent[];
  rows: number;
}

/** Below this a bar has no room for a legible label; the agenda carries it. */
const LABEL_MIN_PCT = 11;
/** A point marker still occupies space, so packing must reserve some for it. */
const MARKER_FOOTPRINT_PCT = 4;
const ROW_HEIGHT_REM = 1.75;

function layOutLanes(events: CalendarEvent[], from: Date, to: Date): Lane[] {
  const start = from.getTime();
  const span = to.getTime() - start;
  const pct = (value: number) => ((value - start) / span) * 100;

  const byKind = new Map<CalendarEventKind, CalendarEvent[]>();
  for (const event of events) {
    const bucket = byKind.get(event.kind);
    if (bucket) bucket.push(event);
    else byKind.set(event.kind, [event]);
  }

  return [...byKind.entries()]
    .sort(([a], [b]) => KIND_LANE_RANK[a] - KIND_LANE_RANK[b])
    .map(([kind, kindEvents]) => {
      const placed = kindEvents
        .map((event) => {
          const rawStart = new Date(event.startsAt).getTime();
          const rawEnd = event.endsAt ? new Date(event.endsAt).getTime() : rawStart;
          const clampedStart = Math.max(rawStart, start);
          const clampedEnd = Math.min(rawEnd, to.getTime());
          const leftPct = Math.max(0, Math.min(100, pct(clampedStart)));
          return {
            event,
            leftPct,
            widthPct: event.isSpan ? Math.max(0.8, pct(clampedEnd) - leftPct) : 0,
            clippedStart: rawStart < start,
            clippedEnd: event.endsAt ? rawEnd > to.getTime() : false,
            row: 0,
          };
        })
        .sort((a, b) => a.leftPct - b.leftPct);

      // Greedy packing into rows. Without it a lane like `capacity_window`
      // stacks nine overlapping bars on one line and reads as a single block.
      const rowEnds: number[] = [];
      for (const item of placed) {
        const footprint =
          item.leftPct + Math.max(item.widthPct, MARKER_FOOTPRINT_PCT) + MARKER_FOOTPRINT_PCT / 2;
        const row = rowEnds.findIndex((end) => end <= item.leftPct);
        if (row === -1) {
          item.row = rowEnds.length;
          rowEnds.push(footprint);
        } else {
          item.row = row;
          rowEnds[row] = footprint;
        }
      }

      return { kind, placed, rows: Math.max(1, rowEnds.length) };
    });
}

function monthSegments(from: Date, to: Date) {
  const start = from.getTime();
  const span = to.getTime() - start;
  const pct = (value: number) => ((value - start) / span) * 100;
  const segments: { key: string; label: string; leftPct: number; widthPct: number }[] = [];
  let cursor = new Date(from.getFullYear(), from.getMonth(), 1);
  while (cursor.getTime() < to.getTime()) {
    const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
    const left = pct(Math.max(cursor.getTime(), start));
    segments.push({
      key: `${cursor.getFullYear()}-${cursor.getMonth()}`,
      label: MONTH_LABEL.format(cursor),
      leftPct: left,
      widthPct: pct(Math.min(next.getTime(), to.getTime())) - left,
    });
    cursor = next;
  }
  return segments;
}

function Timeline({
  events,
  from,
  to,
  contextYear,
  onEvent,
}: {
  events: CalendarEvent[];
  from: Date;
  to: Date;
  contextYear: number;
  onEvent(event: CalendarEvent): void;
}) {
  const lanes = useMemo(() => layOutLanes(events, from, to), [events, from, to]);
  const months = useMemo(() => monthSegments(from, to), [from, to]);
  const now = Date.now();
  const todayPct =
    now >= from.getTime() && now < to.getTime()
      ? ((now - from.getTime()) / (to.getTime() - from.getTime())) * 100
      : null;

  return (
    <Card>
      <CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
        <div className="min-w-0">
          <CardTitle className="text-base">Quarter timeline</CardTitle>
          {/*
            "Click", not "tap", and the agenda is named as the touch path.
            The marks in this lane grid are 24px by construction — the row
            pitch is 28px, so a 44px target would overlap the row above and
            below and open the wrong record — so the timeline is a pointer and
            keyboard surface, and the agenda underneath is the same events at
            full size. Promising a tap the geometry cannot honour was the part
            that was wrong; the geometry itself is what a Gantt lane is.
          */}
          <p className="mt-1 text-xs text-muted">
            One lane per kind. Bars are spans, diamonds are points. Scroll the pane sideways and
            click a mark to open its record — or use the agenda below, which lists the same events
            in full.
          </p>
        </div>
      </CardHeader>
      <CardContent className="px-0 sm:px-0">
        {/* The one pane in the app allowed to be wider than the viewport. */}
        <div className="scroll-x pb-1">
          <div className="min-w-[880px] pr-4 sm:pr-5">
            <div className="flex min-w-0 items-stretch">
              <div className="sticky left-0 z-20 w-40 shrink-0 bg-surface" />
              <div className="relative min-w-0 flex-1 border-b border-border pb-1">
                {months.map((month) => (
                  <div
                    key={month.key}
                    className="absolute top-0 border-l border-border/70 pl-2"
                    style={{ left: `${month.leftPct}%`, width: `${month.widthPct}%` }}
                  >
                    <Label>{month.label}</Label>
                  </div>
                ))}
                <div className="h-5" />
              </div>
            </div>

            <div className="flex min-w-0 flex-col">
              {lanes.map((lane) => (
                <div key={lane.kind} className="flex min-w-0 items-start gap-0 py-1.5">
                  {/*
                    Sticky, because a lane whose label has scrolled out of the
                    pane is an unlabelled row of coloured bars.
                  */}
                  <div className="sticky left-0 z-20 flex w-40 shrink-0 items-center gap-2 bg-surface pl-4 pr-3 sm:pl-5">
                    <span
                      className={cn('size-2 shrink-0 rounded-full', TONE_DOT[KIND_TONE[lane.kind]])}
                      aria-hidden
                    />
                    <span
                      className="min-w-0 truncate text-xs font-medium text-muted"
                      title={KIND_LABELS[lane.kind]}
                    >
                      {KIND_LABELS[lane.kind]}
                    </span>
                  </div>
                  <div
                    className="relative min-w-0 flex-1"
                    style={{ height: `${lane.rows * ROW_HEIGHT_REM}rem` }}
                  >
                    {months.map((month) => (
                      <div
                        key={month.key}
                        className="absolute inset-y-0 border-l border-border/50"
                        style={{ left: `${month.leftPct}%` }}
                        aria-hidden
                      />
                    ))}
                    {todayPct !== null ? (
                      <div
                        className="absolute inset-y-0 w-px bg-brand/70"
                        style={{ left: `${todayPct}%` }}
                        aria-hidden
                      />
                    ) : null}
                    {lane.placed.map((item) => (
                      <TimelineMark
                        key={item.event.id}
                        item={item}
                        contextYear={contextYear}
                        onEvent={onEvent}
                      />
                    ))}
                  </div>
                </div>
              ))}
            </div>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

function TimelineMark({
  item,
  contextYear,
  onEvent,
}: {
  item: PlacedEvent;
  contextYear: number;
  onEvent(event: CalendarEvent): void;
}) {
  const { event } = item;
  const tone = toneFor(event);
  const title = `${event.title} · ${KIND_LABELS[event.kind]} · ${whenLabel(event, contextYear)}`;
  const top = `${item.row * ROW_HEIGHT_REM}rem`;

  if (!event.isSpan) {
    return (
      <button
        type="button"
        title={title}
        aria-label={title}
        onClick={() => onEvent(event)}
        className="absolute flex h-6 w-6 -translate-x-1/2 items-center justify-center"
        style={{ left: `${item.leftPct}%`, top }}
      >
        <span
          className={cn(
            'block size-3 rotate-45 rounded-[2px] border',
            TONE_BAR[tone],
            event.state === 'overdue' && 'ring-2 ring-danger',
          )}
          aria-hidden
        />
      </button>
    );
  }

  return (
    <button
      type="button"
      title={title}
      aria-label={title}
      onClick={() => onEvent(event)}
      className={cn(
        'absolute flex h-6 min-w-0 items-center overflow-hidden rounded-md border px-1.5 text-left text-xs font-medium',
        TONE_BAR[tone],
        item.clippedStart && 'rounded-l-none border-l-2 border-l-dashed',
        item.clippedEnd && 'rounded-r-none border-r-2 border-r-dashed',
        event.state === 'overdue' && 'ring-2 ring-danger',
        event.state === 'done' && 'opacity-55',
      )}
      style={{ left: `${item.leftPct}%`, width: `${item.widthPct}%`, top }}
    >
      {item.widthPct >= LABEL_MIN_PCT ? (
        <span className="min-w-0 truncate">{event.title}</span>
      ) : null}
    </button>
  );
}

// -------------------------------------------------------------------- agenda

const STATE_TONE: Record<CalendarEventState, StatusColor> = {
  overdue: 'danger',
  due: 'warning',
  upcoming: 'info',
  done: 'neutral',
};

/**
 * The tone for a badge, which is not always the tone for a state.
 *
 * `due` covers two different things — a point event inside the seven-day
 * horizon, which needs a person, and a SPAN whose start has passed, which is
 * simply running. `stateLabel` already told them apart in words; the colour did
 * not, so an agenda carried roughly eighteen amber "Running" pills for windows
 * nobody has to act on, sitting beside the two rows that genuinely do. Warning
 * is the product's one "act now" tone and it is spent by that.
 *
 * `info` is what `status.tsx` documents for exactly this case: "a neutral time
 * or system fact — running, queued, out for signature".
 */
function badgeTone(event: CalendarEvent): StatusColor {
  if (event.state === 'due' && event.isSpan) return 'info';
  return STATE_TONE[event.state];
}

const STATE_LABELS: Record<CalendarEventState, string> = {
  overdue: 'Overdue',
  due: 'Due',
  upcoming: 'Upcoming',
  done: 'Done',
};

/**
 * `due` means two different things depending on the shape of the event: a point
 * inside the seven-day horizon is *due*, whereas a span whose start has passed
 * is *running*. One word for both had a meeting scheduled for this afternoon
 * badged "Running".
 */
function stateLabel(event: CalendarEvent): string {
  if (event.state === 'due' && event.isSpan) return 'Running';
  return STATE_LABELS[event.state];
}

function Agenda({
  events,
  overdue,
  contextYear,
  onEvent,
}: {
  events: CalendarEvent[];
  overdue: CalendarEvent[];
  contextYear: number;
  onEvent(event: CalendarEvent): void;
}) {
  // Overdue is lifted out of chronological order deliberately: something that
  // should already have happened is the first thing to read, not something to
  // be found by scrolling back to the month it was filed under.
  const groups = useMemo(() => {
    const rest = new Map<string, { key: string; label: string; events: CalendarEvent[] }>();
    for (const event of events) {
      if (event.state === 'overdue') continue;
      const at = new Date(event.startsAt);
      const key = `${at.getFullYear()}-${at.getMonth()}`;
      const group = rest.get(key);
      if (group) {
        group.events.push(event);
        continue;
      }
      // A span that overlaps this quarter may have STARTED in July of another
      // year, so the month name alone is neither a unique React key nor an
      // unambiguous heading — two "July" groups was a real console warning.
      rest.set(key, {
        key,
        label:
          at.getFullYear() === contextYear
            ? MONTH_LABEL.format(at)
            : `${MONTH_LABEL.format(at)} ${at.getFullYear()}`,
        events: [event],
      });
    }
    return [...rest.values()];
  }, [events, contextYear]);

  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-base">Agenda</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-5 px-0 sm:px-0">
        {overdue.length ? (
          <AgendaGroup
            label="Overdue"
            tone="danger"
            events={overdue}
            contextYear={contextYear}
            onEvent={onEvent}
          />
        ) : null}
        {groups.map((group) => (
          <AgendaGroup
            key={group.key}
            label={group.label}
            events={group.events}
            contextYear={contextYear}
            onEvent={onEvent}
          />
        ))}
      </CardContent>
    </Card>
  );
}

function AgendaGroup({
  label,
  tone,
  events,
  contextYear,
  onEvent,
}: {
  label: string;
  tone?: StatusColor;
  events: CalendarEvent[];
  contextYear: number;
  onEvent(event: CalendarEvent): void;
}) {
  return (
    <section className="min-w-0">
      <Label
        as="h3"
        className={cn('px-4 pb-2 sm:px-5', tone === 'danger' && 'text-danger')}
      >
        {label} <span className="nums">· {events.length}</span>
      </Label>
      <ul className="min-w-0">
        {events.map((event) => (
          <li key={event.id} className="min-w-0 border-t border-border/60">
            <button
              type="button"
              onClick={() => onEvent(event)}
              className="tap flex w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left transition-colors duration-1 ease-enter hover:bg-surface-2 sm:px-5"
            >
              <span
                className={cn('size-2.5 shrink-0 rounded-full', TONE_DOT[toneFor(event)])}
                aria-hidden
              />
              <span className="min-w-0 flex-1">
                <span
                  className={cn(
                    'block truncate text-sm font-medium',
                    event.state === 'done' && 'text-muted line-through',
                  )}
                >
                  {event.title}
                </span>
                <span className="block truncate text-xs text-muted">
                  {KIND_LABELS[event.kind]}
                  {event.accountName ? ` · ${event.accountName}` : ''} ·{' '}
                  {whenLabel(event, contextYear)}
                </span>
              </span>
              {event.amountCents !== null ? (
                <span className="nums shrink-0 text-sm">
                  {money(event.amountCents, event.currency ?? 'USD')}
                </span>
              ) : null}
              <Badge tone={badgeTone(event)} className="hidden shrink-0 sm:inline-flex">
                {stateLabel(event)}
              </Badge>
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

// --------------------------------------------------------------------- forms

/**
 * `datetime-local` rather than the shadcn calendar primitive.
 *
 * The primitive is not installed in this app — there is no
 * `components/ui/calendar.tsx` — and every other dated form in PIG (the
 * obligation editor in Contracts) already uses this input. Adding a second
 * date-entry idiom for one dialog would be the drift, not the fix; swapping
 * both together is a separate change.
 */
function EntryDialog({ defaultStart, onClose }: { defaultStart: Date; onClose(): void }) {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [kind, setKind] = useState<CalendarEntryKind>('meeting');
  const [startsAt, setStartsAt] = useState(toLocalInputValue(defaultStart));
  const [endsAt, setEndsAt] = useState('');
  const [description, setDescription] = useState('');

  const save = useMutation({
    mutationFn: () =>
      post<CalendarEntryRecord>('/api/calendar/entries', {
        title,
        kind,
        startsAt: new Date(startsAt).toISOString(),
        endsAt: endsAt ? new Date(endsAt).toISOString() : null,
        description: description || null,
      }),
    onSuccess: async (created) => {
      await queryClient.invalidateQueries({ queryKey: ['calendar'] });
      toast.success(`${created.title} is on the calendar`);
      onClose();
    },
    onError: (cause: unknown) =>
      toast.error(cause instanceof Error ? cause.message : 'Could not save the entry'),
  });

  return (
    <Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle>New calendar entry</DialogTitle>
          <DialogDescription>
            A meeting, QBR or reminder — the only dated thing PIG has no other home for. It is
            assigned to you unless someone changes it.
          </DialogDescription>
        </DialogHeader>
        <form
          className="flex flex-col gap-3"
          onSubmit={(submit) => {
            submit.preventDefault();
            save.mutate();
          }}
        >
          <FormField label="Title">
            <Input
              required
              maxLength={240}
              value={title}
              onChange={(change) => setTitle(change.target.value)}
              placeholder="Q3 business review — Halcyon Research"
            />
          </FormField>
          <div className="grid gap-3 sm:grid-cols-2">
            <FormField label="Kind">
              <EntryKindSelect value={kind} onValueChange={setKind} />
            </FormField>
            <FormField label="Starts">
              <Input
                required
                type="datetime-local"
                value={startsAt}
                onChange={(change) => setStartsAt(change.target.value)}
              />
            </FormField>
            <FormField
              label="Ends"
              hint="Leave blank for a point in time. A window that closes with nothing done reads as overdue, not done."
              className="sm:col-span-2"
            >
              <Input
                type="datetime-local"
                value={endsAt}
                min={startsAt}
                onChange={(change) => setEndsAt(change.target.value)}
              />
            </FormField>
          </div>
          <FormField label="Notes">
            <Textarea
              rows={3}
              maxLength={8000}
              value={description}
              onChange={(change) => setDescription(change.target.value)}
            />
          </FormField>
          <DialogFooter>
            <Button type="button" variant="ghost" onClick={onClose}>
              Cancel
            </Button>
            <Button type="submit" variant="primary" disabled={save.isPending || !title.trim()}>
              Save entry
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

/**
 * The one event kind this page can act on.
 *
 * Everything else routes to its record; a calendar entry has no record page, so
 * completing and removing it happen here. Completion is the reason the backend
 * distinguishes `overdue` from `done` on these rows at all.
 */
function EntryDetail({ event, onClose }: { event: CalendarEvent; onClose(): void }) {
  const queryClient = useQueryClient();
  const completedAt = typeof event.meta.completedAt === 'string' ? event.meta.completedAt : null;

  const invalidate = () => queryClient.invalidateQueries({ queryKey: ['calendar'] });

  const complete = useMutation({
    mutationFn: () =>
      patch<CalendarEntryRecord>(`/api/calendar/entries/${event.recordId}`, {
        completedAt: completedAt ? null : new Date().toISOString(),
      }),
    onSuccess: async () => {
      await invalidate();
      toast.success(completedAt ? `${event.title} reopened` : `${event.title} marked complete`);
      onClose();
    },
    onError: (cause: unknown) =>
      toast.error(cause instanceof Error ? cause.message : 'Could not update the entry'),
  });

  const remove = useMutation({
    mutationFn: () => api<{ id: string }>(`/api/calendar/entries/${event.recordId}`, { method: 'DELETE' }),
    onSuccess: async () => {
      await invalidate();
      toast.success(`${event.title} removed`);
      onClose();
    },
    onError: (cause: unknown) =>
      toast.error(cause instanceof Error ? cause.message : 'Could not remove the entry'),
  });

  const entryKind =
    typeof event.meta.entryKind === 'string' ? (event.meta.entryKind as CalendarEntryKind) : null;
  const busy = complete.isPending || remove.isPending;

  return (
    <Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle className="pr-8">{event.title}</DialogTitle>
          <DialogDescription>
            {entryKind ? ENTRY_KIND_LABELS[entryKind] : 'Entry'} ·{' '}
            {whenLabel(event, new Date(event.startsAt).getFullYear())}
            {event.accountName ? ` · ${event.accountName}` : ''}
          </DialogDescription>
        </DialogHeader>
        {typeof event.meta.description === 'string' && event.meta.description ? (
          <p className="whitespace-pre-wrap text-sm text-muted">{event.meta.description}</p>
        ) : null}
        <div className="flex items-center gap-2">
          <Badge tone={badgeTone(event)}>{stateLabel(event)}</Badge>
          {event.state === 'overdue' ? (
            <span className="text-xs text-muted">
              Its window closed with nothing in the completion column.
            </span>
          ) : null}
        </div>
        <DialogFooter>
          <Button
            type="button"
            variant="ghost"
            disabled={busy}
            onClick={() => remove.mutate()}
            className="text-danger"
          >
            <Trash2 className="size-4" aria-hidden /> Remove
          </Button>
          <Button type="button" variant="primary" disabled={busy} onClick={() => complete.mutate()}>
            <Check className="size-4" aria-hidden /> {completedAt ? 'Reopen' : 'Mark complete'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

/** Forwards what FormField clones in onto the trigger, which is the focusable element. */
function EntryKindSelect({
  value,
  onValueChange,
  ...trigger
}: {
  id?: string;
  'aria-label'?: string;
  'aria-describedby'?: string;
  value: CalendarEntryKind;
  onValueChange(next: CalendarEntryKind): void;
}) {
  return (
    <Select value={value} onValueChange={(next) => onValueChange(next as CalendarEntryKind)}>
      <SelectTrigger {...trigger}>
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          {CALENDAR_ENTRY_KINDS.map((kind) => (
            <SelectItem key={kind} value={kind}>
              {ENTRY_KIND_LABELS[kind]}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}
