/**
 * Google Flights — free, key-less fallback backend for `flight_search`.
 *
 * Hits Google's internal batchexecute RPC endpoint (the same one the public
 * Google Flights UI calls) and curates the response into the same shape
 * Amadeus produces. No API key, no signup, no Chromium.
 *
 * Exports:
 *   encodeFreq(input)              — pure: build the inner f.req JSON string.
 *   buildBatchexecuteBody(freq)    — pure: wrap the inner payload as form body.
 *   parseBatchexecute(body)        — pure: extract raw offer arrays from the response.
 *   curateGoogleOffer(raw, idx)    — pure: map a raw offer into the unified shape.
 *   googleFlightsSearch(input, o)  — async: full round-trip with cache + wrapFetch.
 *
 * Security:
 *   - Hostname is hard-coded. Zero SSRF surface.
 *   - Response prefix ")]}'" is stripped defensively before any JSON.parse.
 *   - No request/response bodies persisted; only egress metadata via wrapFetch.
 *   - Anonymous: no cookies, no auth header, no account context.
 */

import { wrapFetch } from '../../egress.js';
import { getCached, setCached, makeFlightCacheKey } from '../../flight-cache.js';

// IMPORTANT (2026-05-17): Google rotated the Flights search backend.
//
// Old endpoint (worked at commit 5cb6e8a):
//   POST https://www.google.com/_/TravelFrontendUi/data/batchexecute?rpcids=H4cFwe
//   Payload: 15-slot inner array with IATA codes.
//
// New endpoint (live as of 2026-05-17, captured via DevTools):
//   POST https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetShoppingResults
//   Required query params: f.sid, bl, hl, soc-app=162, soc-platform=1, soc-device=1, _reqid, rt=c
//   Required headers: x-same-domain: 1, Origin: https://www.google.com, Referer: https://www.google.com/travel/flights/search
//   Payload shape (URL-decoded f.req):
//     [null, JSON.stringify([
//       [null, null, null, "<SESSION_TOKEN>"],     // slot 0: server-minted session token, ~50 chars with "---------" pattern
//       [null, null, 1=trip_type, null, [], 1=cabin, [adults, kids_under_2, kids_2_to_11, kids_12plus],
//        null, null, null, null, null, null,
//        [                                          // legs array
//          [
//            [[[[KG_ID, ENTITY_TYPE]]]],            // origin: knowledge-graph id + entity type
//            [[[KG_ID, ENTITY_TYPE]]],              // destination: knowledge-graph id + entity type
//            null, 0=stops, null, null,
//            "YYYY-MM-DD"=date,
//            null × 7, 3
//          ],
//          // additional leg for round-trip
//        ],
//        null, null, null, 1
//       ],
//       0, 0, 0, 1
//     ])]
//
// Knowledge Graph IDs (NOT IATA): Dubai = "/m/01f08r" entity type 4; London = "/m/04jpl" entity type 5.
// IATA → KG mapping is not derivable cheaply; the public autocomplete endpoint is the closest source.
//
// Blocking: slot-0 session token is minted by runtime JS on /travel/flights/search?tfs=... load.
// It cannot be bootstrapped from raw HTTP (verified: NID cookies + fresh f.sid + null/empty/zero/fake
// tokens all return HTTP 400 sub-code 3). The browser likely fires a bootstrap XHR after page load
// that mints (sid, session_token) — but the search page HTML has AF_dataServiceRequests = {} (empty),
// so the token is not server-rendered. Replaying a captured search payload as-is DOES work from a
// non-browser caller (no cookies, no anti-bot blob needed) — but the token cannot be minted offline.
//
// Unblock paths (see specs/active/google-flights-schema-repair.md):
//   (a) One more browser capture: ALL batchexecute / GetShoppingResults / *FlightsFrontendService* XHRs
//       fired in the first 2 seconds after navigating to /travel/flights/search?tfs=<query>. Find the
//       call whose response contains the session token, fold its bootstrap into this adapter.
//   (b) Playwright/Chromium: run the search page in a real browser, capture the token from the live
//       page, then make the search XHR. Heavy and slow.
//   (c) Switch the free fallback to a different provider — Travelpayouts / Tequila by Kiwi / Duffel —
//       each with their own credential and rate-limit profile.
//
// Until one of (a)–(c) lands, this adapter throws immediately without making a network call. The
// router in lib/tools/adapters/travel.js prefers Amadeus when AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET
// are present, so operators with those credentials in the secrets vault keep flight_search working.
const BATCHEXECUTE_URL = 'https://www.google.com/_/TravelFrontendUi/data/batchexecute';
const RPC_ID = 'H4cFwe';

