/**
 * Umami v2 API client. Reads credentials from `system_settings` per port,
 * caches JWTs in-memory when using the username/password flow, and exposes
 * typed wrappers for the handful of endpoints the /website-analytics page
 * uses.
 *
 * Auth resolution order (per port):
 *   1. If `umami_api_token` is set → use it as a Bearer token (Umami Cloud
 *      pattern, also supported by v2 self-hosted with API keys enabled).
 *   2. Otherwise → POST /api/auth/login with `umami_username` +
 *      `umami_password` to get a JWT, cache it, use it as Bearer.
 *
 * No env vars - all config lives in port-scoped system_settings so the
 * operator can configure Umami at runtime via /admin/website-analytics.
 *
 * v2 docs: https://docs.umami.is/docs/api
 */

import { and, eq, inArray } from 'drizzle-orm';

import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { CodedError } from '@/lib/errors';
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
import { decrypt } from '@/lib/utils/encryption';
import { logger } from '@/lib/logger';

// ─── Settings access ────────────────────────────────────────────────────────

interface UmamiPortConfig {
  apiUrl: string;
  apiToken: string | null;
  username: string | null;
  password: string | null;
  websiteId: string;
}

// `umami_api_token` and `umami_password` may be stored EITHER as encrypted
// `iv:cipher:tag` strings (matching the SMTP / S3 secret pattern) OR as
// legacy plaintext. The reader below tries decryption first and falls
// back to the raw value when the format isn't AES-GCM-shaped, so an
// operator can rotate to encrypted-at-rest by re-saving the setting
// without a flag-day migration.
const SETTING_KEYS = [
  'umami_api_url',
  'umami_api_token',
  'umami_username',
  'umami_password',
  'umami_website_id',
] as const;

function readSecret(raw: string | null | undefined): string | null {
  const v = (raw ?? '').toString().trim();
  if (!v) return null;
  // `encrypt()` returns `<iv-hex>:<cipher-hex>:<tag-hex>` (3 colon-separated
  // hex chunks). If we see that shape, decrypt; otherwise treat as legacy
  // plaintext. The fallback path is a transition affordance - operators
  // should re-save the setting via the admin UI, which writes the
  // encrypted form going forward.
  const parts = v.split(':');
  if (parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/i.test(p))) {
    try {
      return decrypt(v);
    } catch (err) {
      logger.warn(
        { err },
        'Umami secret looked encrypted but decrypt failed; treating as plaintext',
      );
    }
  }
  return v;
}

/**
 * Read the five Umami-related setting rows for one port and assemble them.
 * Returns null if the minimum required config (URL + websiteId + an auth
 * method) is missing - callers surface a "not configured" UI in that case.
 */
export async function loadUmamiConfig(portId: string): Promise<UmamiPortConfig | null> {
  // Filter to ONLY the five Umami keys. Without this, every analytics page
  // request pulls every system_settings row for the port (Documenso keys,
  // SMTP, email templates, etc), which scales poorly as the port grows.
  const rows = await db
    .select({ key: systemSettings.key, value: systemSettings.value })
    .from(systemSettings)
    .where(and(eq(systemSettings.portId, portId), inArray(systemSettings.key, [...SETTING_KEYS])));

  const map = new Map(rows.map((r) => [r.key, r.value as string | null | undefined]));
  const apiUrl = (map.get('umami_api_url') ?? '').toString().trim().replace(/\/$/, '');
  // Sensitive values pass through readSecret() to support encrypted-at-rest
  // storage (with plaintext fallback for legacy rows).
  const apiToken = readSecret(map.get('umami_api_token') as string | null | undefined);
  const username = ((map.get('umami_username') ?? '') as string).trim() || null;
  const password = readSecret(map.get('umami_password') as string | null | undefined);
  const websiteId = ((map.get('umami_website_id') ?? '') as string).trim();

  if (!apiUrl || !websiteId) return null;
  if (!apiToken && !(username && password)) return null;

  return { apiUrl, apiToken, username, password, websiteId };
}

// ─── JWT cache (username/password flow only) ────────────────────────────────

