import { OfferSchema, runAdapterSearch } from "./types.js";
import type { BrowserDiagnosticEvent, FetchContext, NormalizedQuery, Offer, SearchDiagnostic, SearchErrorCode, StoreAdapter } from "./types.js";
import { HttpStatusError, StorePausedError } from "./httpErrors.js";
import { ChallengeRequiredError } from "./browserErrors.js";
import type { OfferCache } from "./types.js";

export interface SearchOutcome {
  ok: boolean;
  offers: Offer[];
  cached: boolean;
  durationMs: number;
  /** 200 for a successful live fetch, the store's error status when it sent one,
   *  null when no HTTP happened (cache hit, timeout, network error). */
  httpStatus: number | null;
  observedAt: number | null;
  /** Set when ok=false. Machine-readable; the frontend owns the human wording. */
  errorCode?: SearchErrorCode;
  diagnostic?: SearchDiagnostic;
  /** Walled (onDemand) store hit a Cloudflare challenge; the UI opens `solveUrl`
   *  (noVNC) for the user to clear it, then retries. See WALLED-STORES.md. */
  needsSolve?: boolean;
  solveUrl?: string;
}

/**
 * Per-store anomaly counters (TECHNICAL-SPEC §4): a store whose zeroParse rate
 * spikes while others return results almost certainly changed its markup.
 */
export interface StoreCounters {
  searches: number;
  httpFailures: number;
  zeroParse: number; // HTTP OK but nothing parsed
  zodRejections: number; // offers parsed but invalid
}

export class SearchService {
  readonly counters = new Map<string, StoreCounters>();

  constructor(
    private ctx: FetchContext,
    private cache: OfferCache,
  ) {}

  private countersFor(storeId: string): StoreCounters {
    let c = this.counters.get(storeId);
    if (!c) {
      c = { searches: 0, httpFailures: 0, zeroParse: 0, zodRejections: 0 };
      this.counters.set(storeId, c);
    }
    return c;
  }

  async search(adapter: StoreAdapter, q: NormalizedQuery): Promise<SearchOutcome> {
    const started = Date.now();
    const c = this.countersFor(adapter.id);
    c.searches++;

    const cached = this.cache.getEntry(adapter.id, q.canonical);
    if (cached) {
      return {
        ok: true,
        offers: cached.offers,
        cached: true,
        durationMs: Date.now() - started,
        httpStatus: null,
        observedAt: cached.observedAt,
      };
    }

    let raw: Offer[];
    try {
      raw = await withTimeout(runAdapterSearch(adapter, q, this.ctx), adapter.timeoutMs, adapter.id);
    } catch (err) {
      const diagnostic = failureDiagnostic(err, this.ctx.browser?.takeDiagnostics?.(adapter.id) ?? []);
      // A walled store needing a human to clear a challenge is not a store failure —
      // don't count it against the store; hand the UI the solve URL instead.
      if (err instanceof ChallengeRequiredError) {
        return {
          ok: false,
          offers: [],
          cached: false,
          durationMs: Date.now() - started,
          httpStatus: null,
          observedAt: null,
          needsSolve: true,
          solveUrl: err.solveUrl,
          errorCode: "challenge_required",
          diagnostic,
        };
      }
      c.httpFailures++;
      return {
        ok: false,
        offers: [],
        cached: false,
        durationMs: Date.now() - started,
        httpStatus: err instanceof HttpStatusError ? err.status : null,
        observedAt: null,
        errorCode:
          err instanceof StorePausedError
            ? "store_paused"
            : err instanceof AdapterTimeoutError
              ? "timeout"
              : "fetch_failed",
        diagnostic,
      };
    }

    const offers: Offer[] = [];
    for (const o of raw) {
      const parsed = OfferSchema.safeParse(o);
      if (parsed.success) offers.push(parsed.data);
      else c.zodRejections++;
    }
    if (raw.length === 0) c.zeroParse++;

    const observedAt = Date.now();
    this.cache.set(adapter.id, q.canonical, offers, observedAt);
    const browserEvents = this.ctx.browser?.takeDiagnostics?.(adapter.id) ?? [];
    return {
      ok: true,
      offers,
      cached: false,
      durationMs: Date.now() - started,
      httpStatus: 200,
      observedAt,
      ...(browserEvents.length > 0 ? { diagnostic: { browserEvents } } : {}),
    };
  }
}

function failureDiagnostic(error: unknown, browserEvents: BrowserDiagnosticEvent[]): SearchDiagnostic {
  const raw = error instanceof Error ? error.message : String(error);
  const message = raw
    .replace(/https?:\/\/\S+/gi, "[url removed]")
    .replace(/[\r\n\t]+/g, " ")
    .slice(0, 300);
  return { ...(message ? { message } : {}), browserEvents: browserEvents.slice(-30) };
}

class AdapterTimeoutError extends Error {
  constructor(storeId: string, ms: number) {
    super(`${storeId}: adapter timed out after ${ms}ms`);
    this.name = "AdapterTimeoutError";
  }
}

function withTimeout<T>(p: Promise<T>, ms: number, storeId: string): Promise<T> {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new AdapterTimeoutError(storeId, ms)), ms);
    p.then(
      (v) => (clearTimeout(t), resolve(v)),
      (e) => (clearTimeout(t), reject(e)),
    );
  });
}
