import type { Context } from "hono";
import type Redis from "ioredis";
import type { Kysely } from "kysely";
import { logError } from "@/lib/logger";
import { describeFirestoreError } from "@/lib/firestore/event-tracking";
import { error as errorResponse, success } from "@/lib/response";
import { lookupMembersByNumber } from "@/internal/repository/event-analytics/member-lookup.repo";
import {
  EventFiltersSchema,
  ListEventsSchema,
  MemberAnalyticsSchema,
} from "@/v1/services/admin/event-analytics/event-analytics.schema";
import {
  EventAnalyticsService,
  type EventFilters,
  type MemberAnalyticsRow,
} from "@/v1/services/admin/event-analytics/event-analytics.service";
import type { EventRecord } from "@/v1/services/admin/event-analytics/event-analytics.mapper";

const service = () => EventAnalyticsService.getInstance();

/** Redis is optional — analytics still work uncached if it is unavailable. */
const cache = (c: Context): Redis | undefined =>
  (c.get("redisDB") as Redis | undefined) ?? undefined;

/**
 * Attach member-DB profiles to a page of rows.
 *
 * Deliberately applied to the page, not the whole aggregate: a wide filter can
 * produce tens of thousands of members, and looking all of them up to render 25
 * would put a large query in front of every request for no visible benefit.
 *
 * A failure here degrades to bare member numbers rather than failing the report
 * — the Firestore figures are still correct and useful without names.
 */
async function attachProfiles(
  memberDb: Kysely<any> | undefined,
  rows: MemberAnalyticsRow[],
): Promise<MemberAnalyticsRow[]> {
  if (!memberDb || !rows.length) return rows;
  try {
    const profiles = await lookupMembersByNumber(
      memberDb,
      rows.map((r) => r.memberId),
    );
    return rows.map((row) => ({
      ...row,
      profile: profiles.get(row.memberId) ?? null,
    }));
  } catch (err) {
    logError(err, "event-analytics: member profile lookup failed");
    return rows;
  }
}

/**
 * The same lookup for detail records.
 *
 * Only the name is attached, not the whole profile: the records table shows who
 * the event belonged to, and shipping every member's country, email and account
 * type down for a column that never renders them is payload nobody reads.
 */
async function attachRecordProfiles(
  memberDb: Kysely<any> | undefined,
  records: EventRecord[],
): Promise<EventRecord[]> {
  if (!memberDb || !records.length) return records;
  try {
    const numbers = records
      .map((r) => r.memberId)
      .filter((id): id is string => Boolean(id));
    if (!numbers.length) return records;

    const profiles = await lookupMembersByNumber(memberDb, numbers);
    return records.map((record) => {
      const profile = record.memberId ? profiles.get(record.memberId) : undefined;
      if (!profile) return record;
      return {
        ...record,
        // Only fill what the producer left empty — an event that carries its own
        // name is the more authoritative source for that event.
        memberName: record.memberName ?? profile.fullName,
        memberEmail: record.memberEmail ?? profile.email,
      };
    });
  } catch (err) {
    logError(err, "event-analytics: record member lookup failed");
    return records;
  }
}

const toFilters = (parsed: Record<string, unknown>): EventFilters => ({
  from: parsed.from as string | undefined,
  to: parsed.to as string | undefined,
  eventTypes: parsed.eventTypes as string[] | undefined,
  memberId: parsed.memberId as string | undefined,
  currency: parsed.currency as string | undefined,
  property: parsed.property as string | undefined,
  source: parsed.source as string | undefined,
  search: parsed.search as string | undefined,
});

/**
 * Firestore rejects a range filter on a field with no matching composite index
 * and returns a message containing a one-click console URL to create it. Surface
 * that message verbatim: it is the fix, and hiding it behind "Server error"
 * turns a two-minute setup step into a debugging session.
 *
 * Connection-level failures get the opposite treatment. Their raw form is a bare
 * gRPC status — `5 NOT_FOUND:` with no detail — which says nothing about what to
 * change, so `describeFirestoreError` replaces it with the actual fix.
 */
