import { edgeCache } from "./edge-cache.ts";
import type { FeedStatus } from "./feed-config.ts";
import { DEFAULT_CHAIN, getChain, type ChainId } from "./chains.ts";
export type TokenCategory = "stable" | "eth" | "btc" | "equity" | "other";

export type LiquidityNode = {
  id: string;
  symbol: string;
  name: string;
  category: TokenCategory;
  volume5m: number;
  volume1h: number;
  volume24h: number;
  liquidity: number;
  transactions24h: number;
  priceUsd: number | null;
  degree: number;
};

export type LiquidityEdge = {
  id: string;
  source: string;
  target: string;
  volume5m: number;
  volume1h: number;
  volume24h: number;
  liquidity: number;
  transactions24h: number;
  poolCount: number;
  venues: string[];
  feeTiers: string[];
  addresses: string[];
};

export type VenueSummary = {
  id: string;
  name: string;
  volume24h: number;
  liquidity: number;
  poolCount: number;
};

export type LiquidityPayload = {
  chainId?: ChainId;
  feed?: FeedStatus;
  updatedAt: string;
  stale?: boolean;
  nodes: LiquidityNode[];
  edges: LiquidityEdge[];
  venues: VenueSummary[];
  totals: {
    volume5m: number;
    volume1h: number;
    volume24h: number;
    liquidity: number;
    transactions24h: number;
    pools: number;
  };
  coverage: { scannedPools: number; indexedPools: number; pages: number };
};

type RawToken = {
  id: string;
  type: "token";
  attributes: { address: string; name: string; symbol: string };
};
type RawDex = { id: string; type: "dex"; attributes: { name: string } };
type RawPool = {
  id: string;
  attributes: {
    address: string;
    name: string;
    base_token_price_usd?: string | null;
    quote_token_price_usd?: string | null;
    reserve_in_usd?: string | null;
    volume_usd?: { m5?: string; h1?: string; h24?: string };
    transactions?: { h24?: { buys?: number; sells?: number } };
  };
  relationships: {
    base_token: { data: { id: string } };
    quote_token: { data: { id: string } };
    dex: { data: { id: string } };
  };
};
export type RawPage = { data: RawPool[]; included?: Array<RawToken | RawDex>; observedAt?: string };
type MutableNode = Omit<LiquidityNode, "degree"> & { neighbors: Set<string> };
type MutableEdge = Omit<LiquidityEdge, "venues" | "feeTiers" | "addresses"> & {
  venues: Set<string>;
  feeTiers: Set<string>;
  addresses: Set<string>;
};

const memoryCaches = new Map<ChainId, { expiresAt: number; payload: LiquidityPayload }>();
const activeRequests = new Map<ChainId, Promise<LiquidityPayload>>();
const API_ROOT = "https://api.geckoterminal.com/api/v2";
const PAGE_COUNT = DEFAULT_CHAIN.poolPages;
const CACHE_MS = 55_000;
const FETCH_BATCH_SIZE = 1;
// Successful pages outlive a normal rate-limit cooldown. Their original observation
// timestamps remain attached, so reusing a page never manufactures freshness.
const PAGE_CACHE_MS = 150_000;
const pageCache = new Map<string, RawPage>();
let retryAfter = 0;

export class UpstreamBackoff extends Error {
  retryAt: number;
  constructor(message: string, retryAt: number) {
    super(message);
    this.retryAt = retryAt;
  }
}

export function retryTime(header: string | null, now = Date.now()): number {
  const seconds = header === null ? NaN : Number(header);
  const requested = Number.isFinite(seconds) ? now + seconds * 1000 : Date.parse(header ?? "");
  return Math.max(now + 60_000, Number.isFinite(requested) ? requested : 0);
}

async function pause(ms: number, signal?: AbortSignal) {
  signal?.throwIfAborted();
  await new Promise<void>((resolve, reject) => {
    const abort = () => { clearTimeout(timer); reject(signal?.reason); };
    const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, ms);
    signal?.addEventListener("abort", abort, { once: true });
  });
}

