import { chromium, type BrowserContext, type Page } from "playwright";
import { createServer } from "node:http";

type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
type ResolveRequest = { storage_state?: string; permalink?: string };

type Post = {
  permalink: string;
  author: string;
  text: string;
  track?: "top" | "recent" | "both";
  serp_rank?: number;
  published_at?: string;
  published_label?: string;
};

const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
const token = process.env.SCOUT_CRAWLER_TOKEN || "";

function isThreadsURL(value: string): boolean {
  try {
    const host = new URL(value).hostname.toLowerCase();
    return host === "threads.com" || host.endsWith(".threads.com") || host === "threads.net" || host.endsWith(".threads.net");
  } catch {
    return false;
  }
}

function normalizePermalink(href: string): string {
  const absolute = href.startsWith("http") ? href : `https://www.threads.com${href}`;
  try {
    const u = new URL(absolute);
    u.hash = "";
    u.search = "";
    u.pathname = u.pathname.replace(/\/+$/, "");
    return u.toString();
  } catch {
    return absolute.split("?")[0]!.replace(/\/+$/, "");
  }
}

/** 口語錨點：不能單獨代表主題相關 */
const ANCHORS = new Set(["求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", "有人知道", "請問"]);

/**
 * 正文須含主題核（非口語錨點）。「外包 求推薦」→ 必須出現「外包」。
 * 防搜尋頁沒載入／抓到推薦流時灌進無關貼。
 */
function textMatchesQuery(text: string, query: string): boolean {
  const body = text.replace(/\s+/g, "").toLowerCase();
  if (!body) return false;
  const q = query.replace(/\u3000/g, " ").trim();
  if (!q) return true;
  const parts = q.split(/\s+/).filter((p) => [...p].length >= 2);
  const tokens = parts.length ? parts : [...q].length >= 2 ? [q] : [];
  const content: string[] = [];
  const anchors: string[] = [];
  for (const p of tokens) {
    const t = p.toLowerCase();
    if (ANCHORS.has(t)) anchors.push(t.replace(/\s+/g, ""));
    else content.push(t.replace(/\s+/g, ""));
  }
  if (content.length > 0) {
    return content.some((c) => body.includes(c));
  }
  if (anchors.length > 0) {
    return anchors.some((a) => body.includes(a));
  }
  // 查詢過短無法斷詞時，退回要求正文含原始查詢字串，避免放行整條推薦流
  return body.includes(q.replace(/\s+/g, "").toLowerCase());
}

function parsePublishedFromCardText(text: string): { iso?: string; label?: string } {
  const raw = text.replace(/\s+/g, " ").trim();
  if (!raw) return {};
  const head = raw.slice(0, 100);
  const now = Date.now();
  const relPatterns: Array<{ re: RegExp; ms: (n: number) => number; label: (n: number) => string }> = [
    { re: /剛剛|just now/i, ms: () => 0, label: () => "剛剛" },
    { re: /(\d+)\s*(秒|s|sec)/i, ms: (n) => n * 1000, label: (n) => `${n}秒` },
    { re: /(\d+)\s*(分|分鐘|m|min)/i, ms: (n) => n * 60_000, label: (n) => `${n}分` },
    { re: /(\d+)\s*(小時|時|h|hr)/i, ms: (n) => n * 3_600_000, label: (n) => `${n}小時` },
    { re: /(\d+)\s*(天|日|d|day)/i, ms: (n) => n * 86_400_000, label: (n) => `${n}天` },
    { re: /(\d+)\s*(週|周|w|week)/i, ms: (n) => n * 7 * 86_400_000, label: (n) => `${n}週` },
    { re: /(\d+)\s*(月|mo|month)/i, ms: (n) => n * 30 * 86_400_000, label: (n) => `${n}月` },
    { re: /(\d+)\s*年前/i, ms: (n) => n * 365 * 86_400_000, label: (n) => `${n}年` },
  ];
  for (const p of relPatterns) {
    const m = head.match(p.re);
    if (!m) continue;
    const n = m[1] ? Number(m[1]) : 0;
    if (!Number.isFinite(n) && !/剛剛|just now/i.test(m[0])) continue;
    return { iso: new Date(now - p.ms(Number.isFinite(n) ? n : 0)).toISOString(), label: p.label(Number.isFinite(n) ? n : 0) };
  }
  return {};
}

/**
 * 用 page.evaluate 抽 SERP 貼文：比 locator 祖先更穩，並用 query 過濾無關卡。
 * 只掃 document 出現順序中的 /post/ 連結，略過明顯非結果區（nav 等難以 100% 排除時靠 query 過濾）。
 */
