/**
 * 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 ─────────────────────────────────────────────────────────────

export interface UmamiStats {
  pageviews: { value: number; prev: number };
  visitors: { value: number; prev: number };
  visits: { value: number; prev: number };
  bounces: { value: number; prev: number };
  totaltime: { value: number; prev: 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),
  });
}

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',
  });
}

export type UmamiMetricType =
  | 'url'
  | 'referrer'
  | 'browser'
  | 'os'
  | 'device'
  | 'country'
  | 'event';

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`, {});
}

/**
 * Verify the connection by hitting `/api/websites/:id/active` - the cheapest
 * authenticated endpoint that proves both auth + websiteId are good.
 * Throws on any failure with a descriptive message; resolves on success.
 */
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
  const config = await loadUmamiConfig(portId);
  if (!config) {
    throw new CodedError('UMAMI_NOT_CONFIGURED', {
      internalMessage: 'Umami is not configured for this port.',
    });
  }
  const result = await umamiFetch<UmamiActiveVisitors>(
    config,
    `/api/websites/${config.websiteId}/active`,
    {},
  );
  return { ok: true, visitors: result.visitors };
}
