import type { Context } from "hono";
import { z } from "zod";
import { logError } from "@/lib/logger";
import { error as errorResponse, getErrorMessage, success } from "@/lib/response";
import {
  ViewpointImportRepository,
  type BookingFilters,
} from "@/internal/viewpoint-import/viewpoint-import.repo";
import {
  SYNC_BATCH_LIMIT,
  syncBookingsFromViewpoint,
} from "@/internal/viewpoint-import/viewpoint-sync.service";
import {
  VIEWPOINT_FIELDS,
  VIEWPOINT_REPORT_TYPES,
  guessField,
  type ViewpointReportType,
} from "@/internal/viewpoint-import/viewpoint-fields";

/**
 * Manually imported Viewpoint reports.
 *
 * Console2 only sees bookings created through core, which is a subset of what
 * Viewpoint holds. A super admin exports Viewpoint's own reports and uploads them
 * here; everyone with booking read access can then read the result.
 *
 * The spreadsheet is parsed in the browser, not here. That keeps the header-preview
 * step instant (no round trip before the operator can see the sheet's field titles),
 * avoids multipart handling in core, and means core receives plain JSON rows it can
 * validate like any other payload. exceljs is a console dependency and is not
 * installed here, which is the other half of the reason.
 */

function repo(c: Context) {
  return new ViewpointImportRepository(
    c.get("datastore"),
    c.get("memberDatastore"),
  );
}

function isSuperAdmin(c: Context): boolean {
  return c.get("isAdminConsoleSuperAdmin") === true;
}

/**
 * Refuses an action reserved for super admins.
 *
 * 404, not 403, on purpose: the console's `coreClient` treats *every* 403 as a dead
 * session and force-logs the user out, so a legitimate "you may not do this" would
 * throw them to the login page. Until that is narrowed to authentication failures,
 * a not-found is the honest-enough answer that doesn't destroy the session.
 */
function forbidden(c: Context) {
  return errorResponse(c, "Not found", 404);
}

const ReportTypeSchema = z.enum(VIEWPOINT_REPORT_TYPES);

/**
 * An import commit.
 *
 * `columnMap` is keyed by the sheet's own header text, because that is what the
 * operator was shown and chose against. Rows arrive as objects keyed by the same
 * headers, so the mapping and the data cannot drift out of step.
 */
const CommitImportSchema = z.object({
  reportType: ReportTypeSchema,
  filename: z.string().min(1).max(255),
  sheetName: z.string().max(255).nullish(),
  sheetColumns: z.array(z.string()).default([]),
  columnMap: z.record(z.string(), z.string()),
  /*
   * Capped so one upload cannot exhaust core's memory or hold a request open for
   * minutes. Well above a daily Viewpoint export; the console splits anything
   * larger into several commits against the same import.
   */
  rows: z
    .array(z.record(z.string(), z.unknown()))
    .min(1)
    .max(20000),
});

const BookingQuerySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(200).default(50),
  /** Subito vs Viewpoint-direct — the booking's source in business terms. */
  origins: z.array(z.enum(["subito", "viewpoint", "unchecked"])).optional(),
  /** The report's own source column, which is a different thing. */
  sources: z.array(z.string()).optional(),
  statuses: z.array(z.string()).optional(),
  from: z.string().optional(),
  to: z.string().optional(),
  membershipNumbers: z.array(z.string()).optional(),
  search: z.string().optional(),
  onlyUnmatched: z.boolean().optional(),
});

const SyncSchema = z.object({
  /** Specific bookings, for the per-row sync action. Omit to work the queue. */
  bookingNumbers: z.array(z.string().min(1)).max(SYNC_BATCH_LIMIT).optional(),
  limit: z.coerce.number().int().min(1).max(SYNC_BATCH_LIMIT).optional(),
});

/**
 * The canonical fields, and a suggested mapping for the headers just read.
 *
 * Served from core rather than restated in the console so one list drives the
 * mapping UI, the commit validation and the upsert's column set. The console posts
 * the headers it found and gets back both the field catalogue and a per-header
 * guess, which it pre-selects and the operator can override.
 */
