import { describe, expect, it } from "vitest";
import { SearchService } from "../src/core/search.js";
import { ResponseCache } from "../src/core/cache.js";
import { openDatabase } from "../src/core/db.js";
import { ChallengeRequiredError } from "../src/core/browserErrors.js";
import { HttpStatusError, StorePausedError } from "../src/core/politeHttp.js";
import type { FetchContext, NormalizedQuery, Offer, StoreAdapter } from "../src/core/types.js";

const q: NormalizedQuery = { raw: "W 712/52", canonical: "W71252" };
// The service reaches adapters only through fetchRaw/parse, so an empty ctx suffices.
const ctx = {} as FetchContext;

const validOffer: Offer = {
  store: "fake",
  brand: "MANN-FILTER",
  partNumber: "W 712/52",
  title: "Eļļas filtrs",
  priceEur: 5.42,
  inStock: true,
  availability: "Pieejams",
  deliveryNote: null,
  url: "https://fake.example/w71252",
  imageUrl: null,
  attributes: [],
};

function makeAdapter(over: Partial<StoreAdapter>): StoreAdapter {
  return {
    id: "fake",
    displayName: "Fake.lv",
    storeHomepage: "https://fake.example",
    enabled: true,
    timeoutMs: 100,
    fetchRaw: async () => "raw",
    parse: () => [validOffer],
    ...over,
  };
}

function makeService(): SearchService {
  return new SearchService(ctx, new ResponseCache(openDatabase(":memory:")));
}

describe("SearchService", () => {
  it("returns validated offers live, then the same offers from cache", async () => {
    const service = makeService();
    const adapter = makeAdapter({});

    const live = await service.search(adapter, q);
    expect(live).toMatchObject({ ok: true, cached: false, httpStatus: 200 });
    expect(live.observedAt).toEqual(expect.any(Number));
    expect(live.offers).toEqual([validOffer]);

    const cached = await service.search(adapter, q);
    expect(cached).toMatchObject({ ok: true, cached: true, httpStatus: null });
    expect(cached.observedAt).toBe(live.observedAt);
    expect(cached.offers).toEqual([validOffer]);
  });

  it("drops offers that fail schema validation and counts the rejection", async () => {
    const service = makeService();
    const adapter = makeAdapter({
      parse: () => [validOffer, { ...validOffer, priceEur: 0 }],
    });

    const outcome = await service.search(adapter, q);
    expect(outcome.ok).toBe(true);
    expect(outcome.offers).toEqual([validOffer]);
    expect(service.counters.get("fake")).toMatchObject({ zodRejections: 1, zeroParse: 0 });
  });

  it("counts a parse that finds nothing (breakage signal) and caches the empty answer", async () => {
    const service = makeService();
    const adapter = makeAdapter({ parse: () => [] });

    const outcome = await service.search(adapter, q);
    expect(outcome).toMatchObject({ ok: true, offers: [] });
    expect(service.counters.get("fake")).toMatchObject({ zeroParse: 1 });
    expect((await service.search(adapter, q)).cached).toBe(true);
  });

  it("times out a hung adapter with errorCode 'timeout'", async () => {
    const service = makeService();
    const adapter = makeAdapter({
      timeoutMs: 20,
      fetchRaw: () => new Promise<string>(() => {}), // never settles
    });

    const outcome = await service.search(adapter, q);
    expect(outcome).toMatchObject({ ok: false, errorCode: "timeout", httpStatus: null });
    expect(service.counters.get("fake")).toMatchObject({ httpFailures: 1 });
  });

  it("maps ChallengeRequiredError to needsSolve without blaming the store", async () => {
    const service = makeService();
    const adapter = makeAdapter({
      fetchRaw: async () => {
        throw new ChallengeRequiredError("fake", "http://solve.example/vnc.html");
      },
    });

    const outcome = await service.search(adapter, q);
    expect(outcome).toMatchObject({
      ok: false,
      needsSolve: true,
      solveUrl: "http://solve.example/vnc.html",
      errorCode: "challenge_required",
    });
    expect(service.counters.get("fake")).toMatchObject({ httpFailures: 0 });
  });

  it("maps StorePausedError and HTTP errors to their codes", async () => {
    const service = makeService();

    const paused = await service.search(
      makeAdapter({
        fetchRaw: async () => {
          throw new StorePausedError("fake");
        },
      }),
      q,
    );
    expect(paused).toMatchObject({ ok: false, errorCode: "store_paused" });

    const http = await service.search(
      makeAdapter({
        fetchRaw: async () => {
          throw new HttpStatusError("fake", "https://fake.example", 500);
        },
      }),
      q,
    );
    expect(http).toMatchObject({ ok: false, errorCode: "fetch_failed", httpStatus: 500 });
  });

  it("returns sanitized failure diagnostics with the browser lifecycle trail", async () => {
    const browser = {
      fetchJson: async () => "",
      fetchOriginHtml: async () => "",
      fetchPageHtml: async () => "",
      takeDiagnostics: () => [{ at: 123, event: "challenge_detected" as const, status: 403 }],
    };
    const service = new SearchService(
      { browser } as unknown as FetchContext,
      new ResponseCache(openDatabase(":memory:")),
    );
    const adapter = makeAdapter({
      fetchRaw: async () => { throw new Error("failed at https://secret.example/path?token=abc\nnext"); },
    });

    const outcome = await service.search(adapter, q);

    expect(outcome.diagnostic).toEqual({
      message: "failed at [url removed] next",
      browserEvents: [{ at: 123, event: "challenge_detected", status: 403 }],
    });
  });
});