const TRIP_ONE_WAY = 1;
const TRIP_ROUND = 2;
const CABIN_CODE = { ECONOMY: 1, PREMIUM_ECONOMY: 2, BUSINESS: 3, FIRST: 4 };
const STOPS_ANY = 0;
const STOPS_NON_STOP = 1;

// ── Input normalization (shared with Amadeus path via duplication; intentional) ──

function clampLimit(value, fallback, min, max) {
  const n = Number(value);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(Math.max(Math.trunc(n), min), max);
}

function normIata(value, name) {
  const str = String(value || '').trim().toUpperCase();
  if (!/^[A-Z]{3}$/.test(str)) {
    throw new Error(`"${name}" must be a 3-letter IATA code (got: ${value ?? ''})`);
  }
  return str;
}

function normDate(value, name) {
  const str = String(value || '').trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) {
    throw new Error(`"${name}" must be ISO date YYYY-MM-DD (got: ${value ?? ''})`);
  }
  return str;
}

function normalizeInput(input = {}) {
  const origin = normIata(input.origin, 'origin');
  const destination = normIata(input.destination, 'destination');
  const departure_date = normDate(input.departure_date, 'departure_date');
  const return_date = input.return_date ? normDate(input.return_date, 'return_date') : null;
  const adults = clampLimit(input.adults, 1, 1, 9);
  const children = clampLimit(input.children, 0, 0, 8);
  const cabinKey = input.travel_class ? String(input.travel_class).toUpperCase() : 'ECONOMY';
  const travel_class = CABIN_CODE[cabinKey] ? cabinKey : 'ECONOMY';
  const non_stop = input.non_stop === true || input.non_stop === 'true';
  return { origin, destination, departure_date, return_date, adults, children, travel_class, non_stop };
}

// ── Encoder ────────────────────────────────────────────────────────────────────

/**
 * Build the inner f.req JSON string for one Google Flights query.
 * Output shape (per spec):
 *   [null, "[[\"DXB\"],[\"LHR\"],\"2026-05-18\",null,[],1,[],null,null,null,null,1,null,null,1]"]
 *
 * Slot 11 = cabin (1=econ, 2=prem-econ, 3=biz, 4=first).
 * Slot 14 = stops (0=any, 1=non-stop only).
 */
export function encodeFreq(rawInput = {}) {
  const input = normalizeInput(rawInput);
  const cabin = CABIN_CODE[input.travel_class];
  const stops = input.non_stop ? STOPS_NON_STOP : STOPS_ANY;
  // Child passenger ages — Google uses age numbers; we send 11 (typical child) per kid.
  const childAges = input.children > 0 ? new Array(input.children).fill(11) : [];

  const inner = [
    [input.origin],
    [input.destination],
    input.departure_date,
    input.return_date,
    [],
    input.adults,
    childAges,
    null,
    null,
    null,
    null,
    cabin,
    null,
    null,
    stops,
  ];

  return JSON.stringify([null, JSON.stringify(inner)]);
}

/**
 * Build the form-encoded body wrapping an f.req payload.
 */