async function readPosts(page: Page, query: string, limit: number): Promise<Post[]> {
  type Raw = { href: string; author: string; text: string };
  const raws = await page.evaluate((maxScan: number) => {
    const out: Raw[] = [];
    const seen = new Set<string>();
    const anchors = Array.from(document.querySelectorAll('a[href*="/post/"]')) as HTMLAnchorElement[];
    for (const a of anchors) {
      if (out.length >= maxScan) break;
      const href = a.getAttribute("href") || "";
      if (!href.includes("/post/")) continue;
      // 略過 nav / header 內連結
      if (a.closest("nav, header, [role='navigation']")) continue;
      const key = href.split("?")[0] || href;
      if (seen.has(key)) continue;
      seen.add(key);

      // 找小範圍卡片：往上最多 8 層，取文字長度 20–800 的最近祖先
      let el: HTMLElement | null = a;
      let best = "";
      for (let depth = 0; depth < 8 && el; depth++) {
        const t = (el.innerText || "").replace(/\s+/g, " ").trim();
        if (t.length >= 20 && t.length <= 1200) {
          best = t;
          // 再往上若突然暴衝（整欄 feed）就停在 best
          const parent = el.parentElement;
          if (parent) {
            const pt = (parent.innerText || "").replace(/\s+/g, " ").trim();
            if (pt.length > t.length * 3 && pt.length > 1500) break;
          }
        }
        el = el.parentElement;
      }
      if (best.length < 12) {
        best = (a.innerText || "").replace(/\s+/g, " ").trim();
      }
      if (best.length < 8) continue;
      const author = href.match(/@([^/]+)\/post/)?.[1] || "";
      out.push({ href, author, text: best.slice(0, 2000) });
    }
    return out;
  }, Math.min(limit * 4, 80));

  const posts: Post[] = [];
  let rank = 0;
  for (const r of raws) {
    if (posts.length >= limit) break;
    const permalink = normalizePermalink(r.href);
    if (!isThreadsURL(permalink)) continue;
    if (!textMatchesQuery(r.text, query)) continue;
    const { iso, label } = parsePublishedFromCardText(r.text);
    rank += 1;
    posts.push({
      permalink,
      author: r.author,
      text: r.text,
      serp_rank: rank,
      published_at: iso,
      published_label: label,
    });
  }
  return posts;
}

function searchURL(query: string, track: "top" | "recent"): string {
  const q = encodeURIComponent(query);
  // Top = default；Recent = filter=recent（與官方 web 一致）
  if (track === "recent") {
    return `https://www.threads.com/search?q=${q}&serp_type=default&filter=recent`;
  }
  return `https://www.threads.com/search?q=${q}&serp_type=default`;
}

async function searchTrack(page: Page, query: string, track: "top" | "recent", limit: number): Promise<Post[]> {
  const url = searchURL(query, track);
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
  // 必須仍在搜尋頁，否則 captcha／導向首頁會抓到無關貼文
  const landed = page.url();
  if (landed.includes("/login") || !landed.includes("/search")) {
    const body = await page.locator("body").innerText().catch(() => "");
    if (body.includes("登入") || landed.includes("/login")) throw new Error("crawler session expired");
    // 不在 search：直接空結果，寧可少抓也不亂抓推薦流
    return [];
  }
  const body = await page.locator("body").innerText().catch(() => "");
  if (body.includes("登入") && body.length < 400) throw new Error("crawler session expired");

  await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
  await page.waitForTimeout(900);
  // 溫和捲動 2 次即可；過度捲動容易混進「為你推薦」
  await page.mouse.wheel(0, 1000);
  await page.waitForTimeout(700);
  await page.mouse.wheel(0, 1000);
  await page.waitForTimeout(600);

  const posts = await readPosts(page, query, limit);
  return posts.map((p) => ({ ...p, track }));
}

/** Recent 原序為主，Top 獨有補後；both 僅標記。合併後再做一次 query 相關性過濾。 */
function mergeRecentPrimary(top: Post[], recent: Post[], query: string, limit: number): Post[] {
  const topMap = new Map<string, Post>();
  for (const p of top) topMap.set(normalizePermalink(p.permalink), p);

  const out: Post[] = [];
  const seen = new Set<string>();

  for (const p of recent) {
    if (!textMatchesQuery(p.text, query)) continue;
    const key = normalizePermalink(p.permalink);
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({ ...p, track: topMap.has(key) ? "both" : "recent" });
    if (out.length >= limit) return out;
  }
  for (const p of top) {
    if (!textMatchesQuery(p.text, query)) continue;
    const key = normalizePermalink(p.permalink);
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({ ...p, track: "top" });
    if (out.length >= limit) break;
  }
  return out;
}