export const viewpointFieldsHandler = async (c: Context) => {
  try {
    const body = (await c.req.json().catch(() => ({}))) as {
      reportType?: string;
      headers?: string[];
    };
    const parsedType = ReportTypeSchema.safeParse(body.reportType);
    if (!parsedType.success) {
      return errorResponse(c, "Unknown report type", 400);
    }
    const reportType = parsedType.data;
    const headers = Array.isArray(body.headers) ? body.headers : [];

    return success(
      c,
      {
        fields: VIEWPOINT_FIELDS[reportType].map((f) => ({
          key: f.key,
          label: f.label,
          kind: f.kind,
          required: Boolean(f.required),
        })),
        suggested: Object.fromEntries(
          headers
            .map((h) => [h, guessField(h, reportType)] as const)
            .filter(([, key]) => key !== null),
        ),
      },
      "Viewpoint fields fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(c, getErrorMessage(err, "Failed to fetch fields"), 500);
  }
};

/**
 * Stores an import.
 *
 * The import row is written before the data rows so a failure part-way through
 * still leaves a record that the upload was attempted, rather than vanishing.
 * Rows missing their natural key are reported back per row number instead of being
 * written — see `prepareRows`.
 */
export const commitViewpointImportHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);

  try {
    const body = await c.req.json();
    const parsed = CommitImportSchema.safeParse(body);
    if (!parsed.success) {
      return errorResponse(
        c,
        parsed.error.issues[0]?.message ?? "Invalid import payload",
        400,
      );
    }
    const input = parsed.data;
    const reportType = input.reportType as ViewpointReportType;

    /*
     * A mapping that never names the natural key can only produce rows that are all
     * skipped, so it is rejected up front with a message that says which field is
     * missing — rather than accepting the upload and reporting every row as bad.
     */
    const required = VIEWPOINT_FIELDS[reportType].filter((f) => f.required);
    const mapped = new Set(Object.values(input.columnMap));
    const unmappedRequired = required.filter((f) => !mapped.has(f.key));
    if (unmappedRequired.length > 0) {
      return errorResponse(
        c,
        `Map a column to ${unmappedRequired
          .map((f) => f.label)
          .join(", ")} before importing — it identifies the row on re-import.`,
        400,
      );
    }

    const r = repo(c);
    const { prepared, skipped } = r.prepareRows(
      reportType,
      input.columnMap,
      input.rows,
    );

    const importRow = await r.createImport({
      reportType,
      filename: input.filename,
      sheetName: input.sheetName ?? null,
      uploadedBy: String(c.get("adminEmail") ?? "") || null,
      uploadedById: String(c.get("consoleUserId") ?? "") || null,
      sheetColumns: input.sheetColumns,
      columnMap: input.columnMap,
      rowCount: input.rows.length,
    });

    const counts =
      prepared.length > 0
        ? await r.upsertRows(reportType, importRow.id, prepared)
        : { inserted: 0, updated: 0 };

    await r.finishImport(importRow.id, {
      inserted: counts.inserted,
      updated: counts.updated,
      skipped: skipped.length,
    });

    return success(
      c,
      {
        importId: importRow.id,
        rowCount: input.rows.length,
        inserted: counts.inserted,
        updated: counts.updated,
        skippedCount: skipped.length,
        // Truncated: a badly mapped sheet can skip every row, and returning
        // thousands of identical reasons helps nobody.
        skipped: skipped.slice(0, 50),
      },
      `Imported ${counts.inserted + counts.updated} row${
        counts.inserted + counts.updated === 1 ? "" : "s"
      }`,
      200,
    );
  } catch (err) {
    logError(err);
    /*
     * A value too long for its column is reported as something the operator can act
     * on. Postgres says only `value too long for type character varying(255)` — no
     * column, no row — and passing that through left the whole import failing with
     * nothing to go on. It is worth naming because it aborts the entire batch: one
     * over-long cell in one row means none of the rows are stored.
     */
    const message = getErrorMessage(err, "Failed to import the report");
    if (/value too long/i.test(message)) {
      return errorResponse(
        c,
        "One of the mapped columns contains a value longer than the field allows, so nothing was imported. Leave that column out, or ask for the field to be widened.",
        400,
      );
    }
    return errorResponse(c, message, 500);
  }
};