export function buildBatchexecuteBody(freqJson) {
  const envelope = [[[RPC_ID, freqJson, null, 'generic']]];
  const form = new URLSearchParams();
  form.set('f.req', JSON.stringify(envelope));
  return form.toString();
}

// ── Parser ─────────────────────────────────────────────────────────────────────

/**
 * Parse the batchexecute response into a list of raw offer arrays.
 *
 * Response shape:
 *   )]}'
 *   <decimal length>
 *   [["wrb.fr","<rpc_id>","<inner JSON string>", null, null, null, "generic"], ...]
 *   <decimal length>
 *   [...next frame...]
 *
 * Strategy: skip the XSSI prefix and any decimal length tokens, then balance-scan
 * for each top-level JSON array frame. Find the wrb.fr frame matching our RPC,
 * JSON.parse the inner string, then walk for offer-shaped arrays.
 */
export function parseBatchexecute(body) {
  if (typeof body !== 'string') return { offers: [], warning: 'response is not text' };
  let text = body.trim();
  if (text.startsWith(")]}'")) text = text.slice(4).trim();

  const frames = scanJsonFrames(text);
  let inner = null;
  for (const frame of frames) {
    if (!Array.isArray(frame)) continue;
    for (const row of frame) {
      if (Array.isArray(row) && row[0] === 'wrb.fr' && row[1] === RPC_ID && typeof row[2] === 'string') {
        try {
          inner = JSON.parse(row[2]);
        } catch {
          return { offers: [], warning: 'inner RPC payload was not valid JSON' };
        }
        break;
      }
    }
    if (inner) break;
  }
  if (!inner) return { offers: [], warning: 'no RPC frame matched' };

  const offers = [];
  walkForOffers(inner, 0, offers);
  return { offers, warning: offers.length ? null : 'no offers parsed from response' };
}

function scanJsonFrames(text) {
  const frames = [];
  let i = 0;
  while (i < text.length) {
    while (i < text.length && /\s/.test(text[i])) i++;
    if (i < text.length && /\d/.test(text[i])) {
      while (i < text.length && /\d/.test(text[i])) i++;
      continue;
    }
    if (text[i] === '[') {
      let depth = 0;
      let inString = false;
      let escape = false;
      const start = i;
      for (; i < text.length; i++) {
        const ch = text[i];
        if (inString) {
          if (escape) { escape = false; continue; }
          if (ch === '\\') { escape = true; continue; }
          if (ch === '"') inString = false;
          continue;
        }
        if (ch === '"') { inString = true; continue; }
        if (ch === '[') depth++;
        else if (ch === ']') {
          depth--;
          if (depth === 0) {
            i++;
            try {
              frames.push(JSON.parse(text.slice(start, i)));
            } catch {
              // Skip malformed frame; keep scanning.
            }
            break;
          }
        }
      }
      continue;
    }
    i++;
  }
  return frames;
}

function isPricePair(node) {
  return (
    Array.isArray(node) &&
    node.length >= 2 &&
    typeof node[0] === 'number' &&
    typeof node[1] === 'string' &&
    /^[A-Z]{3}$/.test(node[1])
  );
}

function hasDirectPrice(node) {
  if (!Array.isArray(node)) return false;
  for (const c of node) {
    if (isPricePair(c)) return true;
  }
  return false;
}

function looksLikeOfferList(node) {
  if (!Array.isArray(node) || node.length === 0) return false;
  const arrayChildren = node.filter(x => Array.isArray(x));
  if (arrayChildren.length === 0) return false;
  let matches = 0;
  for (const item of arrayChildren) {
    if (hasDirectPrice(item)) matches++;
  }
  return matches >= 1 && matches === arrayChildren.length;
}

function walkForOffers(node, depth, sink) {
  if (depth > 10 || !Array.isArray(node)) return;
  if (looksLikeOfferList(node)) {
    for (const child of node) {
      if (Array.isArray(child)) sink.push(child);
    }
    return;
  }
  if (hasDirectPrice(node)) {
    sink.push(node);
    return;
  }
  for (const child of node) walkForOffers(child, depth + 1, sink);
}

