// Edge function — fetch OpenGraph/meta tags for a URL and cache the result.
//
// POST /og-preview { "url": "..." }
//
// Returns: { url, title, description, imageUrl, siteName, ok }
//
// Caching: rows live in public.link_previews keyed by URL. Repeat calls
// return the cached row without re-fetching (unless older than MAX_CACHE_AGE_MS).

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const MAX_CACHE_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
const FETCH_TIMEOUT_MS = 6_000;
const MAX_HTML_BYTES = 1_000_000;

const corsHeaders = {
  'access-control-allow-origin': '*',
  'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
  'access-control-allow-methods': 'POST, OPTIONS',
};

function json(body: unknown, status = 200) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json', ...corsHeaders },
  });
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders });
  if (req.method !== 'POST') return json({ error: 'method' }, 405);

  let body: { url?: string };
  try {
    body = await req.json();
  } catch {
    return json({ error: 'bad json' }, 400);
  }
  const url = body.url;
  if (!url || typeof url !== 'string') return json({ error: 'missing url' }, 400);

  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    return json({ error: 'invalid url' }, 400);
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return json({ error: 'unsupported scheme' }, 400);
  }

  const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);

  // Cache lookup.
  const { data: cached } = await client
    .from('link_previews')
    .select('*')
    .eq('url', url)
    .maybeSingle();
  if (
    cached &&
    Date.now() - new Date(cached.fetched_at).getTime() < MAX_CACHE_AGE_MS
  ) {
    return json(toClientShape(cached));
  }

  // Fetch HTML with timeout.
  let html = '';
  let ok = true;
  let error: string | null = null;
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
    const res = await fetch(url, {
      signal: ctrl.signal,
      headers: {
        'user-agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
        accept: 'text/html,application/xhtml+xml',
      },
      redirect: 'follow',
    });
    clearTimeout(timer);
    if (!res.ok) throw new Error('status ' + res.status);
    const ct = res.headers.get('content-type') ?? '';
    if (!ct.includes('html')) throw new Error('not html');
    const reader = res.body?.getReader();
    if (!reader) throw new Error('no body');
    const decoder = new TextDecoder();
    let total = 0;
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      if (!value) continue;
      total += value.byteLength;
      html += decoder.decode(value, { stream: true });
      if (total >= MAX_HTML_BYTES) {
        await reader.cancel();
        break;
      }
    }
  } catch (err) {
    ok = false;
    error = err instanceof Error ? err.message : 'fetch failed';
  }

  const meta = ok ? parseMeta(html, parsed) : null;

  // Upsert cache row so even failures cache briefly.
  const row = {
    url,
    title: meta?.title ?? null,
    description: meta?.description ?? null,
    image_url: meta?.imageUrl ?? null,
    site_name: meta?.siteName ?? null,
    ok,
    error,
    fetched_at: new Date().toISOString(),
  };
  await client.from('link_previews').upsert(row);

  return json(toClientShape(row));
});

interface MetaRow {
  url: string;
  title: string | null;
  description: string | null;
  image_url: string | null;
  site_name: string | null;
  ok: boolean;
}

function toClientShape(row: MetaRow) {
  return {
    url: row.url,
    title: row.title,
    description: row.description,
    imageUrl: row.image_url,
    siteName: row.site_name,
    ok: row.ok,
  };
}

function parseMeta(html: string, baseUrl: URL): {
  title: string | null;
  description: string | null;
  imageUrl: string | null;
  siteName: string | null;
} {
  // Cheap regex-based parser — no DOM in Deno runtime without extra deps.
  // Extracts <meta property|name="..."> values + <title>.
  const getMeta = (...keys: string[]): string | null => {
    for (const key of keys) {
      const re = new RegExp(
        '<meta[^>]+(?:property|name)=[\'"]' +
          escapeRegex(key) +
          '[\'"][^>]+content=[\'"]([^\'"]+)[\'"]',
        'i',
      );
      const m = re.exec(html);
      if (m?.[1]) return decodeHtmlEntities(m[1]);
      const re2 = new RegExp(
        '<meta[^>]+content=[\'"]([^\'"]+)[\'"][^>]+(?:property|name)=[\'"]' +
          escapeRegex(key) +
          '[\'"]',
        'i',
      );
      const m2 = re2.exec(html);
      if (m2?.[1]) return decodeHtmlEntities(m2[1]);
    }
    return null;
  };

  const title =
    getMeta('og:title', 'twitter:title') ??
    /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ??
    null;
  const description = getMeta('og:description', 'twitter:description', 'description');
  const imageRaw = getMeta('og:image', 'twitter:image', 'twitter:image:src');
  const siteName = getMeta('og:site_name') ?? baseUrl.hostname;

  let imageUrl: string | null = null;
  if (imageRaw) {
    try {
      imageUrl = new URL(imageRaw, baseUrl).toString();
    } catch {
      imageUrl = null;
    }
  }

  return {
    title: title ? decodeHtmlEntities(title) : null,
    description,
    imageUrl,
    siteName,
  };
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function decodeHtmlEntities(s: string): string {
  return s
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&#x27;/g, "'");
}