export const listViewpointImportsHandler = async (c: Context) => {
  try {
    const raw = c.req.query("reportType");
    const parsed = ReportTypeSchema.safeParse(raw);
    const r = repo(c);
    const items = await r.listImports(parsed.success ? parsed.data : undefined);

    /*
     * `storedRows` is how many rows each upload *currently* accounts for, which is
     * not its original `inserted_count`. A later import of the same bookings takes
     * those rows over, so an old batch's live count drops — and deleting it would
     * therefore remove fewer rows than it originally wrote. Showing the live number
     * is what makes the delete confirmation truthful.
     */
    const withCounts = await Promise.all(
      items.map(async (item: any) => ({
        ...item,
        storedRows: await r.countRowsForImport(item.report_type, item.id),
      })),
    );

    return success(
      c,
      { items: withCounts },
      "Import history fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to fetch import history"),
      500,
    );
  }
};

function bookingFilters(input: z.infer<typeof BookingQuerySchema>): BookingFilters {
  return {
    origins: input.origins?.length ? input.origins : undefined,
    sources: input.sources?.length ? input.sources : undefined,
    statuses: input.statuses?.length ? input.statuses : undefined,
    from: input.from,
    to: input.to,
    membershipNumbers: input.membershipNumbers?.length
      ? input.membershipNumbers
      : undefined,
    search: input.search,
    onlyUnmatched: input.onlyUnmatched,
  };
}

export const listViewpointBookingsHandler = async (c: Context) => {
  try {
    const body = await c.req.json().catch(() => ({}));
    const parsed = BookingQuerySchema.safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid booking query", 400);
    }
    const input = parsed.data;
    const result = await repo(c).listBookings({
      ...bookingFilters(input),
      page: input.page,
      pageSize: input.pageSize,
    });
    return success(
      c,
      {
        items: result.items,
        pagination: {
          page: input.page,
          pageSize: input.pageSize,
          total: result.total,
        },
      },
      "Viewpoint bookings fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to fetch Viewpoint bookings"),
      500,
    );
  }
};

/** The by-source and by-status roll-ups, plus the filter options in one call. */
export const viewpointBookingAnalyticsHandler = async (c: Context) => {
  try {
    const body = await c.req.json().catch(() => ({}));
    const parsed = BookingQuerySchema.safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid booking query", 400);
    }
    const r = repo(c);
    const [aggregates, options, origin, dimensions] = await Promise.all([
      r.bookingAggregates(bookingFilters(parsed.data)),
      /*
       * Deliberately unfiltered. The picker must keep offering a source even while
       * that source is the one filtered out, or deselecting it would be impossible.
       */
      r.bookingFilterOptions(),
      /*
       * Deliberately unfiltered, like the picker options above: the origin split
       * describes everything imported, and narrowing it to the current filters would
       * make "38 also in core" mean something different on every screen.
       */
      r.originBreakdown(),
      /*
       * Filtered, unlike the options and the origin split: these are charts of the
       * current view, so narrowing the table must narrow them too.
       */
      r.bookingDimensions(bookingFilters(parsed.data)),
    ]);
    return success(
      c,
      { ...aggregates, options, origin, ...dimensions },
      "Viewpoint booking analytics fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to fetch Viewpoint booking analytics"),
      500,
    );
  }
};

export const listViewpointMemberContactsHandler = async (c: Context) => {
  try {
    const body = (await c.req.json().catch(() => ({}))) as Record<
      string,
      unknown
    >;
    const parsed = z
      .object({
        page: z.coerce.number().int().min(1).default(1),
        pageSize: z.coerce.number().int().min(1).max(200).default(50),
        search: z.string().optional(),
        onlyUnmatched: z.boolean().optional(),
      })
      .safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid contact query", 400);
    }
    const result = await repo(c).listMemberContacts(parsed.data);
    return success(
      c,
      {
        items: result.items,
        pagination: {
          page: parsed.data.page,
          pageSize: parsed.data.pageSize,
          total: result.total,
        },
      },
      "Viewpoint member contacts fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to fetch Viewpoint member contacts"),
      500,
    );
  }
};

/**
 * Viewpoint-direct bookings per promo code, for the campaigns dashboard.
 *
 * Only bookings core does not already have, so the figure is additive to the
 * dashboard's own booking counts rather than overlapping them. Attributed through the
 * membership number the report carries; unattributable bookings are simply absent
 * rather than lumped onto a campaign.
 *
 * Its own endpoint rather than part of the campaign analytics: the campaigns dashboard
 * already makes several calls, and a report nobody has imported should cost that page
 * one empty response, not a join it cannot use.
 */