function number(value: string | number | null | undefined) {
  const parsed = Number(value ?? 0);
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
}
function cleanText(value: string) {
  return value.replace(/&amp;/g, "&").replace(/\s*•\s*Robinhood Token\s*/i, "").trim();
}
function categoryFor(symbol: string, name: string): TokenCategory {
  const upper = symbol.toUpperCase();
  if (/^(USDG|USDC|USDT|DAI|USDE|USD1|USDS|PYUSD|SUSDE|EURC|EUROC|CRVUSD|GHO|RLUSD|FRXUSD|SDAI|SUSDS|AUSD|FXUSD)$/.test(upper)) return "stable";
  if (/^(ETH|WETH|WSTETH|STETH|CBETH|RETH|WEETH|EETH|SFRXETH|FRXETH)$/.test(upper)) return "eth";
  if (/^(BTC|WBTC|CBBTC|TBTC)$/.test(upper)) return "btc";
  if (/Robinhood Token/i.test(name) || /^(SPY|QQQ|IWM|DIA)$/.test(upper)) return "equity";
  return "other";
}
function venueFor(dexId: string, dexMap: Map<string, RawDex>) {
  if (/^uniswap(?:[-_]v\d)(?:[-_].*)?$/i.test(dexId)) return "Uniswap";
  if (/^aerodrome(?:-|$)/i.test(dexId)) return "Aerodrome";
  if (/^pons-(?:v2-dex|dot-family)$/i.test(dexId)) return "Pons";
  const name = dexMap.get(dexId)?.attributes.name ?? dexId;
  return name
    .replace(/\s*\((?:Robinhood|Ethereum|Base)\)\s*/i, "")
    .replace(/\s+V\d(?:\s+Dex)?$/i, "")
    .trim();
}
function extractFee(name: string) {
  const match = name.match(/(\d+(?:\.\d+)?)%\s*$/);
  return match ? `${match[1]}%` : null;
}

async function fetchPage(page: number, signal?: AbortSignal, chainId: ChainId = DEFAULT_CHAIN.id): Promise<RawPage> {
  signal?.throwIfAborted();
  const chain = getChain(chainId)!;
  const query = new URLSearchParams({
    include: "base_token,quote_token,dex",
    page: String(page),
    sort: chain.poolSort,
  });
  const url = `${API_ROOT}/networks/${chain.network}/pools?${query}`;
  const cache = await edgeCache();
  const key = new Request(url);
  const cooldownKey = new Request(`${API_ROOT}/__robinhood_cooldown`);
  let upstreamRetryAt = retryAfter;
  try {
    const cachedCooldown = await cache?.match(cooldownKey);
    if (cachedCooldown) upstreamRetryAt = Math.max(upstreamRetryAt, Number(await cachedCooldown.text()) || 0);
  } catch { /* An unavailable edge cache still has an in-isolate cooldown. */ }
  if (Date.now() < upstreamRetryAt) throw new UpstreamBackoff("Upstream cooldown", upstreamRetryAt);
  const validPage = (value: RawPage | undefined) => value && Array.isArray(value.data) && value.data.length && Array.isArray(value.included) &&
    typeof value.observedAt === "string" && Date.now() - Date.parse(value.observedAt) < PAGE_CACHE_MS;
  const memory = pageCache.get(url);
  if (validPage(memory)) return memory!;
  try {
    const hit = await cache?.match(key);
    const value = hit ? await hit.json() as RawPage : undefined;
    if (validPage(value)) { pageCache.set(url, value!); return value!; }
  } catch { /* A page cache failure falls through to the upstream. */ }
  const response = await fetch(url, {
    headers: { accept: "application/json" },
    signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8_000)]) : AbortSignal.timeout(5_000),
  });
  if (response.status === 429 || response.status === 503) {
    retryAfter = retryTime(response.headers.get("retry-after"));
    try {
      await cache?.put(cooldownKey, new Response(String(retryAfter), {
        headers: { "Cache-Control": `public, max-age=${Math.ceil((retryAfter - Date.now()) / 1000)}` },
      }));
    } catch { /* The in-isolate cooldown still prevents repeated requests. */ }
    throw new UpstreamBackoff(`GeckoTerminal page ${page}: HTTP ${response.status}`, retryAfter);
  }
  if (!response.ok) throw new Error(`GeckoTerminal page ${page}: HTTP ${response.status}`);
  const result = await response.json() as RawPage;
  if (!result || !Array.isArray(result.data) || !result.data.length || !Array.isArray(result.included)) {
    throw new Error(`GeckoTerminal page ${page}: invalid JSON schema`);
  }
  result.observedAt = new Date().toISOString();
  pageCache.set(url, result);
  try { await cache?.put(key, Response.json(result, { headers: { "Cache-Control": "public, max-age=180" } })); }
  catch { /* Reuse partial successes in memory if edge caching is unavailable. */ }
  return result;
}