interface CachedJwt {
  token: string;
  expiresAt: number;
}
// Keyed by `${apiUrl}::${username}` so different ports / different Umami
// instances don't share tokens. Tokens are presumed to last 1 hour; we
// refresh proactively a few minutes before expiry.
const jwtCache = new Map<string, CachedJwt>();
const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h

async function loginAndCache(apiUrl: string, username: string, password: string): Promise<string> {
  const res = await fetchWithTimeout(`${apiUrl}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', accept: 'application/json' },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) {
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: `Umami login failed: ${res.status} ${res.statusText}`,
    });
  }
  const body = (await res.json()) as { token?: string };
  if (!body.token)
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: 'Umami login response missing token',
    });
  jwtCache.set(`${apiUrl}::${username}`, {
    token: body.token,
    expiresAt: Date.now() + JWT_TTL_MS,
  });
  return body.token;
}

async function resolveBearer(config: UmamiPortConfig): Promise<string> {
  if (config.apiToken) return config.apiToken;
  if (!config.username || !config.password) {
    throw new CodedError('UMAMI_NOT_CONFIGURED', {
      internalMessage: 'Umami is misconfigured: no API token and no username/password.',
    });
  }
  const cacheKey = `${config.apiUrl}::${config.username}`;
  const cached = jwtCache.get(cacheKey);
  if (cached && cached.expiresAt > Date.now()) return cached.token;
  return loginAndCache(config.apiUrl, config.username, config.password);
}

// ─── Generic request helper ─────────────────────────────────────────────────

async function umamiFetch<T>(
  config: UmamiPortConfig,
  path: string,
  search: Record<string, string | number | undefined>,
): Promise<T> {
  const bearer = await resolveBearer(config);
  const url = new URL(`${config.apiUrl}${path}`);
  for (const [k, v] of Object.entries(search)) {
    if (v === undefined) continue;
    url.searchParams.set(k, String(v));
  }

  const res = await fetchWithTimeout(url.toString(), {
    headers: {
      Authorization: `Bearer ${bearer}`,
      accept: 'application/json',
    },
    // Don't share Next.js's request cache - analytics figures change every
    // few seconds. The service-layer cache (if any) is the right place.
    cache: 'no-store',
  });

  if (res.status === 401 || res.status === 403) {
    // Bearer rejected - drop cached JWT so next call re-logs in.
    if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: `Umami unauthorized: ${res.status}`,
    });
  }
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${
        text ? ` - ${text}` : ''
      }`,
    });
  }
  return (await res.json()) as T;
}

// ─── Range serialization ────────────────────────────────────────────────────

function rangeToParams(range: DateRange): { startAt: number; endAt: number } {
  // Umami expects unix milliseconds for both bounds.
  const { from, to } = rangeToBounds(range);
  return { startAt: from.getTime(), endAt: to.getTime() };
}

/** Pick a sensible bucket size for the pageviews timeseries given the
 *  range span. Avoids returning thousands of points for a 90d range. */
function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
  const { from, to } = rangeToBounds(range);
  const days = (to.getTime() - from.getTime()) / 86_400_000;
  if (days <= 2) return 'hour';
  if (days <= 120) return 'day';
  return 'month';
}

// ─── Public API ─────────────────────────────────────────────────────────────

/**
 * Stats response from `/api/websites/:id/stats` on Umami v2.x / v3.x.
 *
 * Each top-level metric is a plain number for the requested range; the
 * `comparison` block carries the equivalent values for the previous
 * window of the same length (so a 30-day range comes back with the prior
 * 30 days as `comparison.*`). Verified empirically against Umami v3.1.0
 * - earlier internal types modelled this as `{value, prev}` per metric,
 * which matched neither v2 nor v3 and caused the dashboard tile to read
 * `pageviews.value` as undefined and render 0.
 */
export interface UmamiStats {
  pageviews: number;
  visitors: number;
  visits: number;
  bounces: number;
  totaltime: number;
  comparison?: {
    pageviews: number;
    visitors: number;
    visits: number;
    bounces: number;
    totaltime: number;
  };
}