export const viewpointDirectByPromoCodeHandler = async (c: Context) => {
  try {
    const items = await repo(c).viewpointDirectByPromoCode();
    return success(
      c,
      {
        items,
        total: items.reduce((sum, r) => sum + r.bookings, 0),
      },
      "Viewpoint-direct bookings fetched successfully",
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to fetch Viewpoint-direct bookings"),
      500,
    );
  }
};

/**
 * Reconciles imported bookings against core's own booking records.
 *
 * Core stores Viewpoint's booking number against bookings it created, so a match means
 * the booking came through Subito and exists in both systems; no match means it was
 * made directly in Viewpoint. On a real import 38 of 160 matched, so the split is
 * substantial rather than incidental.
 *
 * Read-only against the member DB. This writes only to console2's own table, and only
 * the reconciliation columns — nothing Viewpoint or core told us is overwritten,
 * including core's status, which is stored alongside Viewpoint's precisely so the two
 * can be compared.
 *
 * Not super-admin gated: it reads data the caller can already see and writes nothing
 * a user would notice beyond a freshness stamp. It does take the `update` privilege,
 * because it does write.
 */
export const syncViewpointFromCoreHandler = async (c: Context) => {
  try {
    const outcome = await repo(c).matchAgainstCore();
    return success(
      c,
      outcome,
      outcome.checked === 0
        ? "Nothing imported yet, so there was nothing to reconcile."
        : `Checked ${outcome.checked} booking${outcome.checked === 1 ? "" : "s"}: ${outcome.matched} also in core, ${outcome.unmatched} only in Viewpoint.`,
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to reconcile against core"),
      500,
    );
  }
};

/**
 * How many bookings a set of filters currently matches.
 *
 * Its own endpoint so the delete confirmation can quote a number produced by the
 * same filter builder the delete will use, rather than reusing the table's
 * pagination total — which is a different query and can disagree after a refetch.
 */
export const countViewpointBookingsHandler = async (c: Context) => {
  try {
    const body = await c.req.json().catch(() => ({}));
    const parsed = BookingQuerySchema.safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid booking query", 400);
    }
    const total = await repo(c).countBookingsByFilters(
      bookingFilters(parsed.data),
    );
    return success(c, { total }, "Counted successfully", 200);
  } catch (err) {
    logError(err);
    return errorResponse(c, getErrorMessage(err, "Failed to count bookings"), 500);
  }
};

/**
 * Removes imported rows from the console database.
 *
 * CONSOLE ONLY — VIEWPOINT IS NEVER TOUCHED
 *
 * This deletes what console2 stored. Viewpoint remains the system of record: a
 * booking removed here still exists there and reappears if the same report is
 * imported again. That is deliberate — this is an undo for a mistaken or badly
 * mapped import, not a way to cancel a booking, and nothing in this codebase should
 * be able to destroy a booking in the source system.
 *
 * The upload record in `viewpoint_imports` is kept. It says who uploaded which file,
 * when, and how it was mapped, which is precisely the audit trail for the data being
 * removed; deleting it would erase the evidence of the thing being undone.
 *
 * Super admin only, and `?scope=all` requires the report type to be named
 * explicitly so a mis-typed URL cannot empty a table by default.
 */