// ── Curator ────────────────────────────────────────────────────────────────────

function findPriceDeep(offer) {
  function recurse(n, depth) {
    if (depth > 4 || !Array.isArray(n)) return null;
    if (isPricePair(n)) return { total: n[0], currency: n[1] };
    for (const c of n) {
      const found = recurse(c, depth + 1);
      if (found) return found;
    }
    return null;
  }
  return recurse(offer, 0);
}

function parseSegment(node) {
  let departIata = null;
  let arriveIata = null;
  let depart = null;
  let arrive = null;
  let carrier = null;
  let flightNumber = null;
  let durationMin = null;
  function recurse(n, depth) {
    if (depth > 5) return;
    if (Array.isArray(n)) { for (const c of n) recurse(c, depth + 1); return; }
    if (typeof n === 'number') {
      if (durationMin == null && n > 30 && n < 1500 && Number.isInteger(n)) durationMin = n;
      return;
    }
    if (typeof n !== 'string') return;
    if (/^[A-Z]{3}$/.test(n)) {
      if (!departIata) departIata = n;
      else if (!arriveIata && n !== departIata) arriveIata = n;
    } else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(n)) {
      if (!depart) depart = n;
      else if (!arrive) arrive = n;
    } else if (/^[A-Z]{2,3}\s?\d{1,4}$/.test(n) && !flightNumber) {
      flightNumber = n;
    } else if (/^[A-Z]{2,3}$/.test(n) && !carrier) {
      carrier = n;
    }
  }
  recurse(node, 0);
  if (!departIata || !arriveIata) return null;
  return {
    carrier,
    flight_number: flightNumber,
    departure: { iata: departIata, at: depart, terminal: null },
    arrival: { iata: arriveIata, at: arrive, terminal: null },
    duration_minutes: durationMin,
    aircraft: null,
  };
}

function extractLegs(raw) {
  const legs = [];
  function recurse(n, depth) {
    if (depth > 6 || !Array.isArray(n)) return;
    const segs = [];
    for (const child of n) {
      if (Array.isArray(child)) {
        const seg = parseSegment(child);
        if (seg) segs.push(seg);
      }
    }
    if (segs.length > 0) {
      legs.push({
        segments: segs,
        duration_minutes: segs.reduce((s, sg) => s + (sg.duration_minutes || 0), 0) || null,
      });
      return;
    }
    for (const child of n) recurse(child, depth + 1);
  }
  recurse(raw, 0);
  return legs;
}

