import { NextResponse } from 'next/server';
import { and, eq, desc, inArray } from 'drizzle-orm';

import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { auditLogs } from '@/lib/db/schema/system';
import { documents, documentEvents } from '@/lib/db/schema/documents';
import { user, userProfiles } from '@/lib/db/schema/users';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { clients } from '@/lib/db/schema/clients';
import { canonicalizeStage, stageLabel } from '@/lib/constants';

const OUTCOME_LABELS: Record<string, string> = {
  won: 'Won',
  lost_other_marina: 'Lost - went to another marina',
  lost_unqualified: 'Lost - unqualified',
  lost_no_response: 'Lost - no response',
  cancelled: 'Cancelled',
};

const DOC_EVENT_LABELS: Record<string, string> = {
  sent: 'sent for signing',
  completed: 'fully signed',
  signed: 'signed by recipient',
  rejected: 'rejected',
  expired: 'expired',
  cancelled: 'cancelled',
  reminder_sent: 'reminder sent',
};

interface TimelineEvent {
  id: string;
  type: 'audit' | 'document_event';
  action: string;
  description: string;
  userId: string | null;
  /** Resolved display name for `userId`. `'system'` for auto-events; null when
   *  the user has been deleted or the event has no actor. Falls back to
   *  email-localpart if the user has no display name. */
  userName: string | null;
  createdAt: Date;
  metadata: Record<string, unknown>;
}

