"use client";

import { Lock, Ticket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";

import { getBetProviders } from "@/api/bet-providers";
import { getPlayEffective } from "@/api/play";
import { getWalletBalance } from "@/api/wallet";
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
import { useIsMobile } from "@/hooks/use-mobile";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import { HallBettingTable } from "@/features/hall/hall-betting-table";
import { HallMobileQuickFill } from "@/features/hall/hall-mobile-quick-fill";
import { HallPlaySummaryGrid } from "@/features/hall/hall-play-summary-grid";
import { HallSelectionConfirmDialog } from "@/features/hall/hall-selection-confirm-dialog";
import {
  FALLBACK_BET_PROVIDERS,
  cellRiskState,
  digitSlotOptions,
  numberMaxCharsForCategory,
  playCategory,
  playColumnHeaderLabel,
  sanitizeNumber,
  type DraftRow,
  type HallCategory,
  type PlayColumn,
  type PlayHallCategory,
} from "@/features/hall/hall-betting-grid-model";
import {
  HallDesktopReviewPanel,
  HallDesktopWorkflowBar,
  type HallDesktopReviewModel,
} from "@/features/hall/hall-desktop-workspace";
import {
  draftLineIssueReason,
  playNeedsDigitSlot,
  playNeedsDimension,
  ticketNumberSpec,
  type DraftLineIssueReason,
} from "@/features/hall/hall-bet-rules";
import {
  isFullCoverAmountDivisible,
  isHighCostSelectionType,
  resolveSelectionTotalBet,
  selectionCombinationCount,
  selectionTypesForCategory,
  type SelectionType,
} from "@/features/hall/selection-type";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { getLotteryEcho } from "@/lib/lottery-echo";
import {
  formatMinorAmount,
  formatMinorAsCurrency,
  parseDecimalInputToMinor,
} from "@/lib/money";
import { playLabel } from "@/lib/play-labels";
import { playerViewportFixedBarClass } from "@/lib/player-viewport";
import {
  PLAY_CATALOG_REFRESH_EVENT,
  type PlayCatalogRefreshSource,
} from "@/lib/play-catalog-events";

import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
import type { TicketLineInput, TicketPlaceData, TicketPreviewData } from "@/types/api/ticket";
import type { BetProviderRow } from "@/types/api/bet-provider";

const TRADITIONAL_PLAY_CODES: Record<PlayHallCategory, readonly string[]> = {
  D4: ["big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower", "five_d", "six_d"],
  D3: ["pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e"],
  D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any"],
};

type PendingSelectionChange =
  | { mode: "row"; rowId: string; next: SelectionType; prev: SelectionType }
  | { mode: "all"; next: SelectionType };

type DraftEntry = {
  rowId: string;
  rowNo: number;
  amountKey: string;
  play: PlayEffectivePlayRow;
  digitSlot?: number;
  number: string;
  amountMinor: number;
  line: TicketLineInput;
};

type DraftLineIssue = {
  rowNo: number;
  playCode: string;
  reason: DraftLineIssueReason;
};

type ClosedPlayCleanupData = {
  cleanup_hint?: string;
  cleanup_lines?: Array<{ client_line_no?: number; play_code?: string }>;
};

type TicketPreviewSubmissionSnapshot = {
  drawId: string;
  currencyCode: string;
  clientTraceId: string;
  lines: TicketLineInput[];
  expectedConfigVersions: TicketPreviewData["config_versions"];
};

type PlayToggleWsEvent = {
  play_code?: string;
  enabled?: boolean;
  action?: string;
};

type OddsUpdateWsEvent = {
  message?: string;
};

type RiskSoldOutWsEvent = {
  draw_id?: number;
  draw_no?: string;
  normalized_number?: string;
};

type RiskWarningWsEvent = {
  draw_id?: number;
  draw_no?: string;
  normalized_number?: string;
  usage_ratio?: number;
  usage_percent?: number;
};

type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;
const DEFAULT_PROVIDER_CODE = "SG";

const categoryTabs: { value: PlayHallCategory; label: string }[] = [
  { value: "D4", label: "4D" },
  { value: "D3", label: "3D" },
  { value: "D2", label: "2D" },
];

const D2_PLAY_ORDER = ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"] as const;
const D3_PLAY_ORDER = ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"] as const;
const D4_PLAY_ORDER = [
  "big",
  "small",
  "pos_4a",
  "pos_4b",
  "pos_4c",
  "pos_4d",
  "pos_4e",
  "box",
  "ibox",
  "mbox",
  "roll",
  "straight",
  "head",
  "tail",
  "odd",
  "even",
  "digit_big",
  "digit_small",
] as const;
/** 按大厅 Tab 对应的投注列顺序。 */
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
  D2: D2_PLAY_ORDER,
  D3: D3_PLAY_ORDER,
  D4: D4_PLAY_ORDER,
};
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
  (category) => PLAY_ORDER_BY_CATEGORY[category],
);
const DEFAULT_DRAFT_ROW_COUNT = 20;

function playOrderForActiveCategory(activeCategory: PlayHallCategory): readonly string[] {
  const rest = CATEGORY_ORDER.filter((category) => category !== activeCategory);
  return [
    ...PLAY_ORDER_BY_CATEGORY[activeCategory],
    ...rest.flatMap((category) => PLAY_ORDER_BY_CATEGORY[category]),
  ];
}

function newDraftRows(count = DEFAULT_DRAFT_ROW_COUNT): DraftRow[] {
  return Array.from({ length: count }, newDraftRow);
}

