import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { parseISO } from 'date-fns';
import type { CalendarEvent } from '@/lib/jmap/types';

/**
 * POST /api/calendar-agenda
 *
 * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar
 * account from the stored Stalwart auth context, queries upcoming
 * CalendarEvents over JMAP, expands recurring series server-side, and returns
 * a slim, structured-cloneable agenda the plugin can render directly.
 *
 * Credentials never leave the server — the sandboxed plugin only ever sees
 * the resulting agenda DTOs.
 *
 * Body: { days?: number (1-90, default 7), limit?: number (1-200, default 50) }
 */

const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars';
const PRINCIPALS_CAP = 'urn:ietf:params:jmap:principals';

// Mirror of lib/jmap/client.ts CALENDAR_EVENT_PROPERTIES, trimmed to what the
// agenda actually needs (start/recurrence/display fields).
const EVENT_PROPERTIES = [
  'id', '@type', 'uid', 'calendarIds', 'title', 'start', 'duration', 'timeZone',
  'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'color',
  'locations', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
  'recurrenceOverrides', 'excludedRecurrenceRule',
] as const;

interface JmapSession {
  apiUrl?: string;
  primaryAccounts?: Record<string, string>;
  capabilities?: Record<string, unknown>;
}

interface AgendaEvent {
  id: string;
  uid: string | null;
  title: string;
  start: string;
  end: string;
  allDay: boolean;
  status: string | null;
  color: string | null;
  location: string | null;
  calendarId: string | null;
}

// Pure, server-safe equivalents of lib/calendar-utils' getEventStartDate /
// getEventEndDate. We can't import that module here because it transitively
// pulls in a "use client" calendar component (which throws at load on the
// server). Logic mirrors the originals.
function parseDurationMinutes(duration: string | undefined): number {
  if (!duration) return 0;
  let total = 0;
  const week = duration.match(/(\d+)W/);
  const day = duration.match(/(\d+)D/);
  const hour = duration.match(/(\d+)H/);
  const min = duration.match(/(\d+)M/);
  if (week) total += parseInt(week[1], 10) * 7 * 24 * 60;
  if (day) total += parseInt(day[1], 10) * 24 * 60;
  if (hour) total += parseInt(hour[1], 10) * 60;
  if (min) total += parseInt(min[1], 10);
  return total;
}

function eventStart(event: Partial<CalendarEvent>): Date {
  if (!event.showWithoutTime && event.utcStart) {
    const utc = parseISO(event.utcStart);
    if (!isNaN(utc.getTime())) return utc;
  }
  return parseISO(event.start as string);
}

function eventEnd(event: Partial<CalendarEvent>): Date {
  if (!event.showWithoutTime && event.utcEnd) {
    const utc = parseISO(event.utcEnd);
    if (!isNaN(utc.getTime())) return utc;
  }
  const start = eventStart(event);
  if (!event.duration) return start;
  return new Date(start.getTime() + parseDurationMinutes(event.duration) * 60000);
}

function firstLocationName(event: Partial<CalendarEvent>): string | null {
  const locations = event.locations;
  if (!locations || typeof locations !== 'object') return null;
  for (const loc of Object.values(locations)) {
    const name = (loc as { name?: unknown })?.name;
    if (typeof name === 'string' && name.trim()) return name.trim();
  }
  return null;
}

function firstCalendarId(event: Partial<CalendarEvent>): string | null {
  const ids = event.calendarIds;
  if (!ids || typeof ids !== 'object') return null;
  const keys = Object.keys(ids);
  return keys.length > 0 ? keys[0] : null;
}