// GET /api/v1/interests/[id]/timeline
export const GET = withAuth(
  withPermission('interests', 'view', async (req, ctx, params) => {
    try {
      const interestId = params.id!;

      const interest = await db.query.interests.findFirst({
        where: and(eq(interests.id, interestId), eq(interests.portId, ctx.portId)),
      });
      if (!interest) throw new NotFoundError('Interest');

      // Fetch audit logs for this interest
      const auditRows = await db
        .select()
        .from(auditLogs)
        .where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
        .orderBy(desc(auditLogs.createdAt))
        .limit(50);

      // Fetch document events for documents linked to this interest
      const interestDocs = await db
        .select({ id: documents.id, title: documents.title })
        .from(documents)
        .where(eq(documents.interestId, interestId));

      const docIds = interestDocs.map((d) => d.id);
      const docEventRows =
        docIds.length > 0
          ? await db
              .select({
                id: documentEvents.id,
                documentId: documentEvents.documentId,
                eventType: documentEvents.eventType,
                eventData: documentEvents.eventData,
                createdAt: documentEvents.createdAt,
              })
              .from(documentEvents)
              .where(inArray(documentEvents.documentId, docIds))
              .orderBy(desc(documentEvents.createdAt))
              .limit(50)
          : [];

      const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));

      // Resolve display names for any `userId` that is a real user row (the
      // sentinel value 'system' is used for auto-events and isn't joined).
      const realUserIds = Array.from(
        new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
      );
      const userRows =
        realUserIds.length > 0
          ? await db
              .select({ id: user.id, name: user.name, email: user.email })
              .from(user)
              .where(inArray(user.id, realUserIds))
          : [];
      const userNameById = new Map<string, string>(
        userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
      );
      const resolveUserName = (userId: string | null): string | null => {
        if (!userId) return null;
        if (userId === 'system') return 'system';
        return userNameById.get(userId) ?? null;
      };

      // Collect every UUID that appears in an audit row's newValue under
      // a known FK field, then batch-resolve to human labels - berth
      // mooring numbers, yacht names, client names, user display names.
      // Without this, `Updated primary berth → <uuid>` leaks raw IDs
      // into the timeline. Order: scan rows, fetch labels, build maps.
      const berthIds = new Set<string>();
      const yachtIds = new Set<string>();
      const clientFkIds = new Set<string>();
      const userFkIds = new Set<string>();
      const USER_FK_FIELDS = new Set(['assignedTo', 'ownerId', 'createdBy', 'reassignedTo']);
      for (const row of auditRows) {
        const nv = row.newValue as Record<string, unknown> | null;
        if (!nv) continue;
        for (const [key, val] of Object.entries(nv)) {
          if (typeof val !== 'string' || val.length < 32) continue;
          if (key === 'berthId') berthIds.add(val);
          else if (key === 'yachtId') yachtIds.add(val);
          else if (key === 'clientId') clientFkIds.add(val);
          else if (USER_FK_FIELDS.has(key)) userFkIds.add(val);
        }
      }
      const [berthRows, yachtRows, clientRows, profileRows] = await Promise.all([
        berthIds.size > 0
          ? db
              .select({ id: berths.id, mooring: berths.mooringNumber })
              .from(berths)
              .where(inArray(berths.id, Array.from(berthIds)))
          : Promise.resolve([] as Array<{ id: string; mooring: string }>),
        yachtIds.size > 0
          ? db
              .select({ id: yachts.id, name: yachts.name })
              .from(yachts)
              .where(inArray(yachts.id, Array.from(yachtIds)))
          : Promise.resolve([] as Array<{ id: string; name: string }>),
        clientFkIds.size > 0
          ? db
              .select({ id: clients.id, name: clients.fullName })
              .from(clients)
              .where(inArray(clients.id, Array.from(clientFkIds)))
          : Promise.resolve([] as Array<{ id: string; name: string }>),
        userFkIds.size > 0
          ? db
              .select({
                userId: userProfiles.userId,
                displayName: userProfiles.displayName,
                firstName: userProfiles.firstName,
                lastName: userProfiles.lastName,
              })
              .from(userProfiles)
              .where(inArray(userProfiles.userId, Array.from(userFkIds)))
          : Promise.resolve(
              [] as Array<{
                userId: string;
                displayName: string | null;
                firstName: string | null;
                lastName: string | null;
              }>,
            ),
      ]);
      const fkLabels: FkLabelMaps = {
        berths: new Map(berthRows.map((b) => [b.id, `Berth ${b.mooring}`])),
        yachts: new Map(yachtRows.map((y) => [y.id, y.name])),
        clients: new Map(clientRows.map((c) => [c.id, c.name])),
        users: new Map(
          profileRows.map((p) => [
            p.userId,
            [p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || 'User',
          ]),
        ),
      };

      // Union and sort
      const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
        id: row.id,
        type: 'audit',
        action: row.action,
        description: buildAuditDescription(
          row.action,
          row.newValue as Record<string, unknown> | null,
          (row.metadata as Record<string, unknown>) ?? {},
          row.userId,
          fkLabels,
        ),
        userId: row.userId,
        userName: resolveUserName(row.userId),
        createdAt: row.createdAt,
        metadata: (row.metadata as Record<string, unknown>) ?? {},
      }));

      const docEvents: TimelineEvent[] = docEventRows.map((row) => {
        const title = docTitles[row.documentId] ?? row.documentId;
        const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
        return {
          id: row.id,
          type: 'document_event',
          action: row.eventType,
          description: `Document "${title}" ${action}`,
          userId: null,
          userName: null,
          createdAt: row.createdAt,
          metadata: (row.eventData as Record<string, unknown>) ?? {},
        };
      });

      const allEvents = [...auditEvents, ...docEvents];

      // Fallback: when no audit-log entries exist for this interest (typical
      // for seed/imported data inserted directly into the table without going
      // through the service), synthesize a "Created at <stage>" event so the
      // tab isn't empty when the interest is clearly past the initial stage.
      const hasCreateAudit = allEvents.some((e) => e.action === 'create');
      if (!hasCreateAudit) {
        const stage = stageLabel(interest.pipelineStage);
        const created = interest.createdAt ?? new Date();
        const isInitialStage = canonicalizeStage(interest.pipelineStage) === 'enquiry';
        allEvents.push({
          id: `synth-${interest.id}-create`,
          type: 'audit',
          action: 'create',
          description: isInitialStage ? 'Interest created' : `Interest created at ${stage}`,
          userId: null,
          userName: null,
          createdAt: created,
          metadata: { synthetic: true },
        });
      }

      allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());

      return NextResponse.json({ data: allEvents.slice(0, 50) });
    } catch (error) {
      return errorResponse(error);
    }
  }),
);

