export type AnyObject = Record<string, any>;

/*
|--------------------------------------------------------------------------
| Unwrap nested "data" envelopes until the shape looks like real content
|--------------------------------------------------------------------------
*/

export function unwrapUntil(
  raw: any,
  predicate: (obj: AnyObject) => boolean,
): AnyObject {
  if (!raw || typeof raw !== "object") {
    return {};
  }

  let current: AnyObject = raw;

  let guard = 0;

  while (
    current &&
    typeof current === "object" &&
    !Array.isArray(current) &&
    !predicate(current) &&
    current.data &&
    typeof current.data === "object" &&
    guard < 5
  ) {
    current = current.data;
    guard++;
  }

  return current || {};
}

export function looksLikeContent(obj: AnyObject): boolean {
  return Object.keys(obj || {}).some((key) =>
    /read|meaning|interpret|message|summary|response|text|description|upright|reversed|love|career|finance|health|answer|yes_no|timing/i.test(
      key,
    ),
  );
}

/*
|--------------------------------------------------------------------------
| Known Section Extraction
|--------------------------------------------------------------------------
|
| Best-effort extraction of commonly-named sections. Anything the API
| returns that isn't captured here still shows up via
| collectExtraTextFields() below, so nothing is silently dropped even
| if these guessed key names don't match the real response.
|
*/

export interface OrientationMeaning {
  overview?: string;
  love?: string;
  career?: string;
  finance?: string;
  health?: string;
}

// "description" holds the real narrative paragraph on confirmed real
// responses; "meaning" has been observed holding a terse, unrelated
// one-word value (e.g. "No") on the same object, so it's deliberately
// last-priority rather than first.
const OVERVIEW_ALIASES = ["description", "overview", "summary", "general", "meaning"];

function extractOrientation(obj: AnyObject): OrientationMeaning | undefined {
  if (!obj || typeof obj !== "object") {
    return undefined;
  }

  const overview = firstString(obj, OVERVIEW_ALIASES);

  const love = firstString(obj, ["relationship", "love", "love_relationships", "relationships"]);

  const career = firstString(obj, ["career", "work"]);

  const finance = firstString(obj, ["finance", "money", "finances"]);

  const health = firstString(obj, ["health"]);

  if (!overview && !love && !career && !finance && !health) {
    return undefined;
  }

  return { overview, love, career, finance, health };
}

function firstString(obj: AnyObject, aliases: string[]): string | undefined {
  for (const key of aliases) {
    const value = obj?.[key];

    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
  }

  return undefined;
}

/*
|--------------------------------------------------------------------------
| Card Images
|--------------------------------------------------------------------------
|
| Two confirmed real shapes, depending on the endpoint:
|  - Card reference (/tarot/cards/{id}): card.images.{upright,
|    reversed,back}.{artwork,classic,dark,ghibli} — one bucket per
|    orientation.
|  - Reading endpoints (one-card/three-card/love/yes-no), per card
|    in the "cards" array: a flat image.{artwork,classic,dark,ghibli}
|    already resolved to the drawn direction, plus a separate
|    back.{...} for the card-back art (not this orientation's art —
|    deliberately not used as a fallback here).
|
| Prefer the "artwork" style, falling back through the other variants.
|
*/

const IMAGE_STYLE_PRIORITY = ["artwork", "classic", "dark", "ghibli"];

function pickStyle(bucket: any): string | undefined {
  if (!bucket || typeof bucket !== "object") {
    return undefined;
  }

  for (const style of IMAGE_STYLE_PRIORITY) {
    if (typeof bucket[style] === "string" && bucket[style]) {
      return bucket[style];
    }
  }

  return undefined;
}

function pickStyleCandidates(bucket: any): string[] {
  if (!bucket || typeof bucket !== "object") {
    return [];
  }

  return IMAGE_STYLE_PRIORITY.filter(
    (style) => typeof bucket[style] === "string" && bucket[style],
  ).map((style) => bucket[style]);
}

export function extractCardImage(
  report: AnyObject,
  orientation: "upright" | "reversed" | "back",
): string | undefined {
  const nested =
    pickStyle(report?.card?.images?.[orientation]) ??
    pickStyle(report?.card_images?.[orientation]) ??
    pickStyle(report?.images?.[orientation]);

  if (nested) {
    return nested;
  }

  // Reading-endpoint shape: a flat "image" bucket already resolved
  // to whichever direction was drawn.
  if (orientation !== "back") {
    return pickStyle(report?.image);
  }

  return pickStyle(report?.back);
}