async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> {
  const state = JSON.parse(storageState) as { cookies?: unknown[] };
  if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");

  // 上層 fan-out 一詞一搜；若誤傳多詞只取第一組
  const query = (terms.map((t) => t.trim()).filter(Boolean)[0] || "").slice(0, 180);
  if (!query) return [];

  const browser = await chromium.launch({ headless: true });
  try {
    const context: BrowserContext = await browser.newContext({
      storageState: state,
      locale: "zh-TW",
      timezoneId: "Asia/Taipei",
      userAgent:
        "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
    });
    const perTrack = Math.min(Math.max(limit, 10), 24);
    const pageTop = await context.newPage();
    const pageRecent = await context.newPage();
    // allSettled 保留成功軌結果；失敗軌各自重試一次（session 失效重試無意義）
    const isSessionError = (r: unknown) => r instanceof Error && r.message.includes("session");
    const [topResult, recentResult] = await Promise.allSettled([
      searchTrack(pageTop, query, "top", perTrack),
      searchTrack(pageRecent, query, "recent", perTrack),
    ]);
    let top: Post[] = topResult.status === "fulfilled" ? topResult.value : [];
    let recent: Post[] = recentResult.status === "fulfilled" ? recentResult.value : [];
    const firstFailure =
      topResult.status === "rejected"
        ? topResult.reason
        : recentResult.status === "rejected"
          ? recentResult.reason
          : undefined;
    if (topResult.status === "rejected" && !isSessionError(topResult.reason)) {
      try {
        top = await searchTrack(pageTop, query, "top", perTrack);
      } catch {
        /* keep empty */
      }
    }
    if (recentResult.status === "rejected" && !isSessionError(recentResult.reason)) {
      try {
        recent = await searchTrack(pageRecent, query, "recent", perTrack);
      } catch {
        /* keep empty */
      }
    }
    if (firstFailure !== undefined && top.length === 0 && recent.length === 0) throw firstFailure;
    await pageTop.close().catch(() => undefined);
    await pageRecent.close().catch(() => undefined);
    const merged = mergeRecentPrimary(top, recent, query, limit);
    await context.close();
    return merged;
  } finally {
    await browser.close();
  }
}

function shortcodeFromPermalink(permalink: string): string {
  return permalink.match(/\/post\/([^/?]+)/)?.[1] || "";
}

function findMediaID(value: unknown, shortcode: string): string {
  if (!value || typeof value !== "object") return "";
  if (Array.isArray(value)) {
    for (const item of value) {
      const id = findMediaID(item, shortcode);
      if (id) return id;
    }
    return "";
  }
  const record = value as Record<string, unknown>;
  const code = String(record.code || record.shortcode || "");
  const id = String(record.id || record.pk || record.media_id || "");
  if (code === shortcode && /^\d{10,}$/.test(id)) return id;
  for (const child of Object.values(record)) {
    const found = findMediaID(child, shortcode);
    if (found) return found;
  }
  return "";
}

function findMediaIDInText(raw: string, shortcode: string): string {
  const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const patterns = [
    new RegExp(`(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["'][\\s\\S]{0,800}?(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})`, "i"),
    new RegExp(`(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})[\\s\\S]{0,800}?(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["']`, "i"),
  ];
  for (const pattern of patterns) {
    const match = raw.match(pattern);
    if (match?.[1]) return match[1];
  }
  return "";
}

async function resolve(storageState: string, permalink: string): Promise<string> {
  if (!isThreadsURL(permalink)) throw new Error("invalid Threads permalink");
  const state = JSON.parse(storageState) as { cookies?: unknown[] };
  if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
  const shortcode = shortcodeFromPermalink(permalink);
  if (!shortcode) throw new Error("permalink has no Threads shortcode");
  const browser = await chromium.launch({ headless: true });
  try {
    const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
    const page = await context.newPage();
    let mediaID = "";
    const responseReads: Promise<void>[] = [];
    page.on("response", async (response) => {
      if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
      responseReads.push(
        (async () => {
          try {
            const raw = await response.text();
            if (!mediaID) {
              try {
                mediaID = findMediaID(JSON.parse(raw), shortcode);
              } catch {
                /* non-JSON */
              }
            }
            if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
          } catch {
            /* ignored */
          }
        })(),
      );
    });
    await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
    const body = await page.locator("body").innerText().catch(() => "");
    if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
    await page.waitForTimeout(2500);
    await Promise.allSettled(responseReads);
    if (!mediaID) mediaID = findMediaIDInText(await page.content(), shortcode);
    await context.close();
    if (!mediaID) throw new Error("Threads media ID could not be resolved");
    return mediaID;
  } finally {
    await browser.close();
  }
}

if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");

createServer(async (req, res) => {
  const path = new URL(req.url || "/", "http://127.0.0.1").pathname;
  if (req.method !== "POST" || (path !== "/v1/threads/search" && path !== "/v1/threads/resolve")) {
    res.writeHead(404).end();
    return;
  }
  if (req.headers.authorization !== `Bearer ${token}`) {
    res.writeHead(401).end();
    return;
  }
  const body = await new Promise<string>((resolve, reject) => {
    let raw = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => {
      raw += chunk;
      if (raw.length > 300_000) req.destroy();
    });
    req.on("end", () => resolve(raw));
    req.on("error", reject);
  });
  try {
    if (path === "/v1/threads/resolve") {
      const input = JSON.parse(body) as ResolveRequest;
      const mediaID = await resolve(String(input.storage_state || ""), String(input.permalink || ""));
      res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
    } else {
      const input = JSON.parse(body) as SearchRequest;
      const posts = await search(
        String(input.storage_state || ""),
        Array.isArray(input.terms) ? input.terms.slice(0, 12) : [],
        Math.min(Math.max(Number(input.limit) || 10, 1), 30),
      );
      res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts }));
    }
  } catch (error) {
    res.writeHead(422, { "content-type": "application/json" }).end(
      JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }),
    );
  }
}).listen(port, "127.0.0.1");