interface FkLabelMaps {
  berths: Map<string, string>;
  yachts: Map<string, string>;
  clients: Map<string, string>;
  users: Map<string, string>;
}

function buildAuditDescription(
  action: string,
  newValue: Record<string, unknown> | null,
  metadata: Record<string, unknown>,
  userId: string | null,
  fkLabels: FkLabelMaps,
): string {
  if (action === 'create') return 'Interest created';
  if (action === 'archive') return 'Interest archived';
  if (action === 'restore') return 'Interest restored';

  const type = metadata.type;

  if (type === 'outcome_set') {
    const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
    const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
    const reason = (newValue?.reason as string | undefined) ?? '';
    return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
  }

  if (type === 'outcome_cleared') {
    const stage = (newValue?.pipelineStage as string | undefined) ?? '';
    return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened';
  }

  if (type === 'stage_change' && newValue?.pipelineStage) {
    const stage = stageLabel(newValue.pipelineStage as string);
    const reason = (newValue.reason as string | undefined) ?? '';
    const auto = userId === 'system';
    if (auto) {
      return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
    }
    return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
  }

  if (action === 'update' && newValue?.pipelineStage) {
    return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
  }
  if (action === 'update') {
    // Interest-berth link mutations get a sentence per flag transition
    // ("Berth A1 added to EOI bundle") instead of a literal key/value
    // dump. The audit row is logged against the parent interest, but
    // `newValue` carries the interest_berths row's flags + the keying
    // berthId - so the rep reads it as "what just happened on this
    // berth link", not "field X changed to Y".
    if (newValue && 'berthId' in newValue && typeof newValue.berthId === 'string') {
      const berthLabel = fkLabels.berths.get(newValue.berthId) ?? '(removed berth)';
      const phrases = describeInterestBerthFlags(newValue);
      if (phrases.length > 0) {
        return phrases.map((p) => p.replace('{berth}', berthLabel)).join(' · ');
      }
    }
    // §1.1: surface which field(s) changed instead of a generic
    // "Interest updated". We have the new-value bag in audit_logs;
    // human-friendly labels for the most common fields.
    return describeUpdateDiff(newValue, fkLabels);
  }
  return action;
}

/**
 * Convert an `interest_berths` audit-log diff bag into one or more
 * plain-English sentences. The audit row's `newValue` carries the
 * post-state for one or more flag columns (`isPrimary`,
 * `isInEoiBundle`, `isSpecificInterest`) plus the keying `berthId`.
 * We narrate each flag transition individually so reps don't see a
 * literal "X → on / Y → off" dump.
 *
 * `{berth}` is a placeholder the caller substitutes with the resolved
 * mooring label (e.g. "Berth A1") - keeping the substitution out of
 * here lets us return the same string shape for the "removed berth"
 * fallback case.
 */
function describeInterestBerthFlags(newValue: Record<string, unknown>): string[] {
  const phrases: string[] = [];
  if (newValue.isPrimary === true) phrases.push('{berth} set as primary berth');
  else if (newValue.isPrimary === false) phrases.push('{berth} no longer primary berth');
  if (newValue.isInEoiBundle === true) phrases.push('{berth} added to EOI bundle');
  else if (newValue.isInEoiBundle === false) phrases.push('{berth} removed from EOI bundle');
  if (newValue.isSpecificInterest === true)
    phrases.push('{berth} marked as specific interest (public Under Offer)');
  else if (newValue.isSpecificInterest === false)
    phrases.push('{berth} no longer marked as specific interest');
  return phrases;
}