function newDraftRow(): DraftRow {
  const id =
    typeof crypto !== "undefined" && crypto.randomUUID
      ? crypto.randomUUID()
      : `row-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  return { id, number: "", amounts: {}, providerCodes: [DEFAULT_PROVIDER_CODE], selectionType: "straight" };
}

function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
  return Boolean(row.master_enabled && row.config?.is_enabled);
}

function amountKeyForPlay(playCode: string, digitSlot?: number): string {
  return digitSlot === undefined ? playCode : `${playCode}@${digitSlot}`;
}

function playColumnsForCategory(
  plays: PlayEffectivePlayRow[],
  category: PlayHallCategory,
): PlayColumn[] {
  return plays.flatMap((play) => {
    if (!playNeedsDigitSlot(play.play_code)) {
      return [{ key: amountKeyForPlay(play.play_code), play }];
    }

    return digitSlotOptions(category).map((digitSlot) => ({
      key: amountKeyForPlay(play.play_code, digitSlot),
      play,
      digitSlot,
    }));
  });
}

function sanitizeAmount(raw: string): string {
  return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
}

function parseRebateRate(rate: string | undefined): number {
  const n = Number(rate ?? 0);
  if (!Number.isFinite(n) || n <= 0) return 0;
  return n > 1 ? n / 100 : n;
}

function normalizeNumberForPlay(number: string, playCode: string): string {
  if (playCode.startsWith("pos_2")) return number.slice(-2);
  if (playCode.startsWith("pos_3")) return number.slice(-3);
  if (
    playCode === "head" ||
    playCode === "tail" ||
    playCode === "odd" ||
    playCode === "even" ||
    playCode === "digit_big" ||
    playCode === "digit_small"
  ) {
    return number.slice(-1);
  }
  return number;
}

function lineForPlay(
  play: PlayEffectivePlayRow,
  displayNumber: string,
  amountMinor: number,
  digitSlot?: number,
  selectionType: SelectionType = "straight",
): TicketLineInput | null {
  const number = normalizeNumberForPlay(displayNumber, play.play_code);
  if (draftLineIssueReason(play.play_code, displayNumber, digitSlot) !== null) {
    return null;
  }
  const spec = ticketNumberSpec(play.play_code);
  if (number.length !== spec.maxChars) {
    return null;
  }

  const line: TicketLineInput = {
    number,
    play_code: play.play_code,
    amount: amountMinor,
    selection_type: selectionType,
  };

  if (playNeedsDimension(play.play_code)) {
    line.dimension = playCategory(play.play_code);
  }
  if (playNeedsDigitSlot(play.play_code)) {
    if (digitSlot === undefined) return null;
    line.digit_slot = digitSlot;
  }

  return line;
}

function sortByPlayOrder(plays: PlayEffectivePlayRow[], order: readonly string[]): PlayEffectivePlayRow[] {
  const orderMap = new Map(order.map((code, idx) => [code, idx]));
  return [...plays].sort((a, b) => {
    const ai = orderMap.get(a.play_code) ?? 999;
    const bi = orderMap.get(b.play_code) ?? 999;
    return ai - bi || a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code);
  });
}

function loadStringArray(key: string): string[] {
  if (typeof window === "undefined") return [];
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter((v): v is string => typeof v === "string");
  } catch {
    return [];
  }
}

function saveStringArray(key: string, values: string[]): void {
  if (typeof window === "undefined") return;
  window.localStorage.setItem(key, JSON.stringify(values));
}

function loadQuickFillState(): QuickFillState {
  return {
    D2: {
      favorites: loadStringArray(quickFillKeys("D2").favorites),
      history: loadStringArray(quickFillKeys("D2").history),
    },
    D3: {
      favorites: loadStringArray(quickFillKeys("D3").favorites),
      history: loadStringArray(quickFillKeys("D3").history),
    },
    D4: {
      favorites: loadStringArray(quickFillKeys("D4").favorites),
      history: loadStringArray(quickFillKeys("D4").history),
    },
    JACKPOT: {
      favorites: [],
      history: [],
    },
  };
}

function appendUnique(values: string[], value: string, limit = 20): string[] {
  const trimmed = value.trim();
  if (!trimmed) return values;
  const next = [trimmed, ...values.filter((v) => v !== trimmed)];
  return next.slice(0, limit);
}

function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
  return {
    favorites: `lottery.hall.quickfill.favorites.${category}`,
    history: `lottery.hall.quickfill.history.${category}`,
  };
}

export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }) {
  const { display, isBettable, reload: reloadDraw } = drawLive;
  const { t } = useTranslation("player");
  const { activeCurrency: currencyParam } = useActivePlayerCurrency();
  const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
  const isMobile = useIsMobile();

  const [rows, setRows] = useState<DraftRow[]>(() => newDraftRows());
  const [activeRowId, setActiveRowId] = useState<string | null>(null);
  const [catalogState, setCatalogState] = useState<
    | { kind: "loading" }
    | { kind: "ok"; data: PlayEffectivePayload }
    | { kind: "error"; message: string }
  >({ kind: "loading" });
  const [availableMinor, setAvailableMinor] = useState<number>(0);
  const [previewOpen, setPreviewOpen] = useState(false);
  const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
  const [previewLoading, setPreviewLoading] = useState(false);
  const [placeLoading, setPlaceLoading] = useState(false);
  const [resultOpen, setResultOpen] = useState(false);
  const [resultData, setResultData] = useState<TicketPlaceData | null>(null);
  const [activeCategory, setActiveCategory] = useState<PlayHallCategory>("D4");
  const [betProviders, setBetProviders] = useState<BetProviderRow[]>(FALLBACK_BET_PROVIDERS);
  const [syncAmountColumns, setSyncAmountColumns] = useState<Record<string, boolean>>({});
  const [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
  const [quickFillExpanded, setQuickFillExpanded] = useState(false);
  const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState<Set<string>>(() => new Set());
  const [liveWarningNumbers, setLiveWarningNumbers] = useState<Set<string>>(() => new Set());
  const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
  const [pendingSelectionChange, setPendingSelectionChange] = useState<PendingSelectionChange | null>(
    null,
  );
  /** 单次预览→确认共用，重试 place 复用，避免重复扣款 */
  const placeTraceIdRef = useRef<string | null>(null);
  const previewRequestSeqRef = useRef(0);
  const previewSubmissionRef = useRef<TicketPreviewSubmissionSnapshot | null>(null);
  const newPlaceTraceId = (): string =>
    typeof crypto !== "undefined" && crypto.randomUUID
      ? crypto.randomUUID()
      : `pl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;

  const clearPlaceTraceId = useCallback(() => {
    placeTraceIdRef.current = null;
  }, []);
  const invalidatePreview = useCallback(() => {
    previewRequestSeqRef.current += 1;
    previewSubmissionRef.current = null;
    setPreviewOpen(false);
    setPreviewData(null);
    clearPlaceTraceId();
  }, [clearPlaceTraceId]);
  const catalogSeqRef = useRef(0);
  const walletSeqRef = useRef(0);
  const selectedCatalogProviderCode =
    betProviders.find((provider) => provider.is_default)?.code ??
    betProviders[0]?.code;

  const loadCatalog = useCallback(async () => {
    const seq = ++catalogSeqRef.current;
    setCatalogState({ kind: "loading" });
    try {
      const data = await getPlayEffective({
        currency: currencyParam,
        provider_code: selectedCatalogProviderCode,
      });
      if (seq !== catalogSeqRef.current) return;
      setCatalogState({ kind: "ok", data });
    } catch (e) {
      if (seq !== catalogSeqRef.current) return;
      const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
      setCatalogState({ kind: "error", message: msg });
    }
  }, [currencyParam, selectedCatalogProviderCode, t]);

  const refreshWallet = useCallback(async () => {
    const seq = ++walletSeqRef.current;
    try {
      const wallet = await getWalletBalance({ currency: currencyParam });
      if (seq !== walletSeqRef.current) return;
      setAvailableMinor(Number(wallet.available_balance ?? 0));
    } catch {
      // 保留上次可用余额，避免短暂失败导致误报余额不足
    }
  }, [currencyParam]);

  useAsyncEffect(() => {
    void loadCatalog();
    void refreshWallet();
  }, [loadCatalog, refreshWallet]);

  useEffect(() => {
    let cancelled = false;
    void getBetProviders()
      .then((data) => {
        if (cancelled) return;
        const items = data.items.length > 0 ? data.items : FALLBACK_BET_PROVIDERS;
        setBetProviders(items);
        setRows((current) => {
          const available = new Set(items.map((item) => item.code));
          const fallback = items.find((item) => item.is_default)?.code ?? items[0]?.code;
          return current.map((row) => {
            const selected = row.providerCodes.filter((code) => available.has(code));
            const wasInitialDefault = row.providerCodes.length === 1 && row.providerCodes[0] === DEFAULT_PROVIDER_CODE;
            return selected.length === 0 && wasInitialDefault && fallback
              ? { ...row, providerCodes: [fallback] }
              : { ...row, providerCodes: selected };
          });
        });
      })
      .catch(() => {
        if (cancelled) return;
        setBetProviders(FALLBACK_BET_PROVIDERS);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  useEffect(() => {
    const onCatalogRefresh = (ev: Event) => {
      void loadCatalog();
      const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
        ?.source;
      if (source !== undefined) {
        invalidatePreview();
        toast.message(t("hall.playConfig.updated"));
      }
    };
    window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
    return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
  }, [invalidatePreview, loadCatalog, t]);

  const openPlays = useMemo(() => {
    if (catalogState.kind !== "ok") return [];
    // 投注表格列以当前 Tab 玩法为先；顶部汇总另用稳定的全玩法顺序。
    const order = playOrderForActiveCategory(activeCategory);
    const orderSet = new Set(order);
    return sortByPlayOrder(
      catalogState.data.plays
        .filter(isPlayOpenForPlayer)
        .filter((p) => orderSet.has(p.play_code)),
      order,
    );
  }, [activeCategory, catalogState]);

  const activeCategoryPlays = useMemo(
    () => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
    [activeCategory, openPlays],
  );
  const availableCategories = useMemo(
    () => new Set(openPlays.map((play) => playCategory(play.play_code))),
    [openPlays],
  );

  const currencyCode =
    catalogState.kind === "ok" ? catalogState.data.currency_code : currencyParam;

  const allPlayColumns = useMemo<PlayColumn[]>(() => {
    return openPlays.flatMap((play) => {
      const category = playCategory(play.play_code);
      if (!playNeedsDigitSlot(play.play_code)) {
        return [{ key: amountKeyForPlay(play.play_code), play }];
      }
      return digitSlotOptions(category).map((digitSlot) => ({
        key: amountKeyForPlay(play.play_code, digitSlot),
        play,
        digitSlot,
      }));
    });
  }, [openPlays]);

  const summaryPlayColumns = useMemo<PlayColumn[]>(() => {
    return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => {
      const category = playCategory(play.play_code);
      if (!playNeedsDigitSlot(play.play_code)) {
        return [{ key: amountKeyForPlay(play.play_code), play }];
      }
      return digitSlotOptions(category).map((digitSlot) => ({
        key: amountKeyForPlay(play.play_code, digitSlot),
        play,
        digitSlot,
      }));
    });
  }, [openPlays]);

  const playColumns = useMemo(() => {
    return playColumnsForCategory(activeCategoryPlays, activeCategory);
  }, [activeCategory, activeCategoryPlays]);

  const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
  const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
  const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
  const desktopIndexColClass = "w-9 min-w-9";
  const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]";
  const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]";
  const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
  const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
  const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
  const showSelectionTypeColumn = activeCategory !== "D2";
  const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9";
  const amountColClass = !isMobile
    ? "w-14 min-w-14"
    : "w-[4rem] min-w-[4rem]";
  const providerColClass = !isMobile
    ? "w-11 min-w-11"
    : "w-[2.7rem] min-w-[2.7rem]";
  const rowTotalColClass = !isMobile
    ? "w-20 min-w-20"
    : "w-[5rem] min-w-[5rem]";

  const tableWidthPx = useMemo(() => {
    const indexCol = !isMobile ? 36 : 28;
    const numberCol = !isMobile ? 84 : 68;
    const selectionTypeCol = !isMobile ? 72 : 64;
    const providerCol = !isMobile ? 44 : 43;
    const rowTotalCol = !isMobile ? 80 : 80;
    const amountCol = !isMobile ? 56 : 64;
    return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
  }, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);

  const showWideTableHint = isMobile && playColumns.length > 8;

  const activeRow = useMemo(
    () => rows.find((row) => row.id === activeRowId) ?? rows[0] ?? null,
    [activeRowId, rows],
  );

  const drawNo = display?.draw_no ?? null;

  useEffect(() => {
    let cancelled = false;
    queueMicrotask(() => {
      if (cancelled) return;
      setLiveSoldOutNumbers(new Set());
      setLiveWarningNumbers(new Set());
      invalidatePreview();
    });

    return () => {
      cancelled = true;
    };
  }, [currencyCode, drawNo, invalidatePreview]);

  const alertRows = useMemo(
    () => display?.risk_pool_alerts ?? [],
    [display?.risk_pool_alerts],
  );
  const jackpot = display?.jackpot;
  const currentQuickFill = quickFillState[activeCategory] ?? { favorites: [], history: [] };
  const favorites = currentQuickFill.favorites;
  const historyNumbers = currentQuickFill.history;
  const tableDisabled = !isBettable || catalogState.kind !== "ok";
  const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
  const numberPlaceholder =
    activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
  const numberMaxChars = numberMaxCharsForCategory(activeCategory);

  const updateRowNumber = useCallback((id: string, value: string) => {
    setRows((current) =>
      current.map((row) =>
        row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
      ),
    );
    setActiveRowId(id);
  }, [activeCategory]);

  const applyRowSelectionType = useCallback((id: string, selectionType: SelectionType) => {
    setRows((current) => current.map((row) => (row.id === id ? { ...row, selectionType } : row)));
  }, []);

  const applyAllSelectionTypes = useCallback((selectionType: SelectionType) => {
    setRows((current) => current.map((row) => ({ ...row, selectionType })));
  }, []);

  const requestRowSelectionType = useCallback(
    (id: string, selectionType: SelectionType) => {
      const row = rows.find((item) => item.id === id);
      if (!row || row.selectionType === selectionType) return;
      if (isHighCostSelectionType(selectionType) && !isHighCostSelectionType(row.selectionType)) {
        setPendingSelectionChange({
          mode: "row",
          rowId: id,
          next: selectionType,
          prev: row.selectionType,
        });
        return;
      }
      applyRowSelectionType(id, selectionType);
    },
    [applyRowSelectionType, rows],
  );

  const requestAllSelectionTypes = useCallback(
    (selectionType: SelectionType) => {
      if (isHighCostSelectionType(selectionType)) {
        setPendingSelectionChange({ mode: "all", next: selectionType });
        return;
      }
      applyAllSelectionTypes(selectionType);
    },
    [applyAllSelectionTypes],
  );

  const confirmPendingSelectionChange = useCallback(() => {
    if (!pendingSelectionChange) return;
    if (pendingSelectionChange.mode === "row") {
      applyRowSelectionType(pendingSelectionChange.rowId, pendingSelectionChange.next);
    } else {
      applyAllSelectionTypes(pendingSelectionChange.next);
    }
    setPendingSelectionChange(null);
  }, [applyAllSelectionTypes, applyRowSelectionType, pendingSelectionChange]);

  const cancelPendingSelectionChange = useCallback(() => {
    setPendingSelectionChange(null);
  }, []);

  const updateAmount = useCallback((rowId: string, playCode: string, value: string) => {
    const amount = sanitizeAmount(value);
    const column = allPlayColumns.find((item) => item.key === playCode);
    const syncColumn = syncAmountColumns[playCode] === true && column !== undefined;

    setRows((current) => current.map((row) => {
      if (!syncColumn) {
        return row.id === rowId
          ? { ...row, amounts: { ...row.amounts, [playCode]: amount } }
          : row;
      }

      const validNumber = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null;
      const status = cellRiskState(
        column.play,
        row.number,
        playCategory(column.play.play_code),
        alertRows,
        liveSoldOutNumbers,
        liveWarningNumbers,
        column.digitSlot,
      );
      if (!validNumber || status === "sold_out" || (column.play.config !== null && !column.play.config.is_enabled)) {
        return row;
      }

      return { ...row, amounts: { ...row.amounts, [playCode]: amount } };
    }));
    setActiveRowId(rowId);
  }, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);

  const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => {
    setSyncAmountColumns((current) => ({
      ...current,
      [column.key]: checked,
    }));

    setRows((current) => {
      if (!checked) {
        return current.map((row) => ({
          ...row,
          amounts: { ...row.amounts, [column.key]: "" },
        }));
      }

      const sourceAmount = current.find((row) => {
        const amount = row.amounts[column.key];
        return Boolean(
          amount &&
            draftLineIssueReason(
              column.play.play_code,
              row.number,
              column.digitSlot,
            ) === null,
        );
      })?.amounts[column.key];

      if (!sourceAmount) return current;

      return current.map((row) => {
        const validNumber =
          draftLineIssueReason(
            column.play.play_code,
            row.number,
            column.digitSlot,
          ) === null;
        const status = cellRiskState(
          column.play,
          row.number,
          playCategory(column.play.play_code),
          alertRows,
          liveSoldOutNumbers,
          liveWarningNumbers,
          column.digitSlot,
        );
        if (
          !validNumber ||
          status === "sold_out" ||
          (column.play.config !== null && !column.play.config.is_enabled)
        ) {
          return row;
        }

        return {
          ...row,
          amounts: { ...row.amounts, [column.key]: sourceAmount },
        };
      });
    });
  }, [alertRows, liveSoldOutNumbers, liveWarningNumbers]);

  const clearAllRows = () => {
    if (tableDisabled) return;
    setRows((current) =>
      current.map((row) => ({
        ...row,
        number: "",
        amounts: {},
      })),
    );
    setActiveRowId((current) => current ?? rows[0]?.id ?? null);
  };

  const clearActiveRowAmounts = useCallback(() => {
    const targetId = activeRowId ?? rows[0]?.id;
    if (!targetId || tableDisabled) return;
    setRows((current) =>
      current.map((row) => {
        if (row.id !== targetId) return row;
        const nextAmounts = { ...row.amounts };
        playColumns.forEach((column) => {
          nextAmounts[column.key] = "";
        });
        return { ...row, amounts: nextAmounts };
      }),
    );
  }, [activeRowId, playColumns, rows, tableDisabled]);

  const fillCurrentRow = (number: string) => {
    if (tableDisabled) return;
    const targetId = activeRowId ?? rows[0]?.id;
    if (!targetId) return;
    updateRowNumber(targetId, number);
  };

  const applyQuickAmountToActiveRow = useCallback(
    (amount: string) => {
      const targetId = activeRowId ?? rows[0]?.id;
      if (!targetId || tableDisabled) return;
      setRows((current) =>
        current.map((row) => {
          if (row.id !== targetId) return row;
          if (
            row.number.trim().length === 0 ||
            playColumns.every(
              (column) =>
                draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null,
            )
          ) {
            return row;
          }

          const nextAmounts = { ...row.amounts };
          playColumns.forEach((column) => {
            const isInputValid =
              draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) === null;
            const status = cellRiskState(
              column.play,
              row.number,
              playCategory(column.play.play_code),
              alertRows,
              liveSoldOutNumbers,
              liveWarningNumbers,
              column.digitSlot,
            );
            if (!isInputValid || status === "sold_out") return;
            nextAmounts[column.key] = amount;
          });
          return { ...row, amounts: nextAmounts };
        }),
      );
    },
    [
      activeRowId,
      alertRows,
      liveSoldOutNumbers,
      liveWarningNumbers,
      playColumns,
      rows,
      tableDisabled,
    ],
  );

  const copyPreviousRowToActiveRow = useCallback(() => {
    const targetId = activeRowId ?? rows[0]?.id;
    if (!targetId || tableDisabled) return;
    const targetIndex = rows.findIndex((row) => row.id === targetId);
    if (targetIndex <= 0) return;
    const previousRow = rows[targetIndex - 1];
    if (!previousRow) return;

    setRows((current) =>
      current.map((row, index) => {
        if (index !== targetIndex) return row;
        const nextAmounts = { ...row.amounts };
        playColumns.forEach((column) => {
          nextAmounts[column.key] = previousRow.amounts[column.key] ?? "";
        });
        return {
          ...row,
          number: previousRow.number,
          amounts: nextAmounts,
          providerCodes: previousRow.providerCodes,
        };
      }),
    );
  }, [activeRowId, playColumns, rows, tableDisabled]);

  const toggleFavoriteNumber = (number: string) => {
    const keys = quickFillKeys(activeCategory);
    setQuickFillState((current) => {
      const currentFavorites = current[activeCategory]?.favorites ?? [];
      const exists = currentFavorites.includes(number);
      const next = exists
        ? currentFavorites.filter((n) => n !== number)
        : [number, ...currentFavorites].slice(0, 20);
      saveStringArray(keys.favorites, next);
      return {
        ...current,
        [activeCategory]: {
          ...(current[activeCategory] ?? { favorites: [], history: [] }),
          favorites: next,
        },
      };
    });
  };

  const pushHistory = (number: string) => {
    const keys = quickFillKeys(activeCategory);
    setQuickFillState((current) => {
      const currentHistory = current[activeCategory]?.history ?? [];
      const next = appendUnique(currentHistory, number, 20);
      saveStringArray(keys.history, next);
      return {
        ...current,
        [activeCategory]: {
          ...(current[activeCategory] ?? { favorites: [], history: [] }),
          history: next,
        },
      };
    });
  };

  const toggleRowProvider = useCallback((rowId: string, code: string) => {
    setRows((current) => current.map((row) => {
      if (row.id !== rowId) return row;
      return row.providerCodes.includes(code)
        ? { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) }
        : { ...row, providerCodes: [...row.providerCodes, code] };
    }));
  }, []);

  const toggleProviderColumn = useCallback((code: string, checked: boolean) => {
    setRows((current) => current.map((row) => {
      const hasCode = row.providerCodes.includes(code);
      if (checked && !hasCode) return { ...row, providerCodes: [...row.providerCodes, code] };
      if (!checked && hasCode) return { ...row, providerCodes: row.providerCodes.filter((item) => item !== code) };
      return row;
    }));
  }, []);

  const hasAmountsForPlay = useCallback(
    (playCode: string): boolean =>
      rows.some((row) =>
        Object.entries(row.amounts).some(
          ([amountKey, amountValue]) =>
            amountKey.split("@")[0] === playCode && Boolean(amountValue?.trim()),
        ),
      ),
    [rows],
  );

  const clearAmountsForPlay = useCallback((playCode: string) => {
    setRows((current) =>
      current.map((row) => {
        const nextAmounts = { ...row.amounts };
        let changed = false;
        Object.keys(nextAmounts).forEach((amountKey) => {
          const keyPlayCode = amountKey.split("@")[0];
          if (keyPlayCode !== playCode || !nextAmounts[amountKey]) return;
          nextAmounts[amountKey] = "";
          changed = true;
        });
        return changed ? { ...row, amounts: nextAmounts } : row;
      }),
    );
  }, []);

  useEffect(() => {
    const echo = getLotteryEcho();
    if (!echo) return;

    const channel = echo.channel("lottery-hall");

    const onPlayToggle = (evt: PlayToggleWsEvent) => {
      if (evt.enabled === false && typeof evt.play_code === "string") {
        const removed = hasAmountsForPlay(evt.play_code);
        clearAmountsForPlay(evt.play_code);
        invalidatePreview();
        toast.warning(
          removed
            ? t("hall.playConfig.playClosedDraftCleared", {
                playCode: evt.play_code,
              })
            : t("hall.playConfig.playClosed", {
                playCode: evt.play_code,
              }),
        );
      }
    };

    const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
      invalidatePreview();
      toast.message(evt.message ?? t("hall.playConfig.oddsUpdated"));
    };

    const onRiskSoldOut = (evt: RiskSoldOutWsEvent) => {
      const normalized = evt.normalized_number?.trim().toUpperCase();
      if (!normalized) return;
      if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
        return;
      }
      setLiveSoldOutNumbers((prev) => {
        const next = new Set(prev);
        next.add(normalized);
        return next;
      });
      setLiveWarningNumbers((prev) => {
        const next = new Set(prev);
        next.delete(normalized);
        return next;
      });
      invalidatePreview();
      void reloadDraw();
    };

    const onRiskWarning = (evt: RiskWarningWsEvent) => {
      const normalized = evt.normalized_number?.trim().toUpperCase();
      if (!normalized) return;
      if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
        return;
      }
      setLiveWarningNumbers((prev) => {
        const next = new Set(prev);
        next.add(normalized);
        return next;
      });
    };

    channel.listen(".play.toggle", onPlayToggle);
    channel.listen(".odds.update", onOddsUpdate);
    channel.listen(".risk.sold_out", onRiskSoldOut);
    channel.listen(".risk.warning", onRiskWarning);

    return () => {
      channel.stopListening(".play.toggle");
      channel.stopListening(".odds.update");
      channel.stopListening(".risk.sold_out");
      channel.stopListening(".risk.warning");
    };
  }, [clearAmountsForPlay, drawNo, hasAmountsForPlay, invalidatePreview, reloadDraw, t]);

  const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
    const issues: DraftLineIssue[] = [];
    rows.forEach((row, rowIndex) => {
      allPlayColumns.forEach((column) => {
        const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
        if (amount === null || amount <= 0) return;
        const reason = draftLineIssueReason(column.play.play_code, row.number, column.digitSlot);
        if (reason !== null) {
          issues.push({
            rowNo: rowIndex + 1,
            playCode: column.play.play_code,
            reason,
          });
          return;
        }
        const comboCount = selectionCombinationCount(row.number, row.selectionType);
        if (
          row.selectionType === "full_cover"
          && !isFullCoverAmountDivisible(amount, comboCount)
        ) {
          issues.push({
            rowNo: rowIndex + 1,
            playCode: column.play.play_code,
            reason: "full_cover_amount_not_divisible",
          });
        }
      });
    });
    return issues;
  }, [allPlayColumns, currencyCode, rows]);

  const providerMissingRow = useCallback((): number | null => {
    for (const [index, row] of rows.entries()) {
      const hasAmount = Object.values(row.amounts).some((value) => {
        const amount = parseDecimalInputToMinor(value, currencyCode);
        return amount !== null && amount > 0;
      });
      if (hasAmount && row.providerCodes.length === 0) return index + 1;
    }
    return null;
  }, [currencyCode, rows]);

  const formatDraftLineIssue = useCallback(
    (issue: DraftLineIssue): string => {
      const label = playLabel(issue.playCode, t);
      return t(`hall.lineIssue.${issue.reason}`, { row: issue.rowNo, play: label });
    },
    [t],
  );

  const collectEntries = useCallback((): DraftEntry[] => {
    const entries: DraftEntry[] = [];
    rows.forEach((row, rowIndex) => {
      allPlayColumns.forEach((column) => {
        const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode);
        if (amount === null || amount <= 0) return;
        const line = lineForPlay(column.play, row.number, amount, column.digitSlot, row.selectionType);
        if (!line) return;
        line.provider_codes = row.providerCodes;
        entries.push({
          rowId: row.id,
          rowNo: rowIndex + 1,
          amountKey: column.key,
          play: column.play,
          digitSlot: column.digitSlot,
          number: row.number,
          amountMinor: amount,
          line,
        });
      });
    });
    return entries;
  }, [allPlayColumns, currencyCode, rows]);

  const draftEntries = collectEntries();
  const draftSummary = useMemo(() => {
    return draftEntries.reduce(
      (acc, entry) => {
        const selectionType = entry.line.selection_type ?? "straight";
        const comboCount = selectionCombinationCount(entry.number, selectionType);
        const totalBet = resolveSelectionTotalBet(entry.amountMinor, selectionType, comboCount);
        const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
        const rebate = Math.round(totalBet * rebateRate);
        const providerCount = entry.line.provider_codes?.length ?? 0;
        acc.bet += totalBet * providerCount;
        acc.rebate += rebate * providerCount;
        acc.actual += Math.max(0, totalBet - rebate) * providerCount;
        return acc;
      },
      { bet: 0, rebate: 0, actual: 0 },
    );
  }, [draftEntries]);

  const selectionTypeOptions = useMemo(
    () => selectionTypesForCategory(activeCategory),
    [activeCategory],
  );

  const pendingSelectionPreview = useMemo(() => {
    if (!pendingSelectionChange) return null;
    const next = pendingSelectionChange.next;

    const rowStake = (
      row: DraftRow,
      selectionType: SelectionType,
      mode: "resolved" | "raw",
    ): number => {
      const comboCount = selectionCombinationCount(row.number, selectionType);
      const providerCount = Math.max(1, row.providerCodes.length);
      const stake = playColumns.reduce((total, column) => {
        if (draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null) {
          return total;
        }
        const amount = parseDecimalInputToMinor(row.amounts[column.key] ?? "", currencyCode) ?? 0;
        if (amount <= 0) return total;
        if (mode === "raw") return total + amount;
        return total + resolveSelectionTotalBet(amount, selectionType, comboCount);
      }, 0);
      return stake * providerCount;
    };

    if (pendingSelectionChange.mode === "row") {
      const row = rows.find((item) => item.id === pendingSelectionChange.rowId);
      if (!row) return null;
      const comboCount = selectionCombinationCount(row.number, next);
      const fromMinor = rowStake(row, pendingSelectionChange.prev, "resolved");
      const toMinor =
        next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
      return {
        next,
        comboCount,
        number: row.number,
        fromMinor,
        toMinor,
        scope: "row" as const,
      };
    }

    let fromMinor = 0;
    let toMinor = 0;
    let maxCombo = 1;
    rows.forEach((row) => {
      maxCombo = Math.max(maxCombo, selectionCombinationCount(row.number, next));
      fromMinor += rowStake(row, row.selectionType, "resolved");
      toMinor += next === "full_cover" ? rowStake(row, next, "raw") : rowStake(row, next, "resolved");
    });
    return {
      next,
      comboCount: maxCombo,
      number: "",
      fromMinor,
      toMinor,
      scope: "all" as const,
    };
  }, [currencyCode, pendingSelectionChange, playColumns, rows]);

  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebouncedSummary(draftSummary);
    }, 300);
    return () => window.clearTimeout(id);
  }, [draftSummary]);

  const buildLines = (): TicketLineInput[] => collectEntries().map((entry) => entry.line);

  const applyClosedPlayCleanup = (data: unknown): boolean => {
    const payload = data as ClosedPlayCleanupData | null;
    const cleanupLines = Array.isArray(payload?.cleanup_lines) ? payload.cleanup_lines : [];
    if (cleanupLines.length === 0) return false;

    const entries = collectEntries();
    const cleanupPairs = new Set<string>();
    cleanupLines.forEach((item) => {
      const clientLineNo = Number(item?.client_line_no ?? 0);
      const playCode = String(item?.play_code ?? "");
      if (!Number.isInteger(clientLineNo) || clientLineNo <= 0 || playCode.trim() === "") return;
      const entry = entries[clientLineNo - 1];
      if (!entry || entry.play.play_code !== playCode) return;
      cleanupPairs.add(`${entry.rowId}::${entry.amountKey}`);
    });

    if (cleanupPairs.size === 0) return false;

    setRows((current) =>
      current.map((row) => {
        const nextAmounts = { ...row.amounts };
        let changed = false;
        Object.keys(nextAmounts).forEach((amountKey) => {
          if (!cleanupPairs.has(`${row.id}::${amountKey}`)) return;
          nextAmounts[amountKey] = "";
          changed = true;
        });
        return changed ? { ...row, amounts: nextAmounts } : row;
      }),
    );

    return true;
  };

  const handlePreview = async () => {
    if (!display) {
      toast.error(t("hall.noDraw"));
      return;
    }
    if (!isBettable) {
      toast.error(t("hall.notBettable"));
      return;
    }
    if (catalogState.kind !== "ok") {
      toast.error(t("hall.catalogNotReady"));
      return;
    }

    const lineIssues = collectDraftLineIssues();
    const missingProviderAt = providerMissingRow();
    if (missingProviderAt !== null) {
      toast.error(t("hall.providers.rowRequired", { row: missingProviderAt, defaultValue: `第 ${missingProviderAt} 行请选择至少一个开注商` }));
      return;
    }
    if (lineIssues.length > 0) {
      toast.error(formatDraftLineIssue(lineIssues[0]));
      return;
    }

    const lines = buildLines();
    if (lines.length === 0) {
      toast.error(t("hall.emptyLines"));
      return;
    }

    if (previewLoading || placeLoading) {
      return;
    }

    setPreviewLoading(true);
    const requestSeq = ++previewRequestSeqRef.current;
    const traceId = newPlaceTraceId();
    const drawId = display.draw_no;
    const previewCurrencyCode = currencyCode;
    const frozenLines = lines.map((line) => ({
      ...line,
      provider_codes: line.provider_codes ? [...line.provider_codes] : undefined,
    }));
    placeTraceIdRef.current = traceId;
    try {
      const data = await postTicketPreview({
        draw_id: drawId,
        currency_code: previewCurrencyCode,
        client_trace_id: traceId,
        lines: frozenLines,
      });
      if (requestSeq !== previewRequestSeqRef.current) return;
      previewSubmissionRef.current = {
        drawId: data.draw.draw_id.trim() || drawId,
        currencyCode: previewCurrencyCode,
        clientTraceId: traceId,
        lines: frozenLines,
        expectedConfigVersions: data.config_versions,
      };
      setPreviewData(data);
      setPreviewOpen(true);
      rows.forEach((row) => {
        if (row.number.trim()) pushHistory(row.number.trim());
      });
    } catch (e) {
      const code = e instanceof LotteryApiBizError ? e.code : 0;
      const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
      if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
        const payload = e.data as ClosedPlayCleanupData;
        toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
        return;
      }
      if (e instanceof LotteryApiBizError && code === 2008) {
        invalidatePreview();
      }
      if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
        void reloadDraw();
      }
      toast.error(mapTicketBetError(code, msg, t));
    } finally {
      setPreviewLoading(false);
    }
  };

  const handlePlace = async () => {
    if (!previewData) return;
    if (placeLoading) {
      return;
    }
    if (!isBettable) {
      toast.error(t("hall.closedSubmit"));
      return;
    }

    const snapshot = previewSubmissionRef.current;
    if (snapshot === null) {
      toast.error(t("hall.changedBeforeSubmit"));
      return;
    }

    if (snapshot.drawId === "") {
      toast.error(t("hall.notBettable"));
      return;
    }

    setPlaceLoading(true);
    try {
      const data = await postTicketPlace({
        draw_id: snapshot.drawId,
        currency_code: snapshot.currencyCode,
        client_trace_id: snapshot.clientTraceId,
        lines: snapshot.lines,
        expected_config_versions: snapshot.expectedConfigVersions,
      });
      previewSubmissionRef.current = null;
      clearPlaceTraceId();
      setPreviewOpen(false);
      setPreviewData(null);
      setResultData(data);
      setResultOpen(true);
      setRows(newDraftRows());
      setActiveRowId(null);
      triggerWalletPollingAfterBet();
      void refreshWallet();
      void reloadDraw();
      const failureCount = data.summary.failure_count ?? 0;
      const successCount = data.summary.success_count ?? 0;
      if (failureCount > 0 && successCount === 0) {
        toast.error(
          t("hall.placeAllFailed", {
            failed: failureCount,
          }),
        );
      } else if (failureCount > 0) {
        toast.warning(
          t("hall.placePartialFailed", {
            success: successCount,
            failed: failureCount,
          }),
        );
      } else {
        toast.success(
          t("hall.placeSuccess", {
            orderNo: data.order_no,
            amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
          }),
        );
      }
    } catch (e) {
      const code = e instanceof LotteryApiBizError ? e.code : 0;
      const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
      if (e instanceof LotteryApiBizError && code === 2002 && applyClosedPlayCleanup(e.data)) {
        const payload = e.data as ClosedPlayCleanupData;
        toast.error(payload.cleanup_hint ?? t("hall.ticketError.2002"));
        invalidatePreview();
        return;
      }
      if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
        invalidatePreview();
      }
      if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
        void reloadDraw();
      }
      toast.error(mapTicketBetError(code, msg, t));
    } finally {
      setPlaceLoading(false);
    }
  };

  useEffect(() => {
    const onRefresh = () => void refreshWallet();
    window.addEventListener("lottery-wallet-refresh", onRefresh);
    return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
  }, [refreshWallet]);

  if (catalogState.kind === "loading") {
    return (
      <section
        className={cn("space-y-3", isMobile && "pb-44")}
        aria-label={t("hall.aria")}
      >
        <Skeleton className="h-12 rounded-xl" />
        <Skeleton className="h-72 rounded-xl" />
        <Skeleton className="h-14 rounded-xl" />
      </section>
    );
  }

  if (catalogState.kind === "error") {
    return (
      <section className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
        <p>{catalogState.message}</p>
        <Button
          type="button"
          size="sm"
          variant="outline"
          className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
          onClick={() => void loadCatalog()}
        >
          {t("actions.retry")}
        </Button>
      </section>
    );
  }

  const submitActualMinor =
    previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
  const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId));
  const selectedProviderCount = new Set(
    rows
      .filter((row) => activeDraftRowIds.has(row.id))
      .flatMap((row) => row.providerCodes),
  ).size;
  const warningRowCount = rows.filter(
    (row) =>
      row.number.trim().length > 0 &&
      playColumns.some(
        (column) =>
          cellRiskState(
            column.play,
            row.number,
            playCategory(column.play.play_code),
            alertRows,
            liveSoldOutNumbers,
            liveWarningNumbers,
            column.digitSlot,
          ) === "warning",
      ),
  ).length;
  const canSubmit =
    !tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
  const favoriteChips = favorites.slice(0, 10);
  const historyChips = historyNumbers.slice(0, 20);
  const summaryItems = [
    {
      key: "total",
      label: t("hall.table.total", { defaultValue: "共" }),
      totalMinor: submitActualMinor,
    },
    ...summaryPlayColumns.map((column) => ({
      key: column.key,
      label: playColumnHeaderLabel(
        column.play,
        playCategory(column.play.play_code),
        column.digitSlot,
        t,
      ),
      totalMinor: rows.reduce((sum, row) => {
        const amount = row.amounts[column.key];
        if (
          !amount ||
          row.number.trim().length === 0 ||
          draftLineIssueReason(
            column.play.play_code,
            row.number,
            column.digitSlot,
          ) !== null
        ) {
          return sum;
        }
        return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0);
      }, 0),
    })),
  ];
  const desktopReviewModel: HallDesktopReviewModel = {
    lineCount: draftEntries.length,
    providerCount: selectedProviderCount,
    actualAmount: formatMinorAmount(submitActualMinor),
    availableCredit: formatMinorAmount(availableMinor),
    remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)),
    warningCount: warningRowCount,
    creditSufficient: availableMinor >= submitActualMinor,
  };
  return (
    <>
      <section className="space-y-3" aria-label={t("hall.aria")}>

        {jackpot?.enabled ? (
          <div className="relative overflow-hidden rounded-xl border border-[#d6b74e]/70 bg-[linear-gradient(115deg,#06183c_0%,#0a3a89_50%,#071f50_100%)] px-3 py-3 text-white shadow-[0_10px_28px_rgba(7,46,112,0.2)] sm:px-4">
            <div className="pointer-events-none absolute -left-10 top-1/2 size-28 -translate-y-1/2 rounded-full bg-[#2d79ff]/25 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_2.8s_ease-in-out_infinite]" />
            <div className="pointer-events-none absolute -right-5 -top-16 size-36 rounded-full bg-[#f4ca54]/20 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_3.2s_ease-in-out_infinite]" />
            <div className="pointer-events-none absolute -inset-y-10 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-sm motion-safe:animate-[jackpot-strip-sweep_4s_ease-in-out_infinite]" />

            <div className="relative flex flex-wrap items-center justify-between gap-3">
              <div className="min-w-0">
                <p className="w-fit bg-gradient-to-r from-[#fff1ad] via-white to-[#e2bd45] bg-[length:200%_auto] bg-clip-text text-[11px] font-black uppercase tracking-[0.16em] text-transparent motion-safe:animate-[jackpot-shimmer_3s_linear_infinite]">
                  {t("results.jackpotLabel", { defaultValue: "Jackpot" })}
                </p>
                <p className="mt-0.5 truncate font-mono text-xl font-black tabular-nums text-white drop-shadow-[0_0_12px_rgba(245,216,122,0.35)] sm:text-2xl motion-safe:animate-[jackpot-amount-glow_2.4s_ease-in-out_infinite]">
                  {formatMinorAmount(jackpot.current_amount_minor)}
                </p>
              </div>
              {jackpot.draws_since_last_burst !== null ? (
                <p className="ml-auto rounded-full border border-[#f4d66f]/35 bg-white/10 px-3 py-1.5 text-xs font-bold text-[#ffe8a0] shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-sm">
                  {t("results.jackpotGap", {
                    count: jackpot.draws_since_last_burst,
                  })}
                </p>
              ) : null}
            </div>
          </div>
        ) : null}

        <HallPlaySummaryGrid items={summaryItems} className="hidden lg:grid" />

        <HallDesktopWorkflowBar
          stepGame={t("hall.desktop.stepGame", { defaultValue: "选择玩法" })}
          stepFill={t("hall.desktop.stepFill", { defaultValue: "填写号码和金额" })}
          stepReview={t("hall.desktop.stepReview", { defaultValue: "核对并提交" })}
          gameControls={(
            <div className="inline-flex max-w-full items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
              {categoryTabs.map((tab) => {
                const hasPlays = openPlays.some(
                  (play) => playCategory(play.play_code) === tab.value,
                );
                const active = activeCategory === tab.value;

                return (
                  <button
                    key={`desktop-${tab.value}`}
                    type="button"
                    disabled={!hasPlays}
                    onClick={() => setActiveCategory(tab.value)}
                    className={cn(
                      "inline-flex min-w-14 items-center justify-center rounded-lg px-2 py-2 text-sm font-bold transition-colors xl:min-w-[4.5rem] xl:px-3.5",
                      active
                        ? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.24)]"
                        : "text-[#5b7fbf] hover:bg-white hover:text-[#2d63e2]",
                      !hasPlays && "cursor-not-allowed opacity-40",
                    )}
                    aria-pressed={active}
                  >
                    {tab.label}
                  </button>
                );
              })}
            </div>
          )}
          fillControls={(
            <div className="flex min-h-9 min-w-0 flex-wrap items-center gap-1.5">
              {favoriteChips.length > 0 ? (
                <>
                  <span className="mr-0.5 text-xs font-bold text-[#d81435]">
                    {t("hall.quickFill.favorites")}
                  </span>
                  {favoriteChips.slice(0, 5).map((number) => (
                    <button
                      key={`desktop-fav-${number}`}
                      type="button"
                      className="inline-flex h-8 items-center rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 text-xs font-bold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
                      onClick={() => fillCurrentRow(number)}
                    >
                      {number}
                    </button>
                  ))}
                </>
              ) : null}
              <span className="ml-1 mr-0.5 text-xs font-bold text-slate-400">
                {t("hall.quickFill.history")}
              </span>
              {historyChips.length > 0 ? (
                historyChips.slice(0, 6).map((number) => (
                  <button
                    key={`desktop-his-${number}`}
                    type="button"
                    className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
                    onClick={() => fillCurrentRow(number)}
                  >
                    {number}
                  </button>
                ))
              ) : (
                <span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
                  {t("hall.quickFill.emptyHistory")}
                </span>
              )}
            </div>
          )}
          reviewControls={(
            <div className="flex items-center gap-2">
              <Button
                type="button"
                variant="outline"
                className="h-9 flex-1 justify-center rounded-lg border-[#dfe6f0] bg-white text-sm font-bold text-slate-600"
                onClick={clearAllRows}
              >
                <Trash2 className="size-4" aria-hidden />
                {t("hall.quickFill.clearAll")}
              </Button>
            </div>
          )}
        />

        <HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />

        {isMobile ? (
          <HallMobileQuickFill
            activeNumber={activeRow?.number ?? ""}
            favorites={favoriteChips}
            history={historyChips}
            expanded={quickFillExpanded}
            tableDisabled={tableDisabled}
            activeCategory={activeCategory}
            availableCategories={availableCategories}
            onExpandedChange={setQuickFillExpanded}
            onCategoryChange={setActiveCategory}
            onFillNumber={fillCurrentRow}
            onToggleFavorite={toggleFavoriteNumber}
            onClearAll={clearAllRows}
            onApplyQuickAmount={applyQuickAmountToActiveRow}
            onCopyPreviousRow={copyPreviousRowToActiveRow}
            onClearActiveRowAmounts={clearActiveRowAmounts}
            t={t}
          />
        ) : null}

            {activeCategoryPlays.length === 0 ? (
              <div
                className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
                role="status"
              >
                {t("hall.table.noPlaysInCategory")}
              </div>
            ) : null}

            <div
              className={cn(
                "min-w-0",
                !isMobile &&
                  "flex flex-col items-stretch gap-3 min-[1500px]:flex-row min-[1500px]:items-start",
              )}
            >
              <div
                className={cn(
                  "min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
                  isMobile ? "rounded-lg" : "rounded-xl shadow-[0_8px_24px_rgba(15,23,42,0.05)]",
                  tableDisabled && "opacity-55",
                  showWideTableHint && "player-table-scroll-wrap",
                )}
              >
                <HallBettingTable
                  rows={rows}
                  activeRowId={activeRowId}
                  tableDisabled={tableDisabled}
                  tableWidthPx={tableWidthPx}
                  isMobile={isMobile}
                  indexColClass={indexColClass}
                  numberColClass={numberColClass}
                  stickyNumberLeftClass={stickyNumberLeftClass}
                  numberPlaceholder={numberPlaceholder}
                  numberMaxChars={numberMaxChars}
                  activeCategory={activeCategory}
                  playColumns={playColumns}
                  amountColClass={amountColClass}
                  syncAmountColumns={syncAmountColumns}
                  selectionTypeColClass={selectionTypeColClass}
                  showSelectionTypeColumn={showSelectionTypeColumn}
                  selectionTypeOptions={selectionTypeOptions}
                  betProviders={betProviders}
                  providerColClass={providerColClass}
                  rowTotalColClass={rowTotalColClass}
                  alertRows={alertRows}
                  liveSoldOutNumbers={liveSoldOutNumbers}
                  liveWarningNumbers={liveWarningNumbers}
                  currencyCode={currencyCode}
                  onToggleSyncAmountColumn={toggleSyncAmountColumn}
                  onSetAllSelectionTypes={requestAllSelectionTypes}
                  onToggleProviderColumn={toggleProviderColumn}
                  onUpdateRowNumber={updateRowNumber}
                  onUpdateRowSelectionType={requestRowSelectionType}
                  onUpdateAmount={updateAmount}
                  onToggleRowProvider={toggleRowProvider}
                  onSetActiveRowId={setActiveRowId}
                  t={t}
                />

              </div>

              {!isMobile ? (
                <HallDesktopReviewPanel
                  model={desktopReviewModel}
                  disabled={!canSubmit}
                  loading={previewLoading}
                  isBettable={isBettable}
                  onSubmit={() => void handlePreview()}
                  labels={{
                    title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }),
                    lineCount: t("hall.table.lineCount"),
                    providerCount: t("hall.table.providerCount"),
                    actualTotal: t("hall.table.actualTotal"),
                    availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }),
                    remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }),
                    noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }),
                    warningCount: t("hall.desktop.warningCount", {
                      defaultValue: "{{count}} 行接近售罄",
                      count: warningRowCount,
                    }),
                    creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }),
                    creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }),
                    submit: previewLoading
                      ? t("hall.table.previewing")
                      : !isBettable
                        ? t("hall.closed.title")
                        : availableMinor < submitActualMinor
                          ? t("hall.table.insufficientBalance")
                          : t("hall.table.submitBet"),
                  }}
                />
              ) : null}
            </div>

            {sealedBetUi ? (
              <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
                {t("hall.table.sealedHint")}
              </p>
            ) : null}

      </section>

      {isMobile ? (
        <div
          className={cn(
            playerViewportFixedBarClass,
            "bottom-[calc(3.25rem+env(safe-area-inset-bottom,0px))] z-40",
          )}
        >
          <div className="mx-auto w-full border-t border-[#dfe7f3] bg-white">
            <div className="flex items-center gap-2 px-3 pt-2 pb-2.5">
              <div className="min-w-0 flex flex-1 items-center justify-between gap-2">
                <p className="shrink-0 text-[11px] font-semibold text-slate-500">
                  {t("hall.table.draftTotal")}
                </p>
                <p className="truncate text-[19px] font-black tabular-nums text-[#0b3f96]">
                  {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
                </p>
              </div>
              <Button
                type="button"
                disabled={!canSubmit || previewLoading}
                onClick={() => void handlePreview()}
                className={cn(
                  "h-9 min-w-[7.2rem] shrink-0 gap-1.5 rounded-lg border-0 px-3.5 text-[14px] font-bold text-white",
                  !isBettable
                    ? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
                    : "bg-[#e5002c] shadow-none hover:bg-[#d10028]",
                )}
              >
                {!isBettable ? (
                  <Lock className="size-4.5 shrink-0" aria-hidden />
                ) : (
                  <Ticket className="size-4.5 shrink-0" aria-hidden />
                )}
                {previewLoading
                  ? t("hall.table.previewing")
                  : !isBettable
                    ? t("hall.closed.title")
                    : availableMinor < submitActualMinor
                      ? t("hall.table.insufficientBalance")
                      : t("hall.table.submitBet")}
              </Button>
            </div>
          </div>
        </div>
      ) : null}

      <HallBetPreviewDialog
        open={previewOpen}
        onOpenChange={(open) => {
          setPreviewOpen(open);
          if (!open) {
            setPreviewData(null);
            if (!placeLoading) {
              previewRequestSeqRef.current += 1;
              previewSubmissionRef.current = null;
              clearPlaceTraceId();
            }
          }
        }}
        currencyCode={currencyCode}
        data={previewData}
        placing={placeLoading}
        jackpotEnabled={Boolean(jackpot?.enabled)}
        allowSubmit={isBettable && availableMinor >= submitActualMinor}
        onConfirmPlace={() => void handlePlace()}
      />

      <HallBetResultDialog
        open={resultOpen}
        onOpenChange={(open) => {
          setResultOpen(open);
          if (!open) setResultData(null);
        }}
        currencyCode={currencyCode}
        data={resultData}
        jackpotEnabled={Boolean(jackpot?.enabled)}
        creditMode={creditMode}
      />

      <HallSelectionConfirmDialog
        open={pendingSelectionChange !== null}
        preview={pendingSelectionPreview}
        currencyCode={currencyCode}
        onCancel={cancelPendingSelectionChange}
        onConfirm={confirmPendingSelectionChange}
        t={t}
      />
    </>
  );
}
