/**
 * The API contract between the Fastify backend (src/) and the Angular frontend
 * (web/) — the single source of truth both sides import. Pure types and pure
 * functions only: no zod, no Node, no Angular, so it compiles under both
 * tsconfigs. The backend layers its zod runtime schema on top (src/core/types.ts,
 * checked against these types with `satisfies`).
 */

/** Part-number canonical form: uppercase, non-alphanumerics stripped ("W 712/52" -> "W71252"). */
export function canonicalize(value: string): string {
  return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}

/** Shorter than this (canonicalized) is too ambiguous to be a part number. */
export const MIN_QUERY_CHARS = 4;
/** Longer than this is not a part number — caps store URLs and the search log. */
export const MAX_QUERY_CHARS = 64;
/** Search cache lifetime and purchase-plan freshness window. */
export const OFFER_FRESHNESS_MS = 15 * 60 * 1000;
/** Largest timestamp accepted by JavaScript Date. */
export const MAX_DATE_TIMESTAMP = 8_640_000_000_000_000;

/**
 * One spec row as the store prints it ("Augstums" / "92 mm"). Flag-style
 * attributes without a value ("Ar atgriezējvārstu") go in value with label "".
 */
export interface OfferAttribute {
  label: string;
  value: string;
}

export interface Offer {
  store: string;
  brand: string | null;
  partNumber: string;
  title: string;
  priceEur: number;
  /**
   * Structured stock status: true/false when the store states it, null when
   * unknown. The adapter owns the store-specific interpretation; the UI must
   * never infer stock from the display text.
   */
  inStock: boolean | null;
  /** Display-only stock/availability text as the store shows it. */
  availability: string | null;
  deliveryNote: string | null;
  url: string;
  imageUrl: string | null;
  attributes: OfferAttribute[];
  /** Opaque store product identifier used only for exact-product refreshes. */
  sourceRef?: string | null;
}

export type StoreSort = "lines" | "subtotal" | "alphabetical";
export type RefreshOutcome =
  | "up_to_date"
  | "changed"
  | "not_found"
  | "could_not_check"
  | "human_check_required";

export interface PlanOfferSnapshot extends Offer {
  sourceRef: string | null;
}

export interface RefreshAttempt {
  attemptedAt: number;
  outcome: RefreshOutcome;
  reason: string | null;
}

export interface OfferChange {
  detectedAt: number;
  beforePriceEur: number;
  currentPriceEur: number;
  beforeInStock: boolean | null;
  currentInStock: boolean | null;
  beforeAvailability: string | null;
  currentAvailability: string | null;
}

export interface PlanCandidate {
  id: string;
  storeId: string;
  storeName: string;
  offer: PlanOfferSnapshot;
  addedAt: number;
  observedAt: number | null;
  lastAttempt: RefreshAttempt | null;
  unreviewedChange: OfferChange | null;
}

export interface PartRequirement {
  id: string;
  partKey: string;
  brand: string | null;
  partNumber: string;
  title: string;
  imageUrl: string | null;
  quantity: number;
  chosenCandidateId: string;
  candidates: PlanCandidate[];
}

export interface PurchasePlan {
  schemaVersion: 1;
  storeSort: StoreSort;
  requirements: PartRequirement[];
}

export interface PurchasePlanResponse {
  plan: PurchasePlan | null;
  updatedAt: number | null;
}

export interface SavePurchasePlanRequest {
  plan: PurchasePlan;
}

export interface SearchHistoryEntry {
  /** Most recently submitted formatting, trimmed. */
  query: string;
  /** Existing part-number canonical form, used as the stable distinct key. */
  canonical: string;
  searchedAt: number;
}

export interface SearchHistory {
  schemaVersion: 1;
  entries: SearchHistoryEntry[];
}

export interface SearchHistoryResponse {
  history: SearchHistory | null;
  updatedAt: number | null;
}

export interface SaveSearchHistoryRequest {
  history: SearchHistory;
}

export interface StoreInfo {
  id: string;
  displayName: string;
  homepage: string;
  /** "auto" fetches on every search; "onDemand" (walled) may need a challenge solved. */
  fetchMode: "auto" | "onDemand";
}

/** Machine-readable failure reason; the frontend owns the human wording. */
export type SearchErrorCode =
  | "challenge_required" // walled store hit a Cloudflare challenge — solve, then retry
  | "store_paused" // circuit breaker tripped; the store is cooling down
  | "timeout" // the adapter ran out of its per-store budget
  | "fetch_failed"; // network/HTTP/parse failure

export type BrowserDiagnosticEventName =
  | "operation_started"
  | "navigation_started"
  | "navigation_finished"
  | "challenge_detected"
  | "challenge_cleared"
  | "selector_found"
  | "selector_missing"
  | "response"
  | "timeout"
  | "error";

/** Sanitized browser lifecycle event. Never contains URLs, headers, cookies, or bodies. */
export interface BrowserDiagnosticEvent {
  at: number;
  event: BrowserDiagnosticEventName;
  status?: number;
  detail?: string;
}

export interface SearchDiagnostic {
  /** Length-limited, sanitized application error text. */
  message?: string;
  browserEvents: BrowserDiagnosticEvent[];
}

/** Response of GET /api/search/:store. */
export interface StoreSearchResponse {
  store: string;
  displayName: string;
  query: string;
  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;
  /** Time this store response was fetched; cache hits retain the original time. */
  observedAt: number | null;
  errorCode?: SearchErrorCode;
  diagnostic?: SearchDiagnostic;
  /** Walled store hit a Cloudflare challenge; open `solveUrl` (noVNC) then retry. */
  needsSolve?: boolean;
  solveUrl?: string;
}

export interface OfferRefreshRequest {
  store: string;
  url: string;
  sourceRef: string | null;
  expectedBrand: string | null;
  expectedPartNumber: string;
}

export type OfferRefreshErrorCode =
  | "challenge_required"
  | "store_paused"
  | "timeout"
  | "fetch_failed"
  | "identity_mismatch"
  | "parse_failed"
  | "unsupported";

export interface OfferRefreshResponse {
  store: string;
  ok: boolean;
  status: "found" | "not_found" | "error";
  offer?: Offer;
  checkedAt: number | null;
  errorCode?: OfferRefreshErrorCode;
  needsSolve?: boolean;
  solveUrl?: string;
}