/*
|--------------------------------------------------------------------------
| Card Back Candidates
|--------------------------------------------------------------------------
|
| The "artwork" back style has been observed 404ing on S3 even though
| the API lists it, so callers rendering the shared card-back image
| (e.g. a face-down card fan) should try every style in priority order
| via <img onError> rather than trusting the first URL blindly.
|
*/

export function extractCardBackCandidates(report: AnyObject): string[] {
  const nested = pickStyleCandidates(report?.card?.images?.back);

  if (nested.length) {
    return nested;
  }

  return [
    ...pickStyleCandidates(report?.card_images?.back),
    ...pickStyleCandidates(report?.images?.back),
    ...pickStyleCandidates(report?.back),
  ];
}

/*
|--------------------------------------------------------------------------
| Card Image Map (deck grid — one list call instead of 78)
|--------------------------------------------------------------------------
|
| Real shape confirmed from GET /user/tarot/cards:
| { data: { total, major, minor, cards: [
|     { id, name, arcana, suit?, number, images: { upright, reversed, back } },
|     ...
| ] } }
|
*/

export interface CardImageEntry {
  upright?: string;
  reversed?: string;
}

export function parseCardImageMap(raw: any): Record<string, CardImageEntry> {
  const report = unwrapUntil(
    raw,
    (obj) => Array.isArray(obj?.cards) || Array.isArray(obj),
  );

  const list: any[] = Array.isArray(report)
    ? report
    : Array.isArray(report?.cards)
      ? report.cards
      : [];

  const map: Record<string, CardImageEntry> = {};

  for (const item of list) {
    const id = item?.id ?? item?.slug;

    if (!id) {
      continue;
    }

    const upright = pickStyle(item?.images?.upright);
    const reversed = pickStyle(item?.images?.reversed);

    if (upright || reversed) {
      map[id] = { upright, reversed };
    }
  }

  return map;
}

/*
|--------------------------------------------------------------------------
| Card Meaning (health / relationship / career / finance)
|--------------------------------------------------------------------------
|
| Real response shape: "interpretation" is the clean {health,
| relationship, career, finance} object; "reading" duplicates the
| same four fields plus its own (unrelated) image data, so prefer
| interpretation and only fall back to reading if it's missing.
|
*/

export function extractCardMeaning(report: AnyObject): OrientationMeaning | undefined {
  const source = report?.interpretation ?? report?.reading ?? report;

  return extractOrientation(source);
}

export interface ParsedCardDetail {
  name?: string;
  keywordsUpright?: string;
  keywordsReversed?: string;
  meaning?: OrientationMeaning;
  imageUpright?: string;
  imageReversed?: string;
  onePull?: { upright?: string; reversed?: string };
  timing?: string;
  extra: { label: string; text: string }[];
}

export function parseCardDetail(raw: AnyObject): ParsedCardDetail {
  const report = unwrapUntil(raw, looksLikeContent);

  const meaning = extractCardMeaning(report);

  const onePullRaw = report?.one_card_pull ?? report?.yes_no ?? report?.card_pull;

  const onePull = onePullRaw
    ? {
        upright: firstString(onePullRaw, ["upright", "yes"]),
        reversed: firstString(onePullRaw, ["reversed", "no"]),
      }
    : undefined;

  const timing = firstString(report, ["timing", "when"]);

  const known = new Set([
    "card",
    "card_images",
    "reading",
    "interpretation",
    "reading_source",
    "one_card_pull",
    "yes_no",
    "card_pull",
    "timing",
    "when",
    "name",
    "keywords_upright",
    "keywords_reversed",
    ...OVERVIEW_ALIASES,
    "love",
    "love_relationships",
    "relationships",
    "relationship",
    "career",
    "work",
    "finance",
    "money",
    "finances",
    "health",
  ]);

  const extra = collectExtraTextFields(report, known);

  return {
    name: firstString(report, ["name", "card_name", "title"]) ?? firstString(report?.card, ["name"]),
    keywordsUpright: firstString(report, ["keywords_upright", "upright_keywords"]),
    keywordsReversed: firstString(report, ["keywords_reversed", "reversed_keywords"]),
    meaning,
    imageUpright: extractCardImage(report, "upright"),
    imageReversed: extractCardImage(report, "reversed"),
    onePull,
    timing,
    extra,
  };
}

/*
|--------------------------------------------------------------------------
| Reading Result Extraction (one-card / three-card / love / yes-no)
|--------------------------------------------------------------------------
*/

export interface ParsedReading {
  headline?: string;
  answer?: string;
  paragraphs: string[];
  meaning?: OrientationMeaning;
  image?: string;
  extra: { label: string; text: string }[];
}