export async function POST(request: NextRequest) {
  try {
    const creds = await getStalwartCredentials(request);
    if (!creds) {
      return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
    }

    let body: { days?: unknown; limit?: unknown } = {};
    try {
      body = await request.json();
    } catch {
      /* empty body is fine */
    }
    const days = clampInt(body.days, 1, 90, 7);
    const limit = clampInt(body.limit, 1, 200, 50);

    // ── Resolve the calendar account from the JMAP session ──
    // Hit Stalwart's canonical session endpoint on the SAME host as serverUrl.
    // We deliberately avoid /.well-known/jmap: it 301s to the server's
    // configured public hostname, which the host process may not be able to
    // resolve (the browser client rewrites those URLs back to the origin for
    // the same reason). Fall back to /.well-known/jmap for non-Stalwart servers.
    const session = await fetchJmapSession(creds.serverUrl, creds.authHeader);
    if (!session) {
      return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 });
    }
    const accountId = session.primaryAccounts?.[CALENDAR_CAP];
    if (!accountId) {
      // No calendar account for this user — return an empty agenda, not an error.
      return NextResponse.json({ events: [], generatedAt: new Date().toISOString() });
    }

    const using = ['urn:ietf:params:jmap:core', CALENDAR_CAP];
    if (session.capabilities && PRINCIPALS_CAP in session.capabilities) {
      using.push('urn:ietf:params:jmap:principals:owner');
    }

    // Send method calls to the same-origin JMAP endpoint the app's passthrough
    // uses — never to session.apiUrl's (possibly unreachable) public host.
    const apiUrl = `${creds.serverUrl}/jmap/`;

    const now = new Date();
    const horizon = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
    // Expand from the start of today so all-day / already-running events still
    // show in the agenda.
    const windowStart = new Date(now);
    windowStart.setHours(0, 0, 0, 0);

    // ── 1) Query event IDs in range + load calendars (colours) ──
    const queryReq = {
      using,
      methodCalls: [
        [
          'CalendarEvent/query',
          {
            accountId,
            // Mirror the app's calendar store: an { after, before } window lets
            // Stalwart evaluate recurrence so masters with occurrences in range
            // are returned (a `before`-only filter can drop unbounded series).
            filter: { after: windowStart.toISOString(), before: horizon.toISOString() },
            limit: 1000,
          },
          '0',
        ],
        [
          'Calendar/get',
          { accountId, ids: null, properties: ['id', 'name', 'color'] },
          'c',
        ],
      ],
    };

    const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq);
    const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0');
    if (!queryResp) {
      const err = findResponse(queryRes, 'error', '0');
      return NextResponse.json(
        { error: (err?.description as string) || 'CalendarEvent/query failed' },
        { status: 502 },
      );
    }
    const ids = (queryResp.ids as string[]) || [];

    const calColors = new Map<string, { name: string; color: string | null }>();
    const calResp = findResponse(queryRes, 'Calendar/get', 'c');
    for (const cal of ((calResp?.list as Array<Record<string, unknown>>) || [])) {
      if (typeof cal.id === 'string') {
        calColors.set(cal.id, {
          name: typeof cal.name === 'string' ? cal.name : '',
          color: typeof cal.color === 'string' ? cal.color : null,
        });
      }
    }

    if (ids.length === 0) {
      return NextResponse.json({ events: [], generatedAt: now.toISOString() });
    }

    // ── 2) Fetch full event objects (batched) ──
    const raw: Array<Record<string, unknown>> = [];
    const BATCH = 100;
    for (let i = 0; i < ids.length; i += BATCH) {
      const batch = ids.slice(i, i + BATCH);
      const getRes = await jmapPost(apiUrl, creds.authHeader, {
        using,
        methodCalls: [
          ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'],
        ],
      });
      const getResp = findResponse(getRes, 'CalendarEvent/get', '0');
      if (getResp?.list) raw.push(...(getResp.list as Array<Record<string, unknown>>));
    }

    // ── 3) Normalize + expand recurrences server-side ──
    const normalized = raw
      .map((e) => normalizeCalendarEventLike(e as Partial<CalendarEvent>))
      .filter((e) => (e['@type'] ?? 'Event') === 'Event')
      // Drop malformed events without a parseable start (would crash format()/
      // parseISO downstream) — mirrors the calendar store guard (#316).
      .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[];

    const expanded = expandRecurringEvents(
      normalized,
      windowStart.toISOString(),
      horizon.toISOString(),
    );

    // ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ──
    const agenda: AgendaEvent[] = expanded
      .filter((e) => eventEnd(e).getTime() >= now.getTime())
      .sort((a, b) => eventStart(a).getTime() - eventStart(b).getTime())
      .slice(0, limit)
      .map((e) => {
        const calId = firstCalendarId(e);
        const cal = calId ? calColors.get(calId) : undefined;
        return {
          id: String(e.id ?? ''),
          uid: e.uid ?? null,
          title: (e.title ?? '').trim() || '(no title)',
          start: eventStart(e).toISOString(),
          end: eventEnd(e).toISOString(),
          allDay: !!e.showWithoutTime,
          status: e.status ?? null,
          color: e.color || cal?.color || null,
          location: firstLocationName(e),
          calendarId: calId,
        };
      });

    return NextResponse.json({ events: agenda, generatedAt: now.toISOString() });
  } catch (error) {
    // `fetch failed` from undici is too generic to debug — the real reason
    // (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
    const err = error as Error & { cause?: { code?: string; message?: string } };
    logger.error('Calendar agenda error', {
      error: err?.message ?? 'Unknown',
      causeCode: err?.cause?.code,
      causeMessage: err?.cause?.message,
    });
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

function clampInt(value: unknown, min: number, max: number, fallback: number): number {
  const n = typeof value === 'number' ? value : Number(value);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, Math.round(n)));
}

/**
 * Fetch the JMAP session from the same host as `serverUrl`. Tries Stalwart's
 * canonical /jmap/session first (no redirect), then /.well-known/jmap as a
 * fallback for other servers. Returns null if neither yields a usable session.
 */
async function fetchJmapSession(
  serverUrl: string,
  authHeader: string,
): Promise<JmapSession | null> {
  const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
  for (const url of candidates) {
    try {
      const res = await fetch(url, {
        method: 'GET',
        headers: { Authorization: authHeader },
        redirect: 'follow',
      });
      if (!res.ok) continue;
      const session = (await res.json()) as JmapSession;
      if (session && typeof session === 'object' && session.primaryAccounts) {
        return session;
      }
    } catch {
      // Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
    }
  }
  return null;
}

async function jmapPost(
  apiUrl: string,
  authHeader: string,
  payload: unknown,
): Promise<unknown> {
  const res = await fetch(apiUrl, {
    method: 'POST',
    headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    throw new Error(`JMAP request failed (${res.status})`);
  }
  return res.json();
}

function findResponse(
  res: unknown,
  name: string,
  callId: string,
): Record<string, unknown> | null {
  const responses = (res as { methodResponses?: unknown[] })?.methodResponses;
  if (!Array.isArray(responses)) return null;
  for (const entry of responses) {
    if (Array.isArray(entry) && entry[0] === name && entry[2] === callId) {
      return entry[1] as Record<string, unknown>;
    }
  }
  return null;
}
