import { randomUUID } from "node:crypto";
import { calculateCnyAmount, currencies, type CurrencyCode, type ConversionStatus } from "@cents/domain";
import type { PoolClient } from "pg";
import { pool } from "./db.js";

const PROVIDER = "ECB";
const API_BASE_URL = process.env.EXCHANGE_RATE_API_URL ?? "https://api.frankfurter.dev";
const APP_TIME_ZONE = process.env.APP_TIME_ZONE ?? "Asia/Shanghai";
const CACHE_LIMIT = 2_000;
const CURRENT_RATE_TTL_MS = 15 * 60_000;
const FALLBACK_TTL_MS = 5 * 60_000;
const PENDING_TTL_MS = 30_000;
const WORKER_INTERVAL_MS = 1_000;

export type ResolvedRate = {
  currency: CurrencyCode;
  requestedDate: string;
  effectiveDate: string | null;
  cnyPerUnit: string | null;
  status: ConversionStatus;
};

type CachedRate = ResolvedRate & { expiresAt: number };
type StoredRate = {
  currency: CurrencyCode;
  requestedDate: string;
  effectiveDate: string;
  cnyPerUnit: string;
};

const rateCache = new Map<string, CachedRate>();
const inFlight = new Map<string, Promise<ResolvedRate>>();
let workerTimer: NodeJS.Timeout | null = null;
let workerRunning = false;

function cacheKey(currency: CurrencyCode, requestedDate: string) {
  return `${PROVIDER}:${currency}:${requestedDate}`;
}

function isToday(date: string) {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: APP_TIME_ZONE,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date());
  const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
  return date === `${value.year}-${value.month}-${value.day}`;
}

function cacheResult(result: ResolvedRate) {
  const ttl = result.status === "pending"
    ? PENDING_TTL_MS
    : result.status === "fallback"
      ? FALLBACK_TTL_MS
      : isToday(result.requestedDate) ? CURRENT_RATE_TTL_MS : Number.POSITIVE_INFINITY;
  const key = cacheKey(result.currency, result.requestedDate);
  rateCache.delete(key);
  rateCache.set(key, { ...result, expiresAt: Date.now() + ttl });
  while (rateCache.size > CACHE_LIMIT) {
    const oldestKey = rateCache.keys().next().value as string | undefined;
    if (!oldestKey) break;
    rateCache.delete(oldestKey);
  }
}

function readCache(currency: CurrencyCode, requestedDate: string) {
  const key = cacheKey(currency, requestedDate);
  const cached = rateCache.get(key);
  if (!cached) return null;
  if (cached.expiresAt <= Date.now()) {
    rateCache.delete(key);
    return null;
  }
  rateCache.delete(key);
  rateCache.set(key, cached);
  return cached;
}

async function databaseRates(currency: CurrencyCode, requestedDate: string) {
  const result = await pool.query<{
    currency: CurrencyCode;
    requestedDate: string;
    effectiveDate: string;
    cnyPerUnit: string;
    exact: boolean;
  }>(
    `SELECT currency, rate_date::text AS "requestedDate",
            provider_rate_date::text AS "effectiveDate",
            cny_per_unit::text AS "cnyPerUnit",
            rate_date = $2::date AS exact
       FROM exchange_rates
      WHERE currency = $1
      ORDER BY
        (rate_date = $2::date) DESC,
        (rate_date <= $2::date) DESC,
        CASE WHEN rate_date <= $2::date THEN $2::date - rate_date ELSE rate_date - $2::date END
      LIMIT 1`,
    [currency, requestedDate],
  );
  const row = result.rows[0];
  if (!row) return null;
  return {
    currency: row.currency,
    requestedDate: toDateString(row.requestedDate),
    effectiveDate: toDateString(row.effectiveDate),
    cnyPerUnit: row.cnyPerUnit,
    exact: row.exact,
  };
}