export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiStats>(config, `/api/websites/${config.websiteId}/stats`, {
    ...rangeToParams(range),
  });
}

/**
 * Pageviews time-series response. Umami v3 returns the `pageviews` array
 * unconditionally; the `sessions` array only appears when the request
 * includes a `compare` directive (omitted today). The optional field
 * keeps the type honest so consumers don't blindly read `.sessions[0]`.
 */
export interface UmamiPageviewsSeries {
  pageviews: Array<{ x: string; y: number }>;
  sessions?: Array<{ x: string; y: number }>;
}

export async function getPageviewsSeries(
  portId: string,
  range: DateRange,
): Promise<UmamiPageviewsSeries | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiPageviewsSeries>(config, `/api/websites/${config.websiteId}/pageviews`, {
    ...rangeToParams(range),
    unit: pickUnit(range),
    timezone: 'UTC',
  });
}

/**
 * Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x.
 * `path` replaces the old `url` value - sending `type=url` against a v3
 * instance returns 400. The full Umami enum also includes `entry|exit|
 * title|query|region|city|language|screen|hostname|tag|distinctId`; only
 * the ones the CRM actually surfaces are listed here.
 */
export type UmamiMetricType =
  | 'path'
  | 'referrer'
  | 'browser'
  | 'os'
  | 'device'
  | 'country'
  | 'region'
  | 'city'
  | 'event'
  | 'title'
  | 'query';

export interface UmamiMetricRow {
  x: string;
  y: number;
}

export async function getMetric(
  portId: string,
  range: DateRange,
  type: UmamiMetricType,
  limit = 10,
): Promise<UmamiMetricRow[] | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiMetricRow[]>(config, `/api/websites/${config.websiteId}/metrics`, {
    ...rangeToParams(range),
    type,
    limit,
  });
}

export interface UmamiActiveVisitors {
  visitors: number;
}

export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisitors | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
}

/** Website-level metadata (name + domain) so the analytics page can show
 *  which site it's reporting on without the operator having to hard-code
 *  the domain in system_settings. */
export interface UmamiWebsiteInfo {
  id: string;
  name: string;
  domain: string;
}

export async function getWebsiteInfo(portId: string): Promise<UmamiWebsiteInfo | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  const res = await umamiFetch<{ id?: string; name?: string; domain?: string }>(
    config,
    `/api/websites/${config.websiteId}`,
    {},
  );
  return {
    id: res.id ?? config.websiteId,
    name: res.name ?? res.domain ?? 'Website',
    domain: res.domain ?? '',
  };
}

/**
 * Verify the connection by hitting `/api/websites/:id/active` - the cheapest
 * authenticated endpoint that proves both auth + websiteId are good.
 *
 * M-IN05: returns a tagged union `{ ok: true | false }` instead of throwing,
 * matching the shape of `checkDocumensoHealth` / sales-email health probes.
 * Routes that just want to surface a green/red "Test connection" pill no
 * longer have to wrap the call in try/catch with hand-crafted error
 * extraction.
 */
export async function testConnection(
  portId: string,
): Promise<{ ok: true; visitors: number } | { ok: false; error: string }> {
  const config = await loadUmamiConfig(portId);
  if (!config) {
    return { ok: false, error: 'Umami is not configured for this port.' };
  }
  try {
    const result = await umamiFetch<UmamiActiveVisitors>(
      config,
      `/api/websites/${config.websiteId}/active`,
      {},
    );
    return { ok: true, visitors: result.visitors };
  } catch (err) {
    const message =
      err instanceof Error ? err.message : typeof err === 'string' ? err : 'Umami request failed';
    return { ok: false, error: message };
  }
}

// ─── Realtime panel ────────────────────────────────────────────────────────
//
// `/api/realtime/:id` is the richer alternative to `/active` - returns
// totals, top URLs being viewed right now, top countries, a 30-min
// time-series and a recent-event stream. Used by the realtime dashboard.

