/**
 * M5 live: inspire / research / media / scout
 */
import type {
  Brand,
  BrandProduct,
  InspirationIdea,
  Job,
  InspireChatMessage,
  InspireElement,
  InspireSession,
  InspireSessionSummary,
  ResearchHit,
  ScoutHomeworkRecord,
  ScoutPost,
  ScoutRun,
  ScoutRunStatus,
  ScoutShortfallReason,
  ScoutRunBrief,
  ScoutScanContext,
  TrendItem,
  TrendKind,
} from "../../domain/types";
import type {
  InspirationRepo,
  MediaRepo,
  ResearchRepo,
  ScoutRepo,
} from "../repos";
import type { ListPage, ScoutResultListQuery, ScoutRunListQuery } from "../repos";
import { messageForApiCode } from "../../lib/apiErrors";
import { ApiError, apiRequest, getApiBase, loadTokens } from "./http";

function scoutQuery(params: Record<string, string | number | undefined>): string {
  const parts = Object.entries(params)
    .filter(([, value]) => value !== undefined && value !== "")
    .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`);
  return parts.length ? `?${parts.join("&")}` : "";
}

function mapPagination(raw: unknown, page: number, pageSize: number): ListPage<unknown>["pagination"] {
  const p = (raw as Record<string, unknown> | null | undefined) ?? {};
  const total = Number(p.total ?? 0);
  const normalizedTotal = Number.isFinite(total) && total >= 0 ? total : 0;
  const normalizedPage = Number(p.page ?? page) || page;
  const normalizedPageSize = Number(p.pageSize ?? pageSize) || pageSize;
  const totalPages = Number(p.totalPages ?? (normalizedTotal ? Math.ceil(normalizedTotal / normalizedPageSize) : 0));
  return {
    page: normalizedPage,
    pageSize: normalizedPageSize,
    total: normalizedTotal,
    totalPages: Number.isFinite(totalPages) ? totalPages : 0,
  };
}

function mapTrend(raw: Record<string, unknown>): TrendItem {
  return {
    id: String(raw.id ?? ""),
    kind: (raw.kind as TrendKind) || "threads_tag",
    label: String(raw.label ?? ""),
    summary: String(raw.summary ?? ""),
    heat: Number(raw.heat ?? 0),
    keywords: Array.isArray(raw.keywords) ? (raw.keywords as string[]) : [],
    samples: Array.isArray(raw.samples) ? (raw.samples as string[]) : [],
    source_label: String(raw.source_label ?? ""),
    observed_at: Number(raw.observed_at ?? 0),
  };
}

function mapElement(raw: Record<string, unknown>): InspireElement {
  return {
    id: String(raw.id ?? ""),
    kind: (raw.kind as InspireElement["kind"]) || "snippet",
    title: String(raw.title ?? ""),
    body: String(raw.body ?? ""),
    ref_id: raw.ref_id != null ? String(raw.ref_id) : null,
    reusable: Boolean(raw.reusable),
    created_at: Number(raw.created_at ?? 0),
    updated_at: Number(raw.updated_at ?? 0),
  };
}

function mapMessage(raw: Record<string, unknown>): InspireChatMessage {
  const draftRaw = raw.draft as Record<string, unknown> | undefined;
  return {
    id: String(raw.id ?? ""),
    role: (raw.role as InspireChatMessage["role"]) || "assistant",
    text: String(raw.text ?? ""),
    draft:
      draftRaw && draftRaw.body != null
        ? {
            title: draftRaw.title != null ? String(draftRaw.title) : undefined,
            body: String(draftRaw.body),
          }
        : undefined,
    created_at: Number(raw.created_at ?? 0),
  };
}

function mapSession(raw: Record<string, unknown>): InspireSession {
  const msgs = Array.isArray(raw.messages) ? raw.messages : [];
  return {
    id: String(raw.id ?? ""),
    title: raw.title != null ? String(raw.title) : undefined,
    messages: msgs.map((m) => mapMessage(m as Record<string, unknown>)),
    pinned_element_ids: Array.isArray(raw.pinned_element_ids)
      ? (raw.pinned_element_ids as string[])
      : [],
    created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
    updated_at: Number(raw.updated_at ?? 0),
  };
}

function mapSessionSummary(raw: Record<string, unknown>): InspireSessionSummary {
  return {
    id: String(raw.id ?? ""),
    title: String(raw.title ?? "新對話"),
    preview: String(raw.preview ?? ""),
    message_count: Number(raw.message_count ?? 0),
    updated_at: Number(raw.updated_at ?? 0),
    created_at: Number(raw.created_at ?? 0),
    active: Boolean(raw.active),
  };
}

function mapBrand(raw: Record<string, unknown>): Brand {
  return {
    id: String(raw.id ?? ""),
    display_name: String(raw.display_name ?? ""),
    brief: String(raw.brief ?? ""),
    target_audience: raw.target_audience != null ? String(raw.target_audience) : undefined,
    goals: raw.goals != null ? String(raw.goals) : undefined,
    learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined,
    learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined,
    learned_from_posts_count:
      raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined,
    last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined,
  };
}

function mapProduct(raw: Record<string, unknown>): BrandProduct {
  return {
    id: String(raw.id ?? ""),
    brand_id: String(raw.brand_id ?? ""),
    label: String(raw.label ?? ""),
    product_context: String(raw.product_context ?? ""),
    match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [],
    pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points as string[]) : [],
    provider_capability_terms: Array.isArray(raw.provider_capability_terms)
      ? (raw.provider_capability_terms as string[])
      : [],
    provider_exclude_terms: Array.isArray(raw.provider_exclude_terms)
      ? (raw.provider_exclude_terms as string[])
      : [],
    placement_url: raw.placement_url != null ? String(raw.placement_url) : undefined,
    created_at: Number(raw.created_at ?? 0),
    updated_at: Number(raw.updated_at ?? 0),
  };
}

function mapBrief(raw: Record<string, unknown>): ScoutRunBrief {
  return {
    intent: String(raw.intent ?? ""),
    mode: (raw.mode as ScoutRunBrief["mode"]) || "theme",
    brand_id: raw.brand_id != null ? String(raw.brand_id) : null,
    product_id: raw.product_id != null ? String(raw.product_id) : null,
    product_label: raw.product_label != null ? String(raw.product_label) : undefined,
    pains: Array.isArray(raw.pains) ? (raw.pains as string[]) : [],
    tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : [],
    periphery: Array.isArray(raw.periphery) ? (raw.periphery as string[]) : [],
    scan_terms: Array.isArray(raw.scan_terms) ? (raw.scan_terms as string[]) : [],
    placement_note: raw.placement_note != null ? String(raw.placement_note) : undefined,
    response_stance: raw.response_stance != null ? String(raw.response_stance) : undefined,
    theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
    theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
    product_context: raw.product_context != null ? String(raw.product_context) : undefined,
    target_count:
      raw.target_count != null && Number(raw.target_count) > 0
        ? Math.min(40, Math.floor(Number(raw.target_count)))
        : undefined,
  };
}

function briefToBody(b: ScoutRunBrief): Record<string, unknown> {
  return {
    intent: b.intent,
    mode: b.mode,
    brand_id: b.brand_id || undefined,
    product_id: b.product_id || undefined,
    product_label: b.product_label,
    pains: b.pains,
    tags: b.tags,
    periphery: b.periphery,
    scan_terms: b.scan_terms,
    placement_note: b.placement_note,
    response_stance: b.response_stance,
    theme_key: b.theme_key,
    theme_label: b.theme_label,
    product_context: b.product_context,
    target_count: b.target_count && b.target_count > 0 ? b.target_count : undefined,
  };
}

function mapScoutPost(raw: Record<string, unknown>): ScoutPost {
  return {
    id: String(raw.id ?? ""),
    run_id: raw.run_id != null ? String(raw.run_id) : undefined,
    brand_id: raw.brand_id != null ? String(raw.brand_id) : null,
    author: String(raw.author ?? ""),
    text: String(raw.text ?? ""),
    search_tag: String(raw.search_tag ?? ""),
    opportunity: String(raw.opportunity ?? ""),
    outreach_status: (raw.outreach_status as ScoutPost["outreach_status"]) || "new",
    draft_text: raw.draft_text != null ? String(raw.draft_text) : undefined,
    score: Number(raw.score ?? 0),
    matched_product_id: raw.matched_product_id != null ? String(raw.matched_product_id) : null,
    matched_product_label:
      raw.matched_product_label != null ? String(raw.matched_product_label) : null,
    match_reason: raw.match_reason != null ? String(raw.match_reason) : undefined,
    scout_mode: raw.scout_mode as ScoutPost["scout_mode"],
    intent_snippet: raw.intent_snippet != null ? String(raw.intent_snippet) : undefined,
    theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
    theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
    scan_path: raw.scan_path != null ? String(raw.scan_path) : undefined,
    classification: raw.classification != null ? String(raw.classification) : undefined,
    permalink: raw.permalink != null ? String(raw.permalink) : undefined,
    posted_at: raw.posted_at != null ? Number(raw.posted_at) : undefined,
    created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
    published_at: raw.published_at != null ? Number(raw.published_at) : undefined,
  };
}

function mapScoutRun(raw: Record<string, unknown>): ScoutRun {
  const status = raw.status as ScoutRunStatus;
  const validStatus: ScoutRunStatus[] = ["queued", "running", "succeeded", "failed", "cancelled"];
  const shortfallReasons = Array.isArray(raw.shortfall_reasons)
    ? (raw.shortfall_reasons as unknown[]).filter((v): v is ScoutShortfallReason =>
        ["source_exhausted", "duplicate_exhausted", "relevance_exhausted", "source_unavailable", "limit_reached"].includes(String(v)),
      )
    : [];
  return {
    id: String(raw.id ?? ""),
    job_id: String(raw.job_id ?? ""),
    theme_key: String(raw.theme_key ?? ""),
    theme_label: String(raw.theme_label ?? ""),
    intent: String(raw.intent ?? ""),
    mode: (raw.mode as ScoutRun["mode"]) || "activity",
    brand_id: raw.brand_id != null ? String(raw.brand_id) : null,
    target_count: Number(raw.target_count ?? 0),
    status: validStatus.includes(status) ? status : "queued",
    searched_count: Number(raw.searched_count ?? 0),
    duplicate_count: Number(raw.duplicate_count ?? 0),
    irrelevant_count: Number(raw.irrelevant_count ?? 0),
    eligible_count: Number(raw.eligible_count ?? 0),
    pending_count: Number(raw.pending_count ?? 0),
    shortfall_count: Number(raw.shortfall_count ?? 0),
    shortfall_reasons: shortfallReasons,
    created_at: Number(raw.created_at ?? 0),
    started_at: raw.started_at != null ? Number(raw.started_at) : undefined,
    completed_at: raw.completed_at != null ? Number(raw.completed_at) : undefined,
    error: raw.error != null ? String(raw.error) : undefined,
  };
}

function mapScanJob(raw: Record<string, unknown>): Job {
  return {
    id: String(raw.id ?? ""),
    template_type: String(raw.template_type ?? "scout_scan"),
    status: (raw.status as Job["status"]) || "pending",
    progress_summary: String(raw.progress_summary ?? ""),
    progress_percent: Number(raw.progress_percent ?? 0),
    error: raw.error != null ? String(raw.error) : undefined,
    ref_id: raw.ref_id != null ? String(raw.ref_id) : undefined,
    payload: raw.payload != null ? String(raw.payload) : undefined,
    run_after: raw.run_after != null ? Number(raw.run_after) : undefined,
    created_at: Number(raw.created_at ?? 0),
    updated_at: Number(raw.updated_at ?? raw.created_at ?? 0),
    completed_at: raw.completed_at != null ? Number(raw.completed_at) : undefined,
  };
}

function mapHomework(raw: Record<string, unknown>): ScoutHomeworkRecord {
  return {
    theme_key: String(raw.theme_key ?? ""),
    theme_label: String(raw.theme_label ?? ""),
    purpose: (raw.purpose as ScoutHomeworkRecord["purpose"]) || "value",
    brief: mapBrief((raw.brief as Record<string, unknown>) ?? {}),
    created_at: Number(raw.created_at ?? 0),
  };
}

export function createLiveInspiration(): InspirationRepo {
  return {
    async list() {
      return [] as InspirationIdea[];
    },
    async listTrends() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/inspire/trends");
      return (data.list ?? []).map(mapTrend);
    },
    async refreshTrends() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/inspire/trends/refresh",
        { method: "POST", body: {} },
      );
      return (data.list ?? []).map(mapTrend);
    },
    async listElements() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/inspire/elements",
      );
      return (data.list ?? []).map(mapElement);
    },
    async saveElement(el) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/elements", {
        method: "POST",
        body: {
          id: el.id,
          kind: el.kind,
          title: el.title,
          body: el.body,
          ref_id: el.ref_id,
          reusable: el.reusable,
        },
      });
      return mapElement(raw);
    },
    async removeElement(id) {
      await apiRequest(`/api/v1/inspire/elements/${encodeURIComponent(id)}`, {
        method: "DELETE",
      });
    },
    async getSession() {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/session");
      return mapSession(raw);
    },
    async clearSession() {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/session/clear", {
        method: "POST",
        body: {},
      });
      return mapSession(raw);
    },
    async listSessions() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/inspire/sessions",
      );
      return (data.list ?? []).map(mapSessionSummary);
    },
    async createSession(title) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/sessions", {
        method: "POST",
        body: title ? { title } : {},
      });
      return mapSession(raw);
    },
    async activateSession(id) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/inspire/sessions/${encodeURIComponent(id)}/activate`,
        { method: "POST", body: {} },
      );
      return mapSession(raw);
    },
    async deleteSession(id) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/inspire/sessions/${encodeURIComponent(id)}`,
        { method: "DELETE" },
      );
      return mapSession(raw);
    },
    async chat(opts) {
      const data = await apiRequest<{
        session: Record<string, unknown>;
        messages: Record<string, unknown>[];
      }>("/api/v1/inspire/chat", {
        method: "POST",
        body: {
          message: opts.message,
          pinned_ids: opts.pinnedIds,
          mode: opts.mode,
          persona_id: opts.personaId || undefined,
          session_id: opts.sessionId || undefined,
          use_web: opts.useWeb || undefined,
          material: opts.material || undefined,
        },
      });
      const session = mapSession(data.session ?? {});
      const messages = (data.messages ?? []).map(mapMessage);
      return { session, messages };
    },
    async previewPrompt(opts) {
      const data = await apiRequest<{
        prompt: string;
        mode: string;
        sections?: string[];
        blocks?: Array<{ title: string; body: string }>;
        fingerprint?: string;
        char_count?: number;
        rune_count?: number;
        pinned?: Array<{ id: string; kind: string; title: string; body: string }>;
        note?: string;
      }>("/api/v1/inspire/prompt-preview", {
        method: "POST",
        body: {
          message: opts.message,
          pinned_ids: opts.pinnedIds,
          mode: opts.mode,
          persona_id: opts.personaId || undefined,
          session_id: opts.sessionId || undefined,
          material: opts.material || undefined,
        },
      });
      return {
        prompt: String(data.prompt ?? ""),
        mode: String(data.mode ?? opts.mode),
        sections: (data.sections ?? []).map(String),
        blocks: (data.blocks ?? []).map((b) => ({
          title: String(b.title ?? ""),
          body: String(b.body ?? ""),
        })),
        fingerprint: String(data.fingerprint ?? ""),
        charCount: Number(data.char_count ?? 0),
        runeCount: Number(data.rune_count ?? 0),
        pinned: (data.pinned ?? []).map((p) => ({
          id: String(p.id ?? ""),
          kind: String(p.kind ?? ""),
          title: String(p.title ?? ""),
          body: String(p.body ?? ""),
        })),
        note: data.note ? String(data.note) : undefined,
      };
    },
    async chatStream(opts, onDelta) {
      const tok = loadTokens();
      const headers: Record<string, string> = {
        Accept: "text/event-stream",
        "Content-Type": "application/json",
      };
      if (tok?.access_token) headers.Authorization = `Bearer ${tok.access_token}`;
      const base = getApiBase();
      const url = `${base}/api/v1/inspire/chat-stream`;
      const res = await fetch(url, {
        method: "POST",
        headers,
        signal: opts.signal,
        body: JSON.stringify({
          message: opts.message,
          pinned_ids: opts.pinnedIds,
          mode: opts.mode,
          persona_id: opts.personaId || undefined,
          session_id: opts.sessionId || undefined,
          use_web: opts.useWeb || undefined,
          material: opts.material || undefined,
        }),
      });
      if (!res.ok || !res.body) {
        throw new ApiError(
          messageForApiCode(res.status >= 500 ? 500000 : 400001, `stream HTTP ${res.status}`),
          res.status,
          res.status,
        );
      }
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buf = "";
      let session: InspireSession | null = null;
      let streamError = "";
      let prompt = "";
      let fingerprint = "";
      let charCount = 0;
      let runeCount = 0;
      let sections: string[] = [];
      // 中止時 reader.read() 會 reject，這裡把它轉成一次 cancel，避免連線留著繼續燒 AI 額度。
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buf += decoder.decode(value, { stream: true });
          const parts = buf.split("\n\n");
          buf = parts.pop() ?? "";
          for (const block of parts) {
            for (const line of block.split("\n")) {
              if (!line.startsWith("data:")) continue;
              const raw = line.slice(5).trim();
              if (!raw || raw === "[DONE]") continue;
              let ev: {
                type?: string;
                text?: string;
                message?: string;
                session?: Record<string, unknown>;
                prompt?: string;
                prompt_fingerprint?: string;
                prompt_char_count?: number;
                prompt_rune_count?: number;
                prompt_sections?: string[];
              };
              try {
                ev = JSON.parse(raw) as typeof ev;
              } catch {
                continue;
              }
              if (ev.type === "delta" && ev.text) {
                onDelta(ev.text);
              } else if (ev.type === "error") {
                streamError = ev.message || "stream error";
              } else if (ev.type === "done" && ev.session) {
                session = mapSession(ev.session);
                prompt = String(ev.prompt ?? "");
                fingerprint = String(ev.prompt_fingerprint ?? "");
                charCount = Number(ev.prompt_char_count ?? 0);
                runeCount = Number(ev.prompt_rune_count ?? 0);
                sections = (ev.prompt_sections ?? []).map(String);
              }
            }
          }
        }
      } finally {
        void reader.cancel().catch(() => {});
      }
      if (streamError) {
        throw new ApiError(streamError, 400001, 400);
      }
      if (!session) {
        // 沒收到 done 事件才回頭走非串流版；已中止的請求不該再打一次 AI。
        opts.signal?.throwIfAborted();
        const fallback = await this.chat(opts);
        return { session: fallback.session };
      }
      return { session, prompt, fingerprint, charCount, runeCount, sections };
    },
  };
}

export function createLiveResearch(): ResearchRepo {
  return {
    async search(query) {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/research/search",
        { method: "POST", body: { query } },
      );
      return (data.list ?? []).map(
        (raw): ResearchHit => ({
          id: String(raw.id ?? ""),
          title: String(raw.title ?? ""),
          snippet: String(raw.snippet ?? ""),
          url: String(raw.url ?? ""),
          summary: raw.summary != null ? String(raw.summary) : undefined,
          learn_points: Array.isArray(raw.learn_points)
            ? (raw.learn_points as string[])
            : undefined,
          reply_hooks: Array.isArray(raw.reply_hooks) ? (raw.reply_hooks as string[]) : undefined,
          source_label: raw.source_label != null ? String(raw.source_label) : undefined,
        }),
      );
    },
  };
}

export function createLiveMedia(): MediaRepo {
  return {
    async generateImage(prompt) {
      const data = await apiRequest<{ id: string; url: string; prompt: string }>(
        "/api/v1/media/generate-image",
        { method: "POST", body: { prompt } },
      );
      return {
        id: String(data.id ?? ""),
        url: String(data.url ?? ""),
        prompt: String(data.prompt ?? prompt),
      };
    },
    async attachLocal(files) {
      const list = Array.from(files instanceof FileList ? Array.from(files) : files);
      const out: { id: string; url: string; name?: string }[] = [];
      for (const file of list) {
        const content = await fileToDataURL(file);
        const data = await apiRequest<{ url: string; key?: string }>("/api/v1/media/upload", {
          method: "POST",
          body: { content, kind: "other" },
        });
        out.push({
          id: data.key || `att_${Date.now()}`,
          url: data.url,
          name: file.name,
        });
      }
      return out;
    },
  };
}

function fileToDataURL(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result ?? ""));
    reader.onerror = () => reject(new Error("read file failed"));
    reader.readAsDataURL(file);
  });
}

export function createLiveScout(): ScoutRepo {
  return {
    async listBrands() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/scout/brands");
      return (data.list ?? []).map(mapBrand);
    },
    async get(id) {
      try {
        const raw = await apiRequest<Record<string, unknown>>(
          `/api/v1/scout/brands/${encodeURIComponent(id)}`,
        );
        return mapBrand(raw);
      } catch {
        return null;
      }
    },
    async getActiveBrandId() {
      const data = await apiRequest<{ id: string }>("/api/v1/scout/brands-active");
      return String(data.id ?? "");
    },
    async setActiveBrandId(id) {
      await apiRequest("/api/v1/scout/brands-active", { method: "POST", body: { id } });
    },
    async createBrand(input) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brands", {
        method: "POST",
        body: {
          display_name: input?.display_name || "未命名品牌",
          brief: input?.brief || "",
        },
      });
      return mapBrand(raw);
    },
    async saveBrand(brand) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brands", {
        method: "POST",
        body: {
          id: brand.id,
          display_name: brand.display_name,
          brief: brand.brief,
          target_audience: brand.target_audience,
          goals: brand.goals,
        },
      });
      return mapBrand(raw);
    },
    async removeBrand(id) {
      await apiRequest(`/api/v1/scout/brands/${encodeURIComponent(id)}`, { method: "DELETE" });
    },
    async listProducts(brandId) {
      const q = brandId ? `?brand_id=${encodeURIComponent(brandId)}` : "";
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        `/api/v1/scout/products${q}`,
      );
      return (data.list ?? []).map(mapProduct);
    },
    async listAllProducts() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/scout/products/all",
      );
      return (data.list ?? []).map(mapProduct);
    },
    async getProduct(id) {
      try {
        const raw = await apiRequest<Record<string, unknown>>(
          `/api/v1/scout/products/${encodeURIComponent(id)}`,
        );
        return mapProduct(raw);
      } catch {
        return null;
      }
    },
    async saveProduct(product) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/products", {
        method: "POST",
        body: {
          id: product.id,
          brand_id: product.brand_id,
          label: product.label,
          product_context: product.product_context,
          match_tags: product.match_tags,
          pain_points: product.pain_points,
          provider_capability_terms: product.provider_capability_terms,
          provider_exclude_terms: product.provider_exclude_terms,
          placement_url: product.placement_url,
        },
      });
      return mapProduct(raw);
    },
    async removeProduct(id) {
      await apiRequest(`/api/v1/scout/products/${encodeURIComponent(id)}`, { method: "DELETE" });
    },
    async importProductFromUrl(url) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/products/import", {
        method: "POST",
        body: { url },
      });
      return {
        label: String(raw.label ?? ""),
        product_context: String(raw.product_context ?? ""),
        pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points as string[]) : [],
        match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [],
        placement_url: String(raw.placement_url ?? url),
        source_note: String(raw.source_note ?? ""),
      };
    },
    async prepareBrief(opts) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brief", {
        method: "POST",
        body: {
          intent: opts.intent,
          brand_id: opts.brandId,
          product_id: opts.productId,
          purpose: opts.purpose,
          deep: opts.deep,
        },
      });
      return mapBrief(raw);
    },
    async runScanFromBrief(brief) {
      const data = await apiRequest<{ job?: Record<string, unknown>; run?: Record<string, unknown> }>("/api/v1/scout/scan", {
        method: "POST",
        body: { brief: briefToBody(brief) },
      });
      const job = mapScanJob(data.job ?? {});
      if (!job.id) throw new Error("海巡任務未建立");
      const run = mapScoutRun(data.run ?? {});
      if (!run.id) throw new Error("海巡批次未建立");
      return { job, run };
    },
    async listRuns(query?: ScoutRunListQuery) {
      const page = query?.page ?? 1;
      const pageSize = query?.pageSize ?? 10;
      const data = await apiRequest<{
        list?: Record<string, unknown>[];
        pagination?: Record<string, unknown>;
      }>(`/api/v1/scout/runs${scoutQuery({
        page,
        pageSize,
        brand_id: query?.brandId ?? undefined,
        mode: query?.mode,
      })}`);
      return {
        list: (data.list ?? []).map(mapScoutRun),
        pagination: mapPagination(data.pagination, page, pageSize),
      };
    },
    async listRunPosts(runId: string, query?: ScoutResultListQuery) {
      const page = query?.page ?? 1;
      const pageSize = query?.pageSize ?? 8;
      const data = await apiRequest<{
        run?: Record<string, unknown>;
        list?: Record<string, unknown>[];
        pagination?: Record<string, unknown>;
      }>(
        `/api/v1/scout/runs/${encodeURIComponent(runId)}/posts${scoutQuery({ page, pageSize })}`,
      );
      return {
        run: mapScoutRun(data.run ?? {}),
        list: (data.list ?? []).map(mapScoutPost),
        pagination: mapPagination(data.pagination, page, pageSize),
      };
    },
    async getScanContext(brandId, productId) {
      const brief = await this.prepareBrief({
        intent: "legacy",
        brandId,
        productId,
      });
      return {
        brand_id: brandId,
        product_ids: productId ? [productId] : [],
        pains: brief.pains,
        tags: brief.tags,
        expand_terms: brief.periphery,
      } satisfies ScoutScanContext;
    },
    async listPosts(brandId) {
      const q =
        brandId != null && brandId !== ""
          ? `?brand_id=${encodeURIComponent(brandId)}`
          : "";
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        `/api/v1/scout/posts${q}`,
      );
      return (data.list ?? []).map(mapScoutPost);
    },
    async runScan(brandId, productId, extraTerms) {
      const brief = await this.prepareBrief({ intent: "scan", brandId, productId });
      if (extraTerms?.length) {
        brief.scan_terms = [...new Set([...brief.scan_terms, ...extraTerms])];
      }
      return this.runScanFromBrief(brief);
    },
    async draftOutreach(postId, personaId) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/scout/posts/${encodeURIComponent(postId)}/draft`,
        { method: "POST", body: { persona_id: personaId } },
      );
      return mapScoutPost(raw);
    },
    async skipOutreach(postId) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/scout/posts/${encodeURIComponent(postId)}/skip`,
        { method: "POST", body: {} },
      );
      return mapScoutPost(raw);
    },
    async promoteToOpportunity(postId) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/scout/posts/${encodeURIComponent(postId)}/promote`,
        { method: "POST", body: {} },
      );
      return {
        opportunity_id: String(raw.opportunity_id ?? ""),
        status: String(raw.status ?? ""),
        intent_band: raw.intent_band != null ? String(raw.intent_band) : undefined,
        intent_score: raw.intent_score != null ? Number(raw.intent_score) : undefined,
      };
    },
    async markPublished(postId) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/scout/posts/${encodeURIComponent(postId)}/mark-published`,
        { method: "POST", body: {} },
      );
      return mapScoutPost(raw);
    },
    async sendOutreach(opts) {
      const raw = await apiRequest<Record<string, unknown>>(
        `/api/v1/scout/posts/${encodeURIComponent(opts.postId)}/send`,
        {
          method: "POST",
          body: { text: opts.text, account_id: opts.accountId },
        },
      );
      return mapScoutPost(raw);
    },
    async removePost(postId) {
      await apiRequest(`/api/v1/scout/posts/${encodeURIComponent(postId)}`, {
        method: "DELETE",
      });
    },
    async removeTheme(themeKey) {
      await apiRequest(`/api/v1/scout/themes/${encodeURIComponent(themeKey)}`, {
        method: "DELETE",
      });
    },
    async removeRun(runId: string) {
      await apiRequest(`/api/v1/scout/runs/${encodeURIComponent(runId)}`, { method: "DELETE" });
    },
    async listHomework() {
      const data = await apiRequest<{ list: Record<string, unknown>[] }>(
        "/api/v1/scout/homework",
      );
      return (data.list ?? []).map(mapHomework);
    },
    async saveHomework(record) {
      const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/homework", {
        method: "POST",
        body: {
          theme_key: record.theme_key,
          theme_label: record.theme_label,
          purpose: record.purpose,
          brief: briefToBody(record.brief),
        },
      });
      return mapHomework(raw);
    },
    async getHomework(themeKey) {
      try {
        const raw = await apiRequest<Record<string, unknown>>(
          `/api/v1/scout/homework/${encodeURIComponent(themeKey)}`,
        );
        return mapHomework(raw);
      } catch {
        return null;
      }
    },
    async removeHomework(themeKey) {
      await apiRequest(`/api/v1/scout/homework/${encodeURIComponent(themeKey)}`, {
        method: "DELETE",
      });
    },
  };
}