function extractBookingLink(raw) {
  let link = null;
  function recurse(n, depth) {
    if (link || depth > 6) return;
    if (typeof n === 'string') {
      if (/^https?:\/\/(www\.)?google\.com\/[^"\s]*flights/i.test(n)) link = n;
      return;
    }
    if (Array.isArray(n)) for (const c of n) recurse(c, depth + 1);
  }
  recurse(raw, 0);
  return link;
}

/**
 * Map a raw offer array into the unified flight_search shape.
 */
export function curateGoogleOffer(raw, idx = 0) {
  const price = findPriceDeep(raw) || { total: null, currency: null };
  const legs = extractLegs(raw);
  return {
    id: `gf-${idx}`,
    price: { total: price.total, currency: price.currency, base: null },
    seats_available: null,
    last_ticketing_date: null,
    itineraries: legs.length > 0
      ? legs.map(leg => ({
          duration: leg.duration_minutes != null ? `PT${leg.duration_minutes}M` : null,
          duration_minutes: leg.duration_minutes,
          stops: Math.max(0, (leg.segments || []).length - 1),
          segments: leg.segments || [],
        }))
      : [{ duration: null, duration_minutes: null, stops: 0, segments: [] }],
    validating_airlines: [],
    cabin: null,
    booking_link: extractBookingLink(raw),
  };
}

// ── Search ─────────────────────────────────────────────────────────────────────

/**
 * Hit the Google Flights backend and return curated offers.
 *
 * Throws on 429 with a clear retry message; on other non-2xx with a clean code.
 * Returns `{ count: 0, offers: [], source: { parse_warning } }` when the parser
 * cannot find offers — lets the caller disclose "no offers" instead of refusing.
 */
export async function googleFlightsSearch(input = {}, options = {}) {
  const normalized = normalizeInput(input);
  const max = clampLimit(input.max_results, 5, 1, 25);
  const currency = input.currency ? String(input.currency).toUpperCase().slice(0, 3) : 'USD';

  const cacheKey = makeFlightCacheKey({ ...normalized, currency });
  const cached = getCached(cacheKey);
  if (cached) {
    return {
      ...cached,
      source: { ...cached.source, cache_hit: true, fetched_at: cached.source?.fetched_at || new Date().toISOString() },
    };
  }

  // Hard-fail without a network round-trip. The legacy batchexecute schema
  // this adapter encodes is gone (see file-top comment for the new endpoint
  // and payload shape). Hitting it returns HTTP 400 every time. Throwing
  // here saves ~200ms per call plus an egress log row per call, and gives
  // the operator a single actionable next step.
  if (process.env.MADARX_GOOGLE_FLIGHTS_FORCE_LIVE !== '1') {
    throw new Error(
      'flight_search: free Google Flights fallback is offline as of 2026-05-17 (Google rotated the backend endpoint and payload schema, and slot-0 session tokens cannot be bootstrapped from raw HTTP). To restore flight_search, add AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET to the secrets vault — free tier covers 500 calls/month. See specs/active/google-flights-schema-repair.md and lib/tools/adapters/google-flights.js header for the full repair plan.'
    );
  }

  const freq = encodeFreq(normalized);
  const body = buildBatchexecuteBody(freq);

  const wrappedFetch = wrapFetch('flight_search', options.agent_id ?? null);
  const url = `${BATCHEXECUTE_URL}?rpcids=${RPC_ID}&source-path=%2Ftravel%2Fflights%2Fsearch&rt=c`;
  const res = await wrappedFetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
      'User-Agent': 'Mozilla/5.0 (compatible; madarx-os/1.0; +https://madarx.io)',
      Accept: '*/*',
    },
    body,
  });

  if (res.status === 429) {
    throw new Error('flight_search: provider throttled by Google Flights, retry in a few minutes');
  }
  if (!res.ok) {
    // Bubble a structured hint so the agent and the operator both know this
    // is a provider-side / encoder-schema issue, not a query problem. Google
    // rotates the batchexecute payload shape periodically; when that happens
    // the encoder needs to be re-derived against a live UI session capture.
    throw new Error(
      `Google Flights backend failed (HTTP ${res.status}). This usually means Google has rotated the batchexecute payload schema and the encoder in lib/tools/adapters/google-flights.js needs to be refreshed. Not a date-range or query problem. To unblock, add AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET to the secrets vault — flight_search will then use Amadeus instead of the free fallback.`
    );
  }

  const text = await res.text();
  const parsed = parseBatchexecute(text);
  const offers = parsed.offers.slice(0, max).map((raw, idx) => curateGoogleOffer(raw, idx));

  const result = {
    query: {
      origin: normalized.origin,
      destination: normalized.destination,
      departure_date: normalized.departure_date,
      return_date: normalized.return_date,
      adults: normalized.adults,
      children: normalized.children,
      currency,
      travel_class: normalized.travel_class,
      non_stop: normalized.non_stop,
      max_results: max,
    },
    count: offers.length,
    offers,
    source: {
      provider: 'google_flights_web',
      fetched_at: new Date().toISOString(),
      ...(parsed.warning ? { parse_warning: parsed.warning } : {}),
    },
  };

  if (offers.length > 0) setCached(cacheKey, result);
  return result;
}