export interface UmamiRealtime {
  urls: Record<string, number>;
  countries: Record<string, number>;
  events: Array<{
    __type: string;
    os?: string;
    device?: string;
    country?: string;
    sessionId?: string;
    eventName?: string;
    browser?: string;
    createdAt: string;
    urlPath?: string;
    referrerDomain?: string;
  }>;
  series: {
    views: Array<{ x: string; y: number }>;
    visitors: Array<{ x: string; y: number }>;
  };
  referrers: Record<string, number>;
  totals: {
    visitors: number;
    views: number;
    events: number;
    countries: number;
  };
  timestamp: number;
}

export async function getRealtime(portId: string): Promise<UmamiRealtime | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  // 30-minute window matches Umami's own realtime page default.
  const startAt = Date.now() - 30 * 60 * 1000;
  return umamiFetch<UmamiRealtime>(config, `/api/realtime/${config.websiteId}`, {
    startAt,
    endAt: Date.now(),
    timezone: 'UTC',
  });
}

// ─── Sessions ──────────────────────────────────────────────────────────────

export interface UmamiSession {
  id: string;
  websiteId: string;
  hostname: string;
  browser: string;
  os: string;
  device: string;
  screen: string;
  language: string;
  country: string;
  subdivision1?: string;
  city?: string;
  firstAt: string;
  lastAt: string;
  visits: number;
  views: number;
  events: number;
  totaltime?: number;
}

export interface UmamiSessionsPage {
  data: UmamiSession[];
  count: number;
  page: number;
  pageSize: number;
}

export async function getSessions(
  portId: string,
  range: DateRange,
  opts: { page?: number; pageSize?: number; query?: string } = {},
): Promise<UmamiSessionsPage | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiSessionsPage>(config, `/api/websites/${config.websiteId}/sessions`, {
    ...rangeToParams(range),
    page: opts.page ?? 1,
    pageSize: opts.pageSize ?? 25,
    query: opts.query,
  });
}

export async function getSession(portId: string, sessionId: string): Promise<UmamiSession | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiSession>(
    config,
    `/api/websites/${config.websiteId}/sessions/${sessionId}`,
    {},
  );
}

export interface UmamiSessionActivity {
  eventType: number;
  urlQuery?: string;
  urlPath: string;
  eventName?: string;
  createdAt: string;
  referrerDomain?: string;
  eventId: string;
  visitId: string;
}

export async function getSessionActivity(
  portId: string,
  sessionId: string,
  range: DateRange,
): Promise<UmamiSessionActivity[] | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<UmamiSessionActivity[]>(
    config,
    `/api/websites/${config.websiteId}/sessions/${sessionId}/activity`,
    { ...rangeToParams(range) },
  );
}

/**
 * Sessions by hour-of-week heatmap - returns a 7×24 nested-array (rows are
 * days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap
 * card.
 */
export async function getSessionsWeekly(
  portId: string,
  range: DateRange,
  timezone = 'UTC',
): Promise<number[][] | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch<number[][]>(config, `/api/websites/${config.websiteId}/sessions/weekly`, {
    ...rangeToParams(range),
    timezone,
  });
}

// ─── Events ────────────────────────────────────────────────────────────────
//
// Wrappers ready for when the marketing site starts firing `umami.track()`
// calls. Until then, every read returns an empty list - wired now so the
// UI surface can light up immediately on the day events start arriving.

export interface UmamiEvent {
  id: string;
  sessionId: string;
  websiteId: string;
  createdAt: string;
  urlPath: string;
  eventName?: string;
  pageTitle?: string;
}

export async function getEvents(
  portId: string,
  range: DateRange,
  opts: { page?: number; pageSize?: number } = {},
): Promise<{ data: UmamiEvent[]; count: number; page: number; pageSize: number } | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch(config, `/api/websites/${config.websiteId}/events`, {
    ...rangeToParams(range),
    page: opts.page ?? 1,
    pageSize: opts.pageSize ?? 25,
  });
}

export async function getEventsStats(
  portId: string,
  range: DateRange,
): Promise<{ pageviews: number; visitors: number; events: number } | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch(config, `/api/websites/${config.websiteId}/events/stats`, {
    ...rangeToParams(range),
  });
}

