import { ViewpointBooking } from "@karma/viewpoint-sdk/booking";
import type { Booking } from "@karma/viewpoint-sdk/types";
import { logError } from "@/lib/logger";
import type { ViewpointImportRepository } from "./viewpoint-import.repo";

/**
 * On-demand refresh of imported bookings from the Viewpoint API.
 *
 * An imported status is only true as of the moment the report was exported — a
 * booking that was HOLD yesterday may be cancelled today. Viewpoint exposes no bulk
 * listing, only `GET /Booking/{number}`, so a refresh is one request per booking.
 * That shapes everything here: runs are capped, concurrency is limited, and the
 * queue is ordered oldest-synced-first so a capped run always makes progress
 * instead of re-reading the same head of the list.
 *
 * This is the first code in core to call the booking half of the SDK — the member
 * half is used elsewhere, `ViewpointBooking` was not used anywhere. So failures are
 * recorded per booking rather than assumed away.
 */

/**
 * How many bookings one run will touch.
 *
 * Bounded because each is a separate outbound request to a third party; an
 * unbounded run over a large import would take minutes and hold the HTTP request
 * open. The caller can run it again — the oldest-first ordering means the next run
 * picks up where this one stopped.
 */
export const SYNC_BATCH_LIMIT = 200;

/**
 * Requests in flight at once.
 *
 * Deliberately small. Viewpoint is a shared production system and this is a
 * convenience refresh, not something worth risking its throughput over.
 */
const SYNC_CONCURRENCY = 4;

export interface SyncOutcome {
  attempted: number;
  updated: number;
  notFound: number;
  failed: number;
  errors: Array<{ bookingNo: string; message: string }>;
}

/**
 * Maps an API booking onto the stored columns.
 *
 * Only fields the API actually returned are included. A missing value is left out
 * rather than written as null, so a sync cannot blank a column the import filled —
 * `membership_number` in particular has no API equivalent (the API gives
 * `AccountID`), and it is the key the whole member join depends on.
 */
export function mapApiBooking(booking: Booking): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  const put = (key: string, value: unknown) => {
    if (value === null || value === undefined) return;
    if (typeof value === "string" && value.trim() === "") return;
    out[key] = typeof value === "string" ? value.trim() : value;
  };

  put("status", booking.Status);
  put("booking_source", booking.BookingSource);
  put("booking_source_code", booking.BookingSourceCode);
  put("booking_type", booking.BookingType);
  put("account_id", booking.AccountID);
  put("resort_code", booking.ResortCode);
  put("resort_name", booking.ResortName);
  put("room_type", booking.RoomType);
  put("guest_name", booking.GuestName);
  put("arrival", isoDate(booking.Arrival));
  put("departure", isoDate(booking.Departure));
  put("modify_date_time", isoTimestamp(booking.ModifyDateTime));

  // Only when they are real numbers: the API sends 0 for "none", which is a
  // genuine 0 and must be kept, but a non-numeric would otherwise become NaN.
  if (Number.isFinite(Number(booking.Adults))) out.adults = Number(booking.Adults);
  if (Number.isFinite(Number(booking.Children))) {
    out.children = Number(booking.Children);
  }

  return out;
}

/** ISO date part, or undefined when the value isn't a usable date. */
function isoDate(value: string | null | undefined): string | undefined {
  const iso = isoTimestamp(value);
  return iso ? iso.slice(0, 10) : undefined;
}

/**
 * The API's own date strings are ISO or ISO-like, so `Date` is safe here — unlike
 * spreadsheet values, which are day-first and are parsed by hand in the repository.
 */
function isoTimestamp(value: string | null | undefined): string | undefined {
  if (!value) return undefined;
  const d = new Date(value);
  return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}

/**
 * Refreshes up to `limit` bookings, oldest-synced first.
 *
 * `bookingNumbers` overrides the queue when the caller wants specific bookings —
 * that is what the row-level "sync this one" action uses.
 */
export async function syncBookingsFromViewpoint(
  repo: ViewpointImportRepository,
  opts: { limit?: number; bookingNumbers?: string[] } = {},
): Promise<SyncOutcome> {
  const limit = Math.min(opts.limit ?? SYNC_BATCH_LIMIT, SYNC_BATCH_LIMIT);
  const numbers = opts.bookingNumbers?.length
    ? opts.bookingNumbers.slice(0, limit)
    : await repo.bookingNumbersToSync(limit);

  const outcome: SyncOutcome = {
    attempted: numbers.length,
    updated: 0,
    notFound: 0,
    failed: 0,
    errors: [],
  };
  if (numbers.length === 0) return outcome;

  /*
   * Constructed once, and here rather than at module load: the SDK reads
   * VIEWPOINT_API / VIEWPOINT_API_KEY in its constructor and throws when either is
   * missing, which at module scope would take core down at import time over a
   * feature nobody had asked for yet.
   */
  let client: ViewpointBooking;
  try {
    client = new ViewpointBooking();
  } catch (err) {
    const message =
      (err as Error)?.message ?? "Viewpoint API is not configured";
    logError(err);
    outcome.failed = numbers.length;
    outcome.errors.push({ bookingNo: "*", message });
    return outcome;
  }

  // A shared cursor rather than fixed slices, so one slow booking doesn't idle a
  // worker that could be doing the next one.
  let cursor = 0;
  const worker = async () => {
    for (;;) {
      const index = cursor++;
      if (index >= numbers.length) return;
      const bookingNo = numbers[index];
      try {
        const { booking, error } = await client.getBooking(bookingNo);

        if (error) {
          outcome.failed += 1;
          const message = (error as Error)?.message ?? "Viewpoint request failed";
          outcome.errors.push({ bookingNo, message });
          await repo.recordSyncFailure(bookingNo, message);
          continue;
        }

        /*
         * A null booking with no error is the SDK's shape for a non-OK response,
         * which for a single-resource GET is overwhelmingly "no such booking". It
         * is counted separately from a failure and recorded on the row, because a
         * booking number the report mangled is a data-quality problem for the
         * operator to see, not a transient fault to retry forever.
         */
        if (!booking) {
          outcome.notFound += 1;
          await repo.recordSyncFailure(
            bookingNo,
            "Not found in Viewpoint (or request rejected)",
          );
          continue;
        }

        const values = mapApiBooking(booking);
        if (Object.keys(values).length === 0) {
          // Reached Viewpoint but it told us nothing usable. Stamped as an
          // attempt so it moves down the queue rather than blocking it.
          outcome.notFound += 1;
          await repo.recordSyncFailure(
            bookingNo,
            "Viewpoint returned no usable fields",
          );
          continue;
        }

        await repo.applySyncedBooking(bookingNo, values);
        outcome.updated += 1;
      } catch (err) {
        outcome.failed += 1;
        const message = (err as Error)?.message ?? "Unexpected sync error";
        logError(err);
        outcome.errors.push({ bookingNo, message });
        // Best effort: if recording the failure also fails there is nothing left
        // to do but keep going, rather than abandoning the rest of the batch.
        await repo.recordSyncFailure(bookingNo, message).catch(() => {});
      }
    }
  };

  await Promise.all(
    Array.from({ length: Math.min(SYNC_CONCURRENCY, numbers.length) }, worker),
  );

  return outcome;
}