export function isLiquidityPayload(value: unknown): value is LiquidityPayload {
  if (!value || typeof value !== "object") return false;
  const candidate = value as Partial<LiquidityPayload>;
  if (candidate.chainId !== undefined && !getChain(candidate.chainId)) return false;
  const nonnegative = (n: unknown) => typeof n === "number" && Number.isFinite(n) && n >= 0;
  const text = (s: unknown) => typeof s === "string" && s.length > 0 && s.length <= 500;
  const weights = (item: Record<string, unknown>) => ["volume5m", "volume1h", "volume24h", "liquidity", "transactions24h"].every(key => nonnegative(item[key]));
  if (typeof candidate.updatedAt !== "string" || !Number.isFinite(Date.parse(candidate.updatedAt)) ||
    Date.parse(candidate.updatedAt) > Date.now() + 60_000 ||
    (candidate.stale !== undefined && typeof candidate.stale !== "boolean") ||
    !Array.isArray(candidate.nodes) || !candidate.nodes.length || candidate.nodes.length > 500 ||
    !Array.isArray(candidate.edges) || !candidate.edges.length || candidate.edges.length > 1000 ||
    !Array.isArray(candidate.venues) || !candidate.venues.length || !candidate.totals || !candidate.coverage) return false;
  if (!candidate.nodes.every(n => n && text(n.id) && text(n.symbol) && text(n.name) &&
    ["stable", "eth", "btc", "equity", "other"].includes(n.category) && weights(n) &&
    nonnegative(n.degree) && (n.priceUsd === null || nonnegative(n.priceUsd)))) return false;
  const ids = new Set(candidate.nodes.map(n => n.id));
  if (ids.size !== candidate.nodes.length) return false;
  if (!candidate.edges.every(e => e && text(e.id) && ids.has(e.source) && ids.has(e.target) && e.source !== e.target && weights(e) &&
    Number.isInteger(e.poolCount) && e.poolCount > 0 &&
    [e.venues, e.feeTiers, e.addresses].every(a => Array.isArray(a) && a.every(text)))) return false;
  if (new Set(candidate.edges.map(e => e.id)).size !== candidate.edges.length) return false;
  if (!candidate.venues.every(v => v && text(v.id) && text(v.name) && nonnegative(v.volume24h) && nonnegative(v.liquidity) && nonnegative(v.poolCount))) return false;
  return weights(candidate.totals) && nonnegative(candidate.totals.pools) &&
    [candidate.coverage.scannedPools, candidate.coverage.indexedPools, candidate.coverage.pages].every(nonnegative) &&
    candidate.edges.reduce((sum, e) => sum + e.poolCount, 0) === candidate.totals.pools;
}

export async function fetchLiquidityDataDirect(signal?: AbortSignal, chainId: ChainId = DEFAULT_CHAIN.id): Promise<LiquidityPayload> {
  if (!getChain(chainId)) throw new Error("Unsupported chain");
  const pages: RawPage[] = [];
  for (let start = 1; start <= PAGE_COUNT; start += FETCH_BATCH_SIZE) {
    const batch = await Promise.allSettled(
      Array.from({ length: Math.min(FETCH_BATCH_SIZE, PAGE_COUNT - start + 1) }, (_, index) => fetchPage(start + index, signal, chainId)),
    );
    for (const result of batch) if (result.status === "fulfilled") pages.push(result.value);
    const failed = batch.find(result => result.status === "rejected");
    if (failed?.status === "rejected") throw failed.reason;
    if (start + FETCH_BATCH_SIZE <= PAGE_COUNT) {
      await pause(6_250, signal);
    }
  }
  if (pages.length < PAGE_COUNT) throw new Error("Liquidity feed returned an incomplete snapshot");
  return aggregatePoolPages(pages, new Date(Math.min(...pages.map(p => Date.parse(p.observedAt!)))).toISOString(), chainId);
}