export async function getEventsSeries(
  portId: string,
  range: DateRange,
  eventName: string,
  unit: 'hour' | 'day' | 'month' = 'day',
): Promise<Array<{ x: string; y: number }> | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  return umamiFetch(config, `/api/websites/${config.websiteId}/events/series`, {
    ...rangeToParams(range),
    eventName,
    unit,
    timezone: 'UTC',
  });
}

// ─── Reports (POST endpoints) ──────────────────────────────────────────────
//
// Reports are POST-only and take a JSON body; build a sibling `umamiPost`
// helper that handles auth + error shape the same way as `umamiFetch`.

async function umamiPost<T>(config: UmamiPortConfig, path: string, body: unknown): Promise<T> {
  const bearer = await resolveBearer(config);
  const res = await fetchWithTimeout(`${config.apiUrl}${path}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${bearer}`,
      'Content-Type': 'application/json',
      accept: 'application/json',
    },
    body: JSON.stringify(body),
    cache: 'no-store',
  });
  if (res.status === 401 || res.status === 403) {
    if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: `Umami unauthorized: ${res.status}`,
    });
  }
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new CodedError('UMAMI_UPSTREAM_ERROR', {
      internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
    });
  }
  return (await res.json()) as T;
}

export interface UmamiFunnelStep {
  x: string;
  y: number;
  z: number;
  dropoff: number;
}

export async function runFunnelReport(
  portId: string,
  range: DateRange,
  steps: Array<{ type: 'url' | 'event'; value: string }>,
  windowHours = 24,
): Promise<UmamiFunnelStep[] | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  const { from, to } = rangeToBounds(range);
  return umamiPost<UmamiFunnelStep[]>(config, `/api/reports/funnel`, {
    websiteId: config.websiteId,
    steps,
    window: windowHours * 3600,
    dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
  });
}

export interface UmamiJourneyStep {
  items: string[];
  count: number;
}

export async function runJourneyReport(
  portId: string,
  range: DateRange,
  startStep?: string,
  endStep?: string,
  stepCount = 5,
): Promise<UmamiJourneyStep[] | null> {
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  const { from, to } = rangeToBounds(range);
  return umamiPost<UmamiJourneyStep[]>(config, `/api/reports/journey`, {
    websiteId: config.websiteId,
    startStep,
    endStep,
    steps: stepCount,
    dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
  });
}

// ─── CRM → Umami event push (Phase 6) ──────────────────────────────────────
//
// Thin wrapper around `@umami/node` so CRM outcome events land in the same
// Umami instance the marketing site reports to. Per-port client instances
// are cached so we don't re-instantiate on every event.

import { Umami } from '@umami/node';

const trackerByPort = new Map<string, Umami>();

async function getTracker(portId: string): Promise<Umami | null> {
  const cached = trackerByPort.get(portId);
  if (cached) return cached;
  const config = await loadUmamiConfig(portId);
  if (!config) return null;
  const tracker = new Umami({ websiteId: config.websiteId, hostUrl: config.apiUrl });
  trackerByPort.set(portId, tracker);
  return tracker;
}

/**
 * Push a CRM-side event back to Umami. Outcome milestones (eoi-sent,
 * eoi-signed, reservation-paid, contract-signed) flow through here so
 * Umami's funnel + attribution reports can correlate marketing-site
 * traffic with downstream deal outcomes.
 *
 * Soft-fail: if Umami is unreachable or misconfigured the call swallows
 * the error and logs a warning - outcome events shouldn't fail a CRM
 * mutation.
 */
export async function trackEvent(
  portId: string,
  name: string,
  data?: Record<string, unknown>,
  url?: string,
): Promise<void> {
  try {
    const tracker = await getTracker(portId);
    if (!tracker) return;
    await tracker.track({
      url: url ?? `/crm/${name}`,
      name,
      ...(data ? { data } : {}),
    });
  } catch (err) {
    logger.warn({ err, name }, 'Umami trackEvent failed (non-blocking)');
  }
}