export function parseReading(raw: AnyObject, direction?: "upright" | "reversed"): ParsedReading {
  const report = unwrapUntil(raw, looksLikeContent);

  const headline = firstString(report, ["title", "headline", "card_name"]);

  const answer = firstString(report, ["answer", "yes_no", "result"]);

  const paragraphKeys = [
    "reading",
    "interpretation",
    "description",
    "message",
    "summary",
    "bot_response",
    "overview",
    "meaning",
  ];

  const paragraphs: string[] = [];

  for (const key of paragraphKeys) {
    const value = report?.[key];

    if (typeof value === "string" && value.trim()) {
      paragraphs.push(value.trim());
    }
  }

  // "reading"/"interpretation" are often an object of
  // health/relationship/career/finance paragraphs instead of a
  // single string — same shape as the card reference endpoint.
  // Some endpoints (e.g. one-card) also duplicate everything one
  // level down inside a single-item "cards" array — fall back to
  // that if the top level doesn't have it directly.
  const primaryCardEntry = Array.isArray(report?.cards) ? report.cards[0] : undefined;

  const meaning = extractCardMeaning(report) ?? (primaryCardEntry && extractCardMeaning(primaryCardEntry));

  const image =
    (direction && extractCardImage(report, direction)) ??
    extractCardImage(report, "upright") ??
    extractCardImage(report, "reversed") ??
    (primaryCardEntry &&
      ((direction && extractCardImage(primaryCardEntry, direction)) ??
        extractCardImage(primaryCardEntry, "upright") ??
        extractCardImage(primaryCardEntry, "reversed")));

  const known = new Set([
    "title",
    "headline",
    "card_name",
    "answer",
    "yes_no",
    "result",
    "card",
    "cards",
    "card_images",
    "reading_source",
    "image",
    "images",
    "back",
    "id",
    "name",
    "arcana",
    "suit",
    "number",
    "relationship",
    "love",
    "love_relationships",
    "relationships",
    "career",
    "work",
    "finance",
    "money",
    "finances",
    "health",
    ...paragraphKeys,
  ]);

  const extra = collectExtraTextFields(report, known);

  return { headline, answer, paragraphs, meaning, image: image || undefined, extra };
}

/*
|--------------------------------------------------------------------------
| Spread Reading Extraction (three-card — an overall reading, plus an
| optional per-card breakdown if the API returns one)
|--------------------------------------------------------------------------
*/

export interface ParsedSpread {
  overall: ParsedReading;
  perCard: ParsedReading[];
}

export function parseSpreadReading(
  raw: AnyObject,
  directions?: ("upright" | "reversed")[],
): ParsedSpread {
  const report = unwrapUntil(raw, looksLikeContent);

  const cardsList = Array.isArray(report?.cards)
    ? report.cards
    : Array.isArray(report?.results)
      ? report.results
      : null;

  const perCard = (cardsList || []).map((entry: AnyObject, index: number) =>
    parseReading(entry, directions?.[index]),
  );

  const overallSource = { ...report };

  delete overallSource.cards;
  delete overallSource.results;

  return {
    overall: parseReading(overallSource),
    perCard,
  };
}

/*
|--------------------------------------------------------------------------
| Generic Text Field Collector (safety net)
|--------------------------------------------------------------------------
|
| Walks any remaining fields not already captured by the named
| extractors above and surfaces every readable string it finds, so a
| response shaped differently than guessed still displays something
| useful instead of silently disappearing.
|
*/

export function collectExtraTextFields(
  obj: any,
  exclude: Set<string>,
  path: string[] = [],
): { label: string; text: string }[] {
  const results: { label: string; text: string }[] = [];

  if (typeof obj === "string") {
    if (obj.trim().length > 15) {
      results.push({ label: formatLabel(path), text: obj.trim() });
    }

    return results;
  }

  if (Array.isArray(obj)) {
    obj.forEach((item, index) => {
      results.push(...collectExtraTextFields(item, exclude, [...path, String(index + 1)]));
    });

    return results;
  }

  if (obj && typeof obj === "object") {
    for (const [key, value] of Object.entries(obj)) {
      if (path.length === 0 && exclude.has(key)) {
        continue;
      }

      results.push(...collectExtraTextFields(value, exclude, [...path, key]));
    }
  }

  return results;
}

function formatLabel(path: string[]): string {
  if (path.length === 0) {
    return "Details";
  }

  return path
    .map((segment) =>
      /^\d+$/.test(segment)
        ? `#${segment}`
        : segment
            .replace(/_/g, " ")
            .replace(/\b\w/g, (letter) => letter.toUpperCase()),
    )
    .join(" — ");
}