/**
 * Render a "leadCategory: hot_lead, source: website" style description from
 * an audit log's newValue payload. Filters out audit-internal fields,
 * passes through human-friendly labels for known fields, falls back to
 * the raw key name when the field isn't in the catalog.
 */
function describeUpdateDiff(
  newValue: Record<string, unknown> | null,
  fkLabels: FkLabelMaps,
): string {
  if (!newValue) return 'Interest updated';

  // Audit-internal / housekeeping fields skipped from the timeline copy.
  const SKIP = new Set(['updatedAt', 'createdAt', 'id', 'portId']);

  const FIELD_LABELS: Record<string, string> = {
    leadCategory: 'lead category',
    source: 'source',
    assignedTo: 'owner',
    yachtId: 'yacht',
    berthId: 'primary berth',
    clientId: 'client',
    eoiDocStatus: 'EOI status',
    reservationDocStatus: 'reservation status',
    contractDocStatus: 'contract status',
    dateEoiSent: 'EOI sent date',
    dateEoiSigned: 'EOI signed date',
    dateReservationSigned: 'reservation signed date',
    dateContractSent: 'contract sent date',
    dateContractSigned: 'contract signed date',
    depositExpectedAmount: 'expected deposit',
    depositExpectedCurrency: 'deposit currency',
    desiredLengthFt: 'desired length',
    desiredWidthFt: 'desired width',
    desiredDraftFt: 'desired draft',
    desiredLengthM: 'desired length (m)',
    desiredWidthM: 'desired width (m)',
    desiredDraftM: 'desired draft (m)',
    reminderEnabled: 'follow-up reminder',
    reminderDays: 'reminder cadence',
    reminderNote: 'reminder note',
    outcome: 'outcome',
    isPrimary: 'primary-berth flag',
    isInEoiBundle: 'in EOI bundle',
    isSpecificInterest: 'specific-interest flag',
  };

  // FK-field → label-map lookup. When the audit row carries a UUID for
  // one of these fields, we substitute the human label (mooring number,
  // yacht/client name, user display name) instead of leaking the id.
  const FK_FIELD_MAP: Record<string, keyof FkLabelMaps> = {
    berthId: 'berths',
    yachtId: 'yachts',
    clientId: 'clients',
    assignedTo: 'users',
    ownerId: 'users',
    createdBy: 'users',
    reassignedTo: 'users',
  };

  const changed: string[] = [];
  for (const [key, value] of Object.entries(newValue)) {
    if (SKIP.has(key)) continue;
    if (key === 'pipelineStage') continue; // handled by the earlier branch
    const label = FIELD_LABELS[key] ?? humanizeKey(key);
    const fkMapKey = FK_FIELD_MAP[key];
    const formatted = formatDiffValue(value, fkMapKey ? fkLabels[fkMapKey] : null);
    changed.push(formatted ? `${label} → ${formatted}` : label);
  }

  if (changed.length === 0) return 'Interest updated';
  if (changed.length === 1) return `Updated ${changed[0]}`;
  if (changed.length <= 3) return `Updated ${changed.join(', ')}`;
  return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`;
}

function humanizeKey(key: string): string {
  return key
    .replace(/([a-z])([A-Z])/g, '$1 $2')
    .replace(/_/g, ' ')
    .toLowerCase();
}

function formatDiffValue(v: unknown, fkLabelMap: Map<string, string> | null): string {
  if (v === null || v === undefined) return 'cleared';
  if (typeof v === 'boolean') return v ? 'on' : 'off';
  if (typeof v === 'number') return String(v);
  if (typeof v === 'string') {
    // Resolve UUIDs through the supplied FK label map when present, so
    // `berthId → a53e3b1d-...` renders as `primary berth → Berth A1`.
    // Falls back to "(removed)" if the entity is gone, never the raw id.
    if (fkLabelMap) {
      return fkLabelMap.get(v) ?? '(removed)';
    }
    // Truncate verbose strings so the timeline line stays one row.
    return v.length > 40 ? `${v.slice(0, 37)}…` : v;
  }
  return '';
}