const respondWithError = (c: Context, err: unknown, fallback: string) => {
  const described = describeFirestoreError(err);
  logError(err, described ?? fallback);
  if (described) return errorResponse(c, described, 500);
  const message = err instanceof Error ? err.message : fallback;
  return errorResponse(c, message, 500, err);
};

export const getEventSummaryHandler = async (c: Context) => {
  try {
    const parsed = EventFiltersSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return errorResponse(c, "Invalid filters", 400, parsed.error);
    }
    const aggregate = await service().computeAggregates(
      toFilters(parsed.data),
      cache(c),
    );
    return success(
      c,
      {
        summary: aggregate.summary,
        availableEventTypes: aggregate.availableEventTypes,
        availableCurrencies: aggregate.availableCurrencies,
        availableProperties: aggregate.availableProperties,
        availableSources: aggregate.availableSources,
        byDay: aggregate.byDay,
        byProperty: aggregate.byProperty,
        bySource: aggregate.bySource,
      },
      "Event summary fetched",
      200,
    );
  } catch (err) {
    return respondWithError(c, err, "Could not fetch the event summary");
  }
};

export const getMemberAnalyticsHandler = async (c: Context) => {
  try {
    const parsed = MemberAnalyticsSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return errorResponse(c, "Invalid filters", 400, parsed.error);
    }
    const { page, pageSize, sortBy, sortDir, memberSearch } = parsed.data;
    const aggregate = await service().computeAggregates(
      toFilters(parsed.data),
      cache(c),
    );

    /*
     * Member search runs over the whole member set, before paging.
     *
     * Matching the member number only, not the name: names come from the member
     * DB and are looked up for the current page alone, so a name search would
     * silently only ever find members who happened to already be on screen.
     */
    const needle = memberSearch?.trim().toLowerCase();
    const matching = needle
      ? aggregate.members.filter((m) => m.memberId.toLowerCase().includes(needle))
      : aggregate.members;

    // Sorted and paged here rather than in Firestore: these rows are derived
    // from a fold, so they only exist once the whole filtered set has been
    // read. The cached aggregate makes repeat pages free.
    const sorted = sortMembers(matching, sortBy, sortDir);
    const start = (page - 1) * pageSize;
    const rows = await attachProfiles(
      c.get("memberDatastore") as Kysely<any> | undefined,
      sorted.slice(start, start + pageSize),
    );

    return success(
      c,
      {
        rows,
        total: sorted.length,
        page,
        pageSize,
        totalPages: Math.max(1, Math.ceil(sorted.length / pageSize)),
        truncated: aggregate.summary.truncated,
      },
      "Member analytics fetched",
      200,
    );
  } catch (err) {
    return respondWithError(c, err, "Could not fetch member analytics");
  }
};

export const getEventTypeAnalyticsHandler = async (c: Context) => {
  try {
    const parsed = EventFiltersSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return errorResponse(c, "Invalid filters", 400, parsed.error);
    }
    const aggregate = await service().computeAggregates(
      toFilters(parsed.data),
      cache(c),
    );
    return success(
      c,
      {
        rows: aggregate.eventTypes,
        total: aggregate.eventTypes.length,
        truncated: aggregate.summary.truncated,
      },
      "Event analytics fetched",
      200,
    );
  } catch (err) {
    return respondWithError(c, err, "Could not fetch event analytics");
  }
};