async function fetchProviderRate(currency: CurrencyCode, requestedDate: string): Promise<StoredRate> {
  const url = new URL(`/v2/rate/${currency}/CNY`, API_BASE_URL);
  url.searchParams.set("date", requestedDate);
  url.searchParams.set("providers", PROVIDER);
  const response = await fetch(url, { signal: AbortSignal.timeout(4_000) });
  if (!response.ok) throw new Error(`Frankfurter returned HTTP ${response.status}`);
  const value = await response.json() as Partial<{ date: string; base: string; quote: string; rate: number }>;
  if (
    value.base !== currency
    || value.quote !== "CNY"
    || typeof value.date !== "string"
    || !/^\d{4}-\d{2}-\d{2}$/.test(value.date)
    || typeof value.rate !== "number"
    || !Number.isFinite(value.rate)
    || value.rate <= 0
  ) {
    throw new Error("Frankfurter returned an invalid rate");
  }
  return {
    currency,
    requestedDate,
    effectiveDate: value.date,
    cnyPerUnit: String(value.rate),
  };
}

async function persistRate(rate: StoredRate) {
  await pool.query(
    `INSERT INTO exchange_rates (
       currency, rate_date, cny_per_unit, provider, provider_rate_date, fetched_at
     ) VALUES ($1, $2, $3, $4, $5, now())
     ON CONFLICT (currency, rate_date) DO UPDATE SET
       cny_per_unit = EXCLUDED.cny_per_unit,
       provider = EXCLUDED.provider,
       provider_rate_date = EXCLUDED.provider_rate_date,
       fetched_at = EXCLUDED.fetched_at,
       updated_at = now()`,
    [rate.currency, rate.requestedDate, rate.cnyPerUnit, PROVIDER, rate.effectiveDate],
  );
}

async function resolveUncached(currency: CurrencyCode, requestedDate: string): Promise<ResolvedRate> {
  const stored = await databaseRates(currency, requestedDate);
  if (stored?.exact) {
    return {
      currency,
      requestedDate,
      effectiveDate: stored.effectiveDate,
      cnyPerUnit: stored.cnyPerUnit,
      status: "exact",
    };
  }
  try {
    const providerRate = await fetchProviderRate(currency, requestedDate);
    await persistRate(providerRate);
    return {
      currency,
      requestedDate,
      effectiveDate: providerRate.effectiveDate,
      cnyPerUnit: providerRate.cnyPerUnit,
      status: "exact",
    };
  } catch (error) {
    if (stored) {
      return {
        currency,
        requestedDate,
        effectiveDate: stored.effectiveDate,
        cnyPerUnit: stored.cnyPerUnit,
        status: "fallback",
      };
    }
    console.error("Failed to resolve exchange rate", { currency, requestedDate, error });
    return { currency, requestedDate, effectiveDate: null, cnyPerUnit: null, status: "pending" };
  }
}

export async function resolveExchangeRate(currency: CurrencyCode, requestedDate: string): Promise<ResolvedRate> {
  if (currency === "CNY") {
    return { currency, requestedDate, effectiveDate: requestedDate, cnyPerUnit: "1", status: "exact" };
  }
  const cached = readCache(currency, requestedDate);
  if (cached) return cached;
  const key = cacheKey(currency, requestedDate);
  const active = inFlight.get(key);
  if (active) return active;
  const request = resolveUncached(currency, requestedDate).then((result) => {
    cacheResult(result);
    return result;
  }).finally(() => inFlight.delete(key));
  inFlight.set(key, request);
  return request;
}

export async function enqueueConversion(client: PoolClient, entryId: string, targetVersion: number) {
  await client.query(
    `INSERT INTO entry_conversion_jobs (entry_id, target_version)
     VALUES ($1, $2)
     ON CONFLICT (entry_id) DO UPDATE SET
       target_version = EXCLUDED.target_version,
       attempts = 0,
       available_at = now(),
       locked_at = NULL,
       lock_token = NULL,
       last_error = NULL,
       updated_at = now()`,
    [entryId, targetVersion],
  );
}

async function claimJob() {
  const lockToken = randomUUID();
  const result = await pool.query<{ entryId: string; targetVersion: number; attempts: number }>(
    `UPDATE entry_conversion_jobs
        SET locked_at = now(), lock_token = $1, updated_at = now()
      WHERE entry_id = (
        SELECT entry_id FROM entry_conversion_jobs
         WHERE available_at <= now()
           AND (locked_at IS NULL OR locked_at < now() - interval '2 minutes')
         ORDER BY available_at, created_at
         FOR UPDATE SKIP LOCKED
         LIMIT 1
      )
      RETURNING entry_id AS "entryId", target_version AS "targetVersion", attempts`,
    [lockToken],
  );
  return result.rows[0] ? { ...result.rows[0], lockToken } : null;
}