/** Aggregate distinct pools once. Token identity is its address, never its ticker. */
export function aggregatePoolPages(pages: RawPage[], updatedAt = new Date().toISOString(), chainId: ChainId = DEFAULT_CHAIN.id): LiquidityPayload {
  const tokenMap = new Map<string, RawToken>();
  const dexMap = new Map<string, RawDex>();
  for (const page of pages) {
    for (const item of page.included ?? []) {
      if (item.type === "token") tokenMap.set(item.id, item);
      if (item.type === "dex") dexMap.set(item.id, item);
    }
  }
  const rawPools = pages.flatMap((page) => page.data);
  // Rankings can move between pagination requests. Deduplicate before computing totals.
  const pools = [...new Map(rawPools.map(pool => [pool.id, pool])).values()];
  const nodes = new Map<string, MutableNode>();
  const edges = new Map<string, MutableEdge>();
  const venueBuckets = new Map<string, VenueSummary>();

  function upsertNode(tokenId: string, pool: RawPool, side: "base" | "quote") {
    const token = tokenMap.get(tokenId);
    // Fluid uses 0xeeee… for the same native asset represented by 0x0000… elsewhere.
    if (tokenId.toLowerCase() === `${getChain(chainId)!.network}_0x${"e".repeat(40)}`) tokenId = `${getChain(chainId)!.network}_0x${"0".repeat(40)}`;
    const fallback = pool.attributes.name.split(" / ")[side === "base" ? 0 : 1]?.split(" ")[0] || "?";
    const native = tokenId === `${getChain(chainId)!.network}_0x${"0".repeat(40)}`;
    const symbol = native ? getChain(chainId)!.nativeSymbol : cleanText(token?.attributes.symbol || fallback).toUpperCase();
    const rawName = native ? "Ether (native)" : token?.attributes.name || symbol;
    const price = number(side === "base" ? pool.attributes.base_token_price_usd : pool.attributes.quote_token_price_usd);
    const existing = nodes.get(tokenId);
    if (existing) {
      if (!existing.priceUsd && price) existing.priceUsd = price;
      if (rawName.includes("Robinhood Token")) {
        existing.name = cleanText(rawName);
        existing.category = "equity";
      }
      return existing;
    }
    const created: MutableNode = {
      id: tokenId,
      symbol,
      name: cleanText(rawName),
      category: categoryFor(symbol, rawName),
      volume5m: 0,
      volume1h: 0,
      volume24h: 0,
      liquidity: 0,
      transactions24h: 0,
      priceUsd: price || null,
      neighbors: new Set<string>(),
    };
    nodes.set(tokenId, created);
    return created;
  }

  for (const pool of pools) {
    if (!pool?.attributes || !pool.relationships?.base_token?.data?.id || !pool.relationships?.quote_token?.data?.id || !pool.relationships?.dex?.data?.id) {
      throw new Error("Incomplete pool record");
    }
    const base = upsertNode(pool.relationships.base_token.data.id, pool, "base");
    const quote = upsertNode(pool.relationships.quote_token.data.id, pool, "quote");
    if (base.id === quote.id) continue;
    const [source, target] = [base.id, quote.id].sort();
    const key = `${source}::${target}`;
    const volume5m = number(pool.attributes.volume_usd?.m5);
    const volume1h = number(pool.attributes.volume_usd?.h1);
    const volume24h = number(pool.attributes.volume_usd?.h24);
    const liquidity = number(pool.attributes.reserve_in_usd);
    const transactions24h = number(pool.attributes.transactions?.h24?.buys) + number(pool.attributes.transactions?.h24?.sells);
    const fee = extractFee(pool.attributes.name);
    const dexId = pool.relationships.dex.data.id;
    const venueName = venueFor(dexId, dexMap);
    const venue = venueBuckets.get(venueName) ?? {
      id: venueName.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
      name: venueName,
      volume24h: 0,
      liquidity: 0,
      poolCount: 0,
    };
    venue.volume24h += volume24h;
    venue.liquidity += liquidity;
    venue.poolCount += 1;
    venueBuckets.set(venueName, venue);
    let edge = edges.get(key);
    if (!edge) {
      edge = {
        id: key, source, target, volume5m: 0, volume1h: 0, volume24h: 0,
        liquidity: 0, transactions24h: 0, poolCount: 0,
        venues: new Set<string>(), feeTiers: new Set<string>(), addresses: new Set<string>(),
      };
      edges.set(key, edge);
    }
    edge.volume5m += volume5m;
    edge.volume1h += volume1h;
    edge.volume24h += volume24h;
    edge.liquidity += liquidity;
    edge.transactions24h += transactions24h;
    edge.poolCount += 1;
    edge.venues.add(venueName);
    if (fee) edge.feeTiers.add(fee);
    edge.addresses.add(pool.attributes.address);
    for (const node of [base, quote]) {
      node.volume5m += volume5m;
      node.volume1h += volume1h;
      node.volume24h += volume24h;
      node.liquidity += liquidity;
      node.transactions24h += transactions24h;
    }
    base.neighbors.add(quote.id);
    quote.neighbors.add(base.id);
  }

  const finalEdges = [...edges.values()].map((edge) => ({
    ...edge,
    venues: [...edge.venues].sort(),
    feeTiers: [...edge.feeTiers].sort(),
    addresses: [...edge.addresses],
  })).sort((a, b) => b.volume24h - a.volume24h);
  const finalNodes = [...nodes.values()].map(({ neighbors, ...node }) => ({
    ...node,
    degree: neighbors.size,
  })).sort((a, b) => b.volume24h - a.volume24h);
  const finalVenues = [...venueBuckets.values()].sort((a, b) => b.volume24h - a.volume24h);
  return {
    chainId,
    updatedAt,
    nodes: finalNodes,
    edges: finalEdges,
    venues: finalVenues,
    totals: {
      volume5m: finalEdges.reduce((sum, edge) => sum + edge.volume5m, 0),
      volume1h: finalEdges.reduce((sum, edge) => sum + edge.volume1h, 0),
      volume24h: finalEdges.reduce((sum, edge) => sum + edge.volume24h, 0),
      liquidity: finalEdges.reduce((sum, edge) => sum + edge.liquidity, 0),
      transactions24h: finalEdges.reduce((sum, edge) => sum + edge.transactions24h, 0),
      pools: finalEdges.reduce((sum, edge) => sum + edge.poolCount, 0),
    },
    coverage: { scannedPools: rawPools.length, indexedPools: finalEdges.reduce((sum, edge) => sum + edge.poolCount, 0), pages: pages.length },
  };
}

