import { describe, expect, it } from "vitest";
import type { Brand, BrandProduct, Opportunity } from "../../domain/types";
import { isThreadsSearchable } from "../../lib/threadsTerm";
import { createMockRadarRepo } from "./radarRepo";

const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" };
const product: BrandProduct = {
  id: "p1", brand_id: "b1", label: "產品一", product_context: "處理漏水",
  pain_points: ["漏水"], match_tags: ["水管"], provider_capability_terms: ["抓漏"],
  provider_exclude_terms: [], created_at: 1, updated_at: 1,
};

function opportunity(id: string, postedAt = Date.now() * 1e6, patch: Partial<Opportunity> = {}): Opportunity {
  return {
    id,
    source: "threads",
    external_id: id,
    permalink: `https://threads.net/post/${id}`,
    author_handle: "buyer",
    text: `需求 ${id}`,
    posted_at: postedAt,
    status: "qualified",
    intent_score: 70,
    intent_band: "mid",
    reasons: [],
    region_match: "unknown",
    freshness_hours: 1,
    matched_terms: [],
    created_at: postedAt,
    ...patch,
  };
}

describe("mock product radar repository", () => {
  it("validates the paired owner product context and allows one generic assignment", async () => {
    const repo = createMockRadarRepo({ brands: [brand], products: [product], profile: { exists: true } });
    await expect(repo.createWatch({ terms: ["找水電"], brand_id: "b1" })).rejects.toMatchObject({ code: 400001 });
    const generic = await repo.createWatch({ terms: ["找水電"] });
    const assigned = await repo.assignWatchProduct(generic.id, "b1", "p1");
    expect(assigned).toMatchObject({ context_mode: "product", brand_id: "b1", product_id: "p1" });
    await expect(repo.assignWatchProduct(generic.id, "b1", "p1")).rejects.toMatchObject({ code: 409001 });
  });

  it("does not resume an unavailable product watch", async () => {
    const repo = createMockRadarRepo({
      brands: [brand],
      products: [product],
      watches: [{
        id: "unavailable", terms: ["找水電"], exclude_terms: [], regions: [], status: "paused",
        context_mode: "product", brand_id: "b1", product_id: "p1", pause_reason: "product_unavailable",
        created_at: 1, updated_at: 1,
      }],
    });
    await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 });
  });

  it("stamps last_matched_at on sweep so older posted hits stay on today", async () => {
    const yesterday = Date.now() * 1e6 - 2 * 86_400_000_000_000;
    const repo = createMockRadarRepo({
      watches: [{
        id: "w1", terms: ["敏感肌"], exclude_terms: [], regions: [], status: "active",
        created_at: 1, updated_at: 1,
      }],
      opportunities: [
        opportunity("old-hit", yesterday, { created_at: yesterday, watch_id: "w1" }),
      ],
    });
    expect((await repo.listOpportunities({ review_state: "pending", time_scope: "today" })).list.map((o) => o.id))
      .not.toContain("old-hit");
    await repo.triggerWatchSweep("w1");
    const after = await repo.listOpportunities({ review_state: "pending", time_scope: "today" });
    expect(after.list.map((o) => o.id)).toContain("old-hit");
    expect(after.list.find((o) => o.id === "old-hit")).toMatchObject({ review_state: "pending" });
    expect(after.list.find((o) => o.id === "old-hit")?.last_matched_at).toBeGreaterThan(yesterday);
  });

  it("maps legacy status and supports an idempotent review-state lifecycle", async () => {
    const repo = createMockRadarRepo({
      opportunities: [
        opportunity("pending"),
        opportunity("accepted", Date.now() * 1e6, { status: "accepted" }),
        opportunity("dismissed", Date.now() * 1e6, { status: "dismissed" }),
        opportunity("same-author"),
      ],
    });

    expect((await repo.getOpportunity("accepted")).review_state).toBe("completed");
    expect((await repo.getOpportunity("dismissed")).review_state).toBe("removed");
    expect((await repo.listOpportunities({ review_state: "pending", time_scope: "today" })).list.map((o) => o.id).sort())
      .toEqual(["pending", "same-author"]);

    const removed = await repo.updateOpportunityReviewState("pending", {
      state: "removed",
      removal_reason: "pain_mismatch",
    });
    expect(removed).toMatchObject({ review_state: "removed", previous_review_state: "pending", removal_reason: "pain_mismatch" });
    const repeated = await repo.updateOpportunityReviewState("pending", {
      state: "removed",
      removal_reason: "pain_mismatch",
    });
    expect(repeated.removed_at).toBe(removed.removed_at);

    const restored = await repo.updateOpportunityReviewState("pending", { state: "pending" });
    expect(restored).toMatchObject({ review_state: "pending", removal_reason: undefined, removed_at: undefined });
    await expect(repo.updateOpportunityReviewState("pending", { state: "removed" })).rejects.toMatchObject({ code: 400001 });
    await expect(repo.updateOpportunityReviewState("pending", { state: "removed", removal_reason: "other" })).rejects.toMatchObject({ code: 400001 });
  });

  it("requires a same-author target for duplicate tombstones", async () => {
    const repo = createMockRadarRepo({ opportunities: [opportunity("a"), opportunity("b", Date.now() * 1e6, { author_handle: "other" })] });
    await expect(repo.updateOpportunityReviewState("a", {
      state: "removed", removal_reason: "duplicate", duplicate_of_opportunity_id: "b",
    })).rejects.toMatchObject({ code: 409001 });
    await expect(repo.updateOpportunityReviewState("a", {
      state: "removed", removal_reason: "duplicate", duplicate_of_opportunity_id: "a",
    })).rejects.toMatchObject({ code: 400001 });
    const result = await repo.updateOpportunityReviewState("a", {
      state: "removed", removal_reason: "duplicate", duplicate_of_opportunity_id: "missing",
    }).catch((error) => error);
    expect(result.code).toBe(409001);
  });

  it("accept and legacy dismiss keep the old CRM methods usable", async () => {
    const repo = createMockRadarRepo({ opportunities: [opportunity("a")] });
    expect(await repo.acceptOpportunity("a")).toMatchObject({ opportunity_id: "a", status: "accepted" });
    expect((await repo.getOpportunity("a")).review_state).toBe("completed");
    const dismissed = await repo.dismissOpportunity("a", "campaign noise");
    expect(dismissed).toMatchObject({ status: "dismissed", review_state: "removed", removal_reason: "legacy_unknown", removal_note: "campaign noise" });
  });

  it("builds a free product baseline and guards optimistic demand-map updates", async () => {
    const repo = createMockRadarRepo({ brands: [brand], products: [product] });
    const baseline = await repo.getDemandMap("p1");
    expect(baseline).toMatchObject({ product_id: "p1", map_version: 1, state: "ready" });
    expect(baseline.pain_phrases[0]).toMatchObject({ text: "漏水", origin: "product" });
    const updated = await repo.updateDemandMap("p1", {
      ...baseline,
      custom_phrases: [{ text: "晚上漏水", kind: "custom", origin: "user", enabled: true }],
      expected_map_version: baseline.map_version,
    });
    expect(updated.map_version).toBe(2);
    expect(updated.custom_phrases[0].text).toBe("晚上漏水");
    await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 });
  });

  it("shortens long product sentences into searchable demand phrases and suggestions", async () => {
    const longProduct: BrandProduct = {
      ...product,
      id: "p-long",
      product_context: "換季日常修護",
      pain_points: ["晚上睡覺容易口乾舌燥怎麼辦"],
      match_tags: ["求推薦"],
      provider_capability_terms: ["保濕"],
    };
    const repo = createMockRadarRepo({ brands: [brand], products: [longProduct] });
    const baseline = await repo.getDemandMap("p-long");
    expect(baseline.pain_phrases.every((item) => item.text.length <= 4)).toBe(true);
    expect(baseline.pain_phrases.some((item) => item.text === "口乾舌燥" || item.text === "晚上睡覺")).toBe(true);
    const suggestions = await repo.suggestWatchTerms({ product_id: "p-long", limit: 8 });
    expect(suggestions.length).toBeGreaterThan(0);
    expect(suggestions.filter((item) => item.usage === "include").every((item) => isThreadsSearchable(item.term))).toBe(true);
  });
});