export const deleteViewpointDataHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);

  try {
    const body = await c.req.json().catch(() => ({}));
    const parsed = z
      .object({
        reportType: ReportTypeSchema,
        /** Target one upload's rows. */
        importId: z.string().uuid().optional(),
        /**
         * Target specific bookings — the row-level delete.
         *
         * Bounded so a single request cannot be turned into a table-wide delete by
         * pasting in every booking number; that scope goes through `confirmAll`,
         * which is confirmed separately.
         */
        bookingNumbers: z.array(z.string().min(1)).max(500).optional(),
        /**
         * Target the rows matching these filters — "delete what is on screen".
         *
         * Bookings only. The contacts table has no equivalent filter set, and
         * inventing one here would let a caller pass filters that silently did
         * nothing.
         */
        filters: BookingQuerySchema.partial().optional(),
        /*
         * Required, and required to be true, for the delete-everything path. A
         * destructive default is the one thing this endpoint must not have.
         */
        confirmAll: z.boolean().optional(),
      })
      .safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid delete request", 400);
    }
    const { reportType, importId, bookingNumbers, filters, confirmAll } =
      parsed.data;
    const r = repo(c);

    /*
     * Checked before the filter and import scopes: it is the most specific request,
     * and a caller sending both should get the narrow one rather than the broad one.
     */
    if (bookingNumbers?.length) {
      if (reportType !== "bookings") {
        return errorResponse(
          c,
          "Deleting by booking number is only available for bookings.",
          400,
        );
      }
      const deleted = await r.deleteBookingsByNumbers(bookingNumbers);
      return success(
        c,
        { deleted, scope: "rows" as const, viewpointUnchanged: true },
        `Removed ${deleted} booking${deleted === 1 ? "" : "s"} from console2. Viewpoint is unchanged.`,
        200,
      );
    }

    /*
     * Filters that narrow nothing are treated as the all-scope, not as a filtered
     * delete. Otherwise an empty filter object — a UI with every control cleared, or
     * a serialisation that dropped them — would quietly wipe the table while looking
     * like a targeted request.
     */
    const narrowing = filters
      ? Boolean(
          filters.origins?.length ||
            filters.sources?.length ||
            filters.statuses?.length ||
            filters.membershipNumbers?.length ||
            filters.search?.trim() ||
            filters.from ||
            filters.to ||
            filters.onlyUnmatched,
        )
      : false;

    if (filters && narrowing) {
      if (reportType !== "bookings") {
        return errorResponse(
          c,
          "Filtered delete is only available for bookings.",
          400,
        );
      }
      const scoped = bookingFilters({
        ...filters,
        page: 1,
        pageSize: 1,
      } as any);
      const deleted = await r.deleteBookingsByFilters(scoped);
      return success(
        c,
        { deleted, scope: "filtered" as const, viewpointUnchanged: true },
        `Removed ${deleted} matching row${deleted === 1 ? "" : "s"} from console2. Viewpoint is unchanged.`,
        200,
      );
    }

    if (importId) {
      const deleted = await r.deleteRowsForImport(reportType, importId);
      return success(
        c,
        { deleted, scope: "import" as const, viewpointUnchanged: true },
        `Removed ${deleted} row${deleted === 1 ? "" : "s"} from console2. Viewpoint is unchanged.`,
        200,
      );
    }

    if (confirmAll !== true) {
      return errorResponse(
        c,
        "Deleting every imported row requires confirmAll: true.",
        400,
      );
    }

    const deleted = await r.deleteAllRows(reportType);
    return success(
      c,
      { deleted, scope: "all" as const, viewpointUnchanged: true },
      `Removed all ${deleted} imported row${deleted === 1 ? "" : "s"} from console2. Viewpoint is unchanged.`,
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to remove imported rows"),
      500,
    );
  }
};

/**
 * Refreshes imported bookings from the Viewpoint API.
 *
 * Super admin only, because it spends requests against a shared production system
 * — not because the resulting data is sensitive.
 */
export const syncViewpointBookingsHandler = async (c: Context) => {
  if (!isSuperAdmin(c)) return forbidden(c);

  try {
    const body = await c.req.json().catch(() => ({}));
    const parsed = SyncSchema.safeParse(body);
    if (!parsed.success) {
      return errorResponse(c, "Invalid sync request", 400);
    }
    const outcome = await syncBookingsFromViewpoint(repo(c), parsed.data);

    /*
     * Reported as a success even when individual bookings failed: the run did
     * happen, and the counts are the answer. A whole-batch failure — the API being
     * unconfigured or unreachable — shows up as attempted === failed, which the
     * message calls out rather than leaving to be inferred from the numbers.
     */
    const wholeBatchFailed =
      outcome.attempted > 0 && outcome.failed === outcome.attempted;
    return success(
      c,
      outcome,
      wholeBatchFailed
        ? `Sync failed for all ${outcome.attempted} bookings — ${
            outcome.errors[0]?.message ?? "Viewpoint request failed"
          }`
        : `Synced ${outcome.updated} of ${outcome.attempted} booking${
            outcome.attempted === 1 ? "" : "s"
          }`,
      200,
    );
  } catch (err) {
    logError(err);
    return errorResponse(
      c,
      getErrorMessage(err, "Failed to sync bookings from Viewpoint"),
      500,
    );
  }
};