export async function getLiquidityData(chainId: ChainId = DEFAULT_CHAIN.id): Promise<LiquidityPayload> {
  const memoryCache = memoryCaches.get(chainId), activeRequest = activeRequests.get(chainId);
  if (memoryCache && Date.now() < memoryCache.expiresAt) return memoryCache.payload;
  if (activeRequest) return activeRequest;
  if (Date.now() < retryAfter) {
    if (memoryCache) return { ...memoryCache.payload, stale: true };
    throw new Error("Upstream cooldown");
  }
  const pending = fetchLiquidityDataDirect(undefined, chainId)
    .then((payload) => {
      if (!isLiquidityPayload(payload)) throw new Error("Invalid aggregated snapshot");
      memoryCaches.set(chainId, { expiresAt: Math.min(Date.now() + CACHE_MS, Date.parse(payload.updatedAt) + CACHE_MS), payload });
      return payload;
    })
    .catch((error) => {
      retryAfter = Math.max(retryAfter, Date.now() + 60_000);
      console.warn("Liquidity refresh deferred:", error instanceof Error ? error.message : "upstream failure");
      if (memoryCache) return { ...memoryCache.payload, stale: true };
      throw error;
    })
    .finally(() => { activeRequests.delete(chainId); });
  activeRequests.set(chainId, pending);
  return pending;
}