async function completeJob(job: Awaited<ReturnType<typeof claimJob>>) {
  if (!job) return;
  const entryResult = await pool.query<{
    id: string;
    amount: number;
    currency: CurrencyCode;
    exchangeRateDate: string;
    version: number;
    deletedAt: Date | null;
  }>(
    `SELECT id, amount, currency, exchange_rate_date::text AS "exchangeRateDate",
            version, deleted_at AS "deletedAt"
       FROM entries WHERE id = $1`,
    [job.entryId],
  );
  const entry = entryResult.rows[0];
  if (!entry || entry.deletedAt) {
    await pool.query("DELETE FROM entry_conversion_jobs WHERE entry_id = $1 AND lock_token = $2", [job.entryId, job.lockToken]);
    return;
  }
  if (entry.version !== job.targetVersion) {
    await pool.query(
      `UPDATE entry_conversion_jobs SET target_version = $1, attempts = 0,
              available_at = now(), locked_at = NULL, lock_token = NULL, updated_at = now()
        WHERE entry_id = $2 AND lock_token = $3`,
      [entry.version, entry.id, job.lockToken],
    );
    return;
  }

  const requestedDate = toDateString(entry.exchangeRateDate);
  try {
    const rate = await resolveExchangeRate(entry.currency, requestedDate);
    if (!rate.cnyPerUnit || !rate.effectiveDate || rate.status === "pending") {
      throw new Error("No exchange rate is available");
    }
    const baseAmount = calculateCnyAmount(entry.amount, entry.currency, rate.cnyPerUnit);
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const updated = await client.query(
        `UPDATE entries SET
           base_currency = 'CNY',
           base_amount = $1,
           exchange_rate = $2,
           exchange_rate_effective_date = $3,
           exchange_rate_source = 'system',
           conversion_status = $4,
           server_updated_at = now()
         WHERE id = $5 AND version = $6`,
        [baseAmount, rate.cnyPerUnit, rate.effectiveDate, rate.status, entry.id, entry.version],
      );
      if (!updated.rowCount) {
        await client.query(
          `UPDATE entry_conversion_jobs SET locked_at = NULL, lock_token = NULL,
                  available_at = now(), updated_at = now()
            WHERE entry_id = $1 AND lock_token = $2`,
          [entry.id, job.lockToken],
        );
      } else if (rate.status === "exact") {
        await client.query(
          "DELETE FROM entry_conversion_jobs WHERE entry_id = $1 AND lock_token = $2",
          [entry.id, job.lockToken],
        );
      } else {
        await client.query(
          `UPDATE entry_conversion_jobs SET attempts = attempts + 1,
                  available_at = now() + interval '1 hour',
                  locked_at = NULL, lock_token = NULL, last_error = NULL, updated_at = now()
            WHERE entry_id = $1 AND lock_token = $2`,
          [entry.id, job.lockToken],
        );
      }
      await client.query("COMMIT");
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  } catch (error) {
    const delayMinutes = Math.min(60, 2 ** Math.min(job.attempts, 5));
    await pool.query(
      `UPDATE entry_conversion_jobs SET attempts = attempts + 1,
              available_at = now() + ($1 * interval '1 minute'),
              locked_at = NULL, lock_token = NULL, last_error = $2, updated_at = now()
        WHERE entry_id = $3 AND lock_token = $4`,
      [delayMinutes, error instanceof Error ? error.message.slice(0, 500) : "Unknown conversion error", entry.id, job.lockToken],
    );
  }
}

async function runWorker() {
  if (workerRunning) return;
  workerRunning = true;
  try {
    for (let count = 0; count < 10; count += 1) {
      const job = await claimJob();
      if (!job) break;
      await completeJob(job);
    }
  } finally {
    workerRunning = false;
  }
}

export function startExchangeRateWorker() {
  if (workerTimer) return;
  void runWorker();
  workerTimer = setInterval(() => void runWorker(), WORKER_INTERVAL_MS);
  workerTimer.unref();
}

function toDateString(value: string) {
  return value.slice(0, 10);
}

export function isCurrencyCode(value: unknown): value is CurrencyCode {
  return typeof value === "string" && Object.hasOwn(currencies, value);
}