export const listEventRecordsHandler = async (c: Context) => {
  try {
    const parsed = ListEventsSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return errorResponse(c, "Invalid filters", 400, parsed.error);
    }
    const { pageSize, cursor } = parsed.data;
    const filters = toFilters(parsed.data);

    const [page, total] = await Promise.all([
      service().listEvents({ filters, pageSize, cursor }),
      service().countEvents(filters),
    ]);

    /*
     * Detailed records name their member too.
     *
     * The producer writes no member name on the event, so without this every
     * row's Member column was a bare number — the same complaint the member-wise
     * table had. One lookup per page, same as there.
     */
    const records = await attachRecordProfiles(
      c.get("memberDatastore") as Kysely<any> | undefined,
      page.records,
    );

    return success(
      c,
      {
        records,
        nextCursor: page.nextCursor,
        hasMore: page.hasMore,
        // Null when a text search is active — Firestore cannot count a
        // predicate it does not evaluate. The UI shows "—" rather than a
        // number that would describe a different result set.
        total,
      },
      "Event records fetched",
      200,
    );
  } catch (err) {
    return respondWithError(c, err, "Could not fetch event records");
  }
};

/**
 * Everything known about one member: their profile from the member DB, their
 * aggregates from Firestore, and their most recent events.
 *
 * One endpoint rather than three so the detail page renders in a single round
 * trip — the three pieces are always wanted together and share the same filter.
 */
export const getMemberDetailHandler = async (c: Context) => {
  try {
    const memberId = c.req.param("memberId");
    if (!memberId) return errorResponse(c, "Member number is required", 400);

    const parsed = EventFiltersSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return errorResponse(c, "Invalid filters", 400, parsed.error);
    }

    // The member filter is forced, not merged: this endpoint is about one
    // member, and honouring a conflicting memberId from the query string would
    // let the URL and the payload disagree.
    const filters: EventFilters = { ...toFilters(parsed.data), memberId };

    const [aggregate, breakdown] = await Promise.all([
      service().computeAggregates(filters, cache(c)),
      service().getMemberBreakdown(filters),
    ]);

    const memberDb = c.get("memberDatastore") as Kysely<any> | undefined;
    const [row] = await attachProfiles(
      memberDb,
      aggregate.members.filter((m) => m.memberId === memberId),
    );

    /*
     * A member with no events in the range still gets a page.
     *
     * The profile lookup is independent of Firestore, so narrowing the date
     * range until nothing matches should show an empty timeline for a real
     * member — not a 404 implying the member does not exist.
     */
    const profile =
      row?.profile ??
      (memberDb
        ? ((await lookupMembersByNumber(memberDb, [memberId]).catch(() => null))?.get(
            memberId,
          ) ?? null)
        : null);

    return success(
      c,
      {
        memberId,
        profile,
        member: row ?? null,
        summary: aggregate.summary,
        eventTypes: aggregate.eventTypes,
        records: breakdown.records,
        recordsTruncated: breakdown.truncated,
        byDay: breakdown.byDay,
        byProperty: breakdown.byProperty,
        bySource: breakdown.bySource,
      },
      "Member detail fetched",
      200,
    );
  } catch (err) {
    return respondWithError(c, err, "Could not fetch the member detail");
  }
};

function sortMembers(
  rows: MemberAnalyticsRow[],
  sortBy: "events" | "revenue" | "points" | "lastSeen" | "member",
  sortDir: "asc" | "desc",
): MemberAnalyticsRow[] {
  const dir = sortDir === "asc" ? 1 : -1;
  // Revenue is multi-currency and deliberately never summed across currencies,
  // so sorting by it ranks on the member's single largest currency total. Any
  // cross-currency total would be a meaningless number.
  const topRevenue = (r: MemberAnalyticsRow) =>
    r.revenueByCurrency.reduce((max, x) => Math.max(max, x.revenue), 0);

  return [...rows].sort((a, b) => {
    switch (sortBy) {
      case "revenue":
        return (topRevenue(a) - topRevenue(b)) * dir;
      case "points":
        return (a.points - b.points) * dir;
      case "lastSeen":
        return ((a.lastSeen ?? "") < (b.lastSeen ?? "") ? -1 : 1) * dir;
      case "member":
        return (
          (a.memberName ?? a.memberId).localeCompare(b.memberName ?? b.memberId) * dir
        );
      default:
        return (a.events - b.events) * dir;
    }
  });
}
