"use client";

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

import { getPlayEffective } from "@/api/play";
import { getWalletBalance } from "@/api/wallet";
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
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 {
  draftLineIssueReason,
  playNeedsDigitSlot,
  playNeedsDimension,
  ticketNumberSpec,
  type DraftLineIssueReason,
} from "@/features/hall/hall-bet-rules";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { getLotteryEcho } from "@/lib/lottery-echo";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { playLabel } from "@/lib/play-labels";
import {
  PLAY_CATALOG_REFRESH_EVENT,
  type PlayCatalogRefreshSource,
} from "@/lib/play-catalog-events";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
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 { DrawCurrentRiskPoolAlert } from "@/types/api/draw-current";

const MAX_ROWS = 20;

type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";

type DraftRow = {
  id: string;
  number: string;
  amounts: Record<string, string>;
};

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 PlayColumn = {
  key: string;
  play: PlayEffectivePlayRow;
  digitSlot?: number;
};

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

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 CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record<HallCategory, { favorites: string[]; history: string[] }>;

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

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;

type D4PlayGroupId =
  | "big_small"
  | "position"
  | "combo"
  | "roll_straight"
  | "head_tail_odd_even"
  | "digit_big_small";

const D4_PLAY_GROUPS: { id: D4PlayGroupId; labelKey: string; playCodes: readonly string[] }[] = [
  { id: "big_small", labelKey: "hall.d4Group.big_small", playCodes: ["big", "small"] },
  { id: "position", labelKey: "hall.d4Group.position", playCodes: ["pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e"] },
  { id: "combo", labelKey: "hall.d4Group.combo", playCodes: ["box", "ibox", "mbox"] },
  { id: "roll_straight", labelKey: "hall.d4Group.roll_straight", playCodes: ["roll", "straight"] },
  { id: "head_tail_odd_even", labelKey: "hall.d4Group.head_tail_odd_even", playCodes: ["head", "tail", "odd", "even"] },
  { id: "digit_big_small", labelKey: "hall.d4Group.digit_big_small", playCodes: ["digit_big", "digit_small"] },
];

const categoryPlayOrders: Record<Exclude<HallCategory, "JACKPOT">, readonly string[]> = {
  D2: D2_PLAY_ORDER,
  D3: D3_PLAY_ORDER,
  D4: D4_PLAY_ORDER,
};

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: {} };
}

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

type HallTranslate = (key: string, options?: Record<string, unknown>) => string;

/** 表头用短标签，避免 digit_big + 千/百/十/个 挤成一团。 */
function playColumnHeaderLabel(
  play: PlayEffectivePlayRow,
  category: Exclude<HallCategory, "JACKPOT">,
  digitSlot: number | undefined,
  t: HallTranslate,
): string {
  if (digitSlot !== undefined) {
    const kind = play.play_code === "digit_big" ? "big" : "small";
    return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`;
  }
  return playLabel(play.play_code, t);
}

function digitSlotOptions(category: Exclude<HallCategory, "JACKPOT">): number[] {
  if (category === "D2") return [2, 3];
  if (category === "D3") return [1, 2, 3];
  return [0, 1, 2, 3];
}

function digitSlotLabel(category: Exclude<HallCategory, "JACKPOT">, slot: number): string {
  const labels: Record<Exclude<HallCategory, "JACKPOT">, Record<number, string>> = {
    D2: { 2: "十", 3: "个" },
    D3: { 1: "百", 2: "十", 3: "个" },
    D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
  };
  return labels[category][slot] ?? String(slot + 1);
}

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

function playColumnsForCategory(
  plays: PlayEffectivePlayRow[],
  category: Exclude<HallCategory, "JACKPOT">,
): 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 inferCategory(row: PlayEffectivePlayRow): Exclude<HallCategory, "JACKPOT"> {
  if (row.play_code.startsWith("pos_2")) return "D2";
  if (row.play_code.startsWith("pos_3")) return "D3";
  return "D4";
}

function categoryDigits(category: HallCategory): number {
  if (category === "D2") return 2;
  if (category === "D3") return 3;
  if (category === "D4") return 4;
  return 0;
}

function sanitizeNumber(raw: string, category: HallCategory): string {
  if (category === "D4") {
    return raw.replace(/[^0-9Rr]/g, "").toUpperCase().slice(0, 4);
  }
  return raw.replace(/\D/g, "").slice(0, categoryDigits(category));
}

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(
  category: Exclude<HallCategory, "JACKPOT">,
  play: PlayEffectivePlayRow,
  displayNumber: string,
  amountMinor: number,
  digitSlot?: number,
): 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,
  };

  if (playNeedsDimension(play.play_code)) {
    line.dimension = category;
  }
  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 sortedDigits(value: string): string {
  return value.split("").sort().join("");
}

function matchesRiskAlert(
  alertNumber: string,
  playCode: string,
  rowNumber: string,
  category: Exclude<HallCategory, "JACKPOT">,
  digitSlot?: number,
): boolean {
  const normalizedRow = rowNumber.toUpperCase();

  if (playCode === "big" || playCode === "small" || playCode === "straight") {
    return alertNumber === normalizedRow.slice(0, 4);
  }

  if (playCode === "box" || playCode === "ibox" || playCode === "mbox") {
    return sortedDigits(alertNumber) === sortedDigits(normalizedRow.slice(0, 4));
  }

  if (playCode === "roll") {
    const regex = new RegExp(`^${normalizedRow.replace(/R/g, "[0-9]")}$`);
    return regex.test(alertNumber);
  }

  if (playCode.startsWith("pos_4")) {
    return alertNumber === normalizedRow.slice(0, 4);
  }

  if (playCode.startsWith("pos_3")) {
    return alertNumber.endsWith(normalizedRow.slice(-3));
  }

  if (playCode.startsWith("pos_2")) {
    return alertNumber.endsWith(normalizedRow.slice(-2));
  }

  if (playCode === "head") {
    return ["5", "6", "7", "8", "9"].includes(alertNumber[0] ?? "");
  }
  if (playCode === "tail") {
    return ["0", "1", "2", "3", "4"].includes(alertNumber[0] ?? "");
  }
  if (playCode === "odd" || playCode === "even") {
    const last = alertNumber[3] ?? "";
    return playCode === "odd"
      ? ["1", "3", "5", "7", "9"].includes(last)
      : ["0", "2", "4", "6", "8"].includes(last);
  }
  if (playCode === "digit_big" || playCode === "digit_small") {
    const slot = digitSlot ?? digitSlotOptions(category).at(-1) ?? 3;
    const last = alertNumber[slot] ?? "";
    return playCode === "digit_big"
      ? ["5", "6", "7", "8", "9"].includes(last)
      : ["0", "1", "2", "3", "4"].includes(last);
  }

  return false;
}

function cellRiskState(
  play: PlayEffectivePlayRow,
  rowNumber: string,
  category: Exclude<HallCategory, "JACKPOT">,
  alertRows: DrawCurrentRiskPoolAlert[] | undefined,
  liveSoldOutNumbers: ReadonlySet<string>,
  liveWarningNumbers: ReadonlySet<string>,
  digitSlot?: number,
): CellRiskState {
  const normalizedRow = rowNumber.trim().toUpperCase();
  if (!normalizedRow) return "open";

  if (liveSoldOutNumbers.has(normalizedRow)) {
    return "sold_out";
  }

  if (liveWarningNumbers.has(normalizedRow)) {
    return "warning";
  }

  const alerts = alertRows ?? [];
  if (alerts.length === 0) return "open";

  for (const alert of alerts) {
    if (matchesRiskAlert(alert.normalized_number, play.play_code, normalizedRow, category, digitSlot)) {
      return alert.status === "sold_out" ? "sold_out" : "warning";
    }
  }

  return "open";
}

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 [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
  const [d4PlayGroup, setD4PlayGroup] = useState<D4PlayGroupId>("big_small");
  const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
  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 [quickFillState, setQuickFillState] = useState<QuickFillState>(() => loadQuickFillState());
  const [riskStateDrawNo, setRiskStateDrawNo] = useState<string | null>(display?.draw_no ?? null);
  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 holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
    timer: null,
    number: null,
    longPress: false,
  });
  /** 单次预览→确认共用，重试 place 复用，避免重复扣款 */
  const placeTraceIdRef = useRef<string | 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 loadCatalog = useCallback(async () => {
    setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
    try {
      const data = await getPlayEffective({ currency: currencyParam });
      setCatalogState({ kind: "ok", data });
    } catch (e) {
      const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
      setCatalogState({ kind: "error", message: msg });
    }
  }, [currencyParam, t]);

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

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

  useEffect(() => {
    const onCurrencyChange = () => {
      void loadCatalog();
      void refreshWallet();
    };
    window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
    return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
  }, [loadCatalog, refreshWallet]);

  useEffect(() => {
    const onCatalogRefresh = (ev: Event) => {
      void loadCatalog();
      const source = (ev as CustomEvent<{ source?: PlayCatalogRefreshSource }>).detail
        ?.source;
      if (source === "play_config" || source === "risk_cap") {
        setPreviewOpen(false);
        setPreviewData(null);
        clearPlaceTraceId();
        toast.message(t("hall.playConfig.updated"));
      }
    };
    window.addEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
    return () => window.removeEventListener(PLAY_CATALOG_REFRESH_EVENT, onCatalogRefresh);
  }, [clearPlaceTraceId, loadCatalog, t]);

  const openPlays = useMemo(() => {
    if (catalogState.kind !== "ok") return [];
    const order = categoryPlayOrders[activeCategory === "JACKPOT" ? "D4" : activeCategory];
    return sortByPlayOrder(
      catalogState.data.plays
        .filter(isPlayOpenForPlayer)
        .filter((p) => order.includes(p.play_code)),
      order,
    );
  }, [activeCategory, catalogState]);

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

  const categoryPlays = useMemo(() => {
    if (catalogState.kind !== "ok") return [];
    if (activeCategory === "JACKPOT") return [];
    const order = categoryPlayOrders[activeCategory];
    return sortByPlayOrder(
      openPlays.filter((p) => inferCategory(p) === activeCategory || activeCategory === "D4"),
      order,
    );
  }, [activeCategory, catalogState, openPlays]);

  const allPlayColumns = useMemo(() => {
    if (activeCategory === "JACKPOT") return [];
    return playColumnsForCategory(categoryPlays, activeCategory);
  }, [activeCategory, categoryPlays]);

  const playColumns = useMemo(() => {
    if (activeCategory !== "D4") return allPlayColumns;
    const group = D4_PLAY_GROUPS.find((g) => g.id === d4PlayGroup);
    if (!group) return allPlayColumns;
    const allowedCodes = new Set(group.playCodes);
    return allPlayColumns.filter((col) => allowedCodes.has(col.play.play_code));
  }, [activeCategory, allPlayColumns, d4PlayGroup]);

  const compactTable = playColumns.length > 6;
  const amountColClass = compactTable
    ? "w-[3.75rem] min-w-[3.75rem] max-w-[3.75rem]"
    : "w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem]";

  const tableMinWidthPx = useMemo(() => {
    const indexCol = 40;
    const numberCol = activeCategory === "D4" ? 112 : activeCategory === "D3" ? 88 : 72;
    const amountCol = compactTable ? 60 : 72;
    const deleteCol = 36;
    return indexCol + numberCol + playColumns.length * amountCol + deleteCol;
  }, [activeCategory, compactTable, playColumns.length]);

  const showWideTableHint = activeCategory === "D4" && allPlayColumns.length > 8 && playColumns.length > 8;

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

  const drawNo = display?.draw_no ?? null;

  if (riskStateDrawNo !== drawNo) {
    setRiskStateDrawNo(drawNo);
    setLiveSoldOutNumbers(new Set());
    setLiveWarningNumbers(new Set());
  }

  const alertRows = 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 defaultNumberPlaceholder =
    activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
  const numberPlaceholder = useMemo(() => {
    if (activeCategory !== "D4") return defaultNumberPlaceholder;
    const targetRow = activeRow ?? rows[0];
    if (!targetRow) return defaultNumberPlaceholder;
    const hasRollStake = allPlayColumns.some((column) => {
      if (column.play.play_code !== "roll") return false;
      const amount = parseDecimalInputToMinor(targetRow.amounts[column.key] ?? "", currencyCode);
      return amount !== null && amount > 0;
    });
    return hasRollStake ? t("hall.numberInput.rollPlaceholder") : defaultNumberPlaceholder;
  }, [
    activeCategory,
    activeRow,
    currencyCode,
    defaultNumberPlaceholder,
    allPlayColumns,
    rows,
    t,
  ]);

  const availableD4Groups = useMemo(() => {
    if (activeCategory !== "D4") return [];
    return D4_PLAY_GROUPS.filter((group) => {
      const allowedCodes = new Set(group.playCodes);
      return allPlayColumns.some((col) => allowedCodes.has(col.play.play_code));
    });
  }, [activeCategory, allPlayColumns]);

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

  const updateAmount = (rowId: string, playCode: string, value: string) => {
    setRows((current) =>
      current.map((row) =>
        row.id === rowId
          ? { ...row, amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) } }
          : row,
      ),
    );
    setActiveRowId(rowId);
  };

  const addRow = () => {
    setRows((current) => {
      if (current.length >= MAX_ROWS) return current;
      const row = newDraftRow();
      setActiveRowId(row.id);
      return [...current, row];
    });
  };

  const removeRow = (id: string) => {
    setRows((current) => {
      if (current.length <= 1) return current;
      const next = current.filter((row) => row.id !== id);
      setActiveRowId((prev) => (prev === id ? next[0]?.id ?? null : prev));
      return next;
    });
  };

  const clearAllRows = () => {
    if (tableDisabled) return;
    const next = [newDraftRow()];
    setRows(next);
    setActiveRowId(next[0].id);
  };

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

  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 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);
        setPreviewOpen(false);
        setPreviewData(null);
        clearPlaceTraceId();
        toast.warning(
          removed
            ? t("hall.playConfig.playClosedDraftCleared", {
                playCode: evt.play_code,
              })
            : t("hall.playConfig.playClosed", {
                playCode: evt.play_code,
              }),
        );
      }
    };

    const onOddsUpdate = (evt: OddsUpdateWsEvent) => {
      setPreviewOpen(false);
      setPreviewData(null);
      clearPlaceTraceId();
      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;
      });
      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, clearPlaceTraceId, drawNo, hasAmountsForPlay, reloadDraw, t]);

  const collectDraftLineIssues = useCallback((): DraftLineIssue[] => {
    if (activeCategory === "JACKPOT") return [];
    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 issues;
  }, [activeCategory, currencyCode, allPlayColumns, 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[] => {
    if (activeCategory === "JACKPOT") return [];
    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(activeCategory, column.play, row.number, amount, column.digitSlot);
        if (!line) return;
        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;
  }, [activeCategory, currencyCode, allPlayColumns, rows]);

  const draftEntries = collectEntries();
  const draftSummary = useMemo(() => {
    return draftEntries.reduce(
      (acc, entry) => {
        const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
        const rebate = Math.round(entry.amountMinor * rebateRate);
        acc.bet += entry.amountMinor;
        acc.rebate += rebate;
        acc.actual += Math.max(0, entry.amountMinor - rebate);
        return acc;
      },
      { bet: 0, rebate: 0, actual: 0 },
    );
  }, [draftEntries]);

  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();
    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);
    try {
      placeTraceIdRef.current = newPlaceTraceId();
      const data = await postTicketPreview({
        draw_id: display.draw_no,
        currency_code: currencyCode,
        client_trace_id: placeTraceIdRef.current,
        lines,
      });
      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) {
        setPreviewOpen(false);
        setPreviewData(null);
        clearPlaceTraceId();
      }
      if (e instanceof LotteryApiBizError && (code === 2001 || code === 2006)) {
        void reloadDraw();
      }
      toast.error(mapTicketBetError(code, msg, t));
    } finally {
      setPreviewLoading(false);
    }
  };

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

    const lineIssues = collectDraftLineIssues();
    if (lineIssues.length > 0) {
      toast.error(formatDraftLineIssue(lineIssues[0]));
      return;
    }

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

    const traceId = placeTraceIdRef.current ?? newPlaceTraceId();
    placeTraceIdRef.current = traceId;

    const drawIdForBet =
      previewData.draw.draw_id.trim() || display.draw_no.trim();
    if (drawIdForBet === "") {
      toast.error(t("hall.notBettable"));
      return;
    }

    setPlaceLoading(true);
    try {
      const data = await postTicketPlace({
        draw_id: drawIdForBet,
        currency_code: currencyCode,
        client_trace_id: traceId,
        lines,
        expected_config_versions: previewData.config_versions,
      });
      clearPlaceTraceId();
      setPreviewOpen(false);
      setPreviewData(null);
      setResultData(data);
      setResultOpen(true);
      setRows([newDraftRow()]);
      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"));
        setPreviewOpen(false);
        setPreviewData(null);
        clearPlaceTraceId();
        return;
      }
      if (e instanceof LotteryApiBizError && (code === 2008 || code === 2009)) {
        setPreviewOpen(false);
        setPreviewData(null);
        clearPlaceTraceId();
      }
      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="space-y-3" 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 canSubmit =
    !tableDisabled && draftEntries.length > 0 && availableMinor >= submitActualMinor;
  const favoriteChips = favorites.slice(0, 10);
  const historyChips = historyNumbers.slice(0, 20);

  return (
    <>
      <section className="space-y-3" aria-label={t("hall.aria")}>
        <div className="rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
          <div className="grid grid-cols-3 gap-1">
            {categoryTabs.map((tab) => {
              const active = activeCategory === tab.value;
              return (
                <button
                  key={tab.value}
                  type="button"
                  onClick={() => {
                    setActiveCategory(tab.value);
                    setRows((current) =>
                      current.map((row) => ({
                        ...row,
                        number: sanitizeNumber(row.number, tab.value),
                      })),
                    );
                  }}
                  className={cn(
                    "relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
                    active
                      ? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
                      : "text-[#4b5563] hover:bg-[#f4f7fb]",
                    tab.value === "JACKPOT" && active && "bg-[#7b8492]",
                  )}
                >
                  <span className="truncate">{tab.label}</span>
                </button>
              );
            })}
          </div>
        </div>

        {activeCategory === "D4" && availableD4Groups.length > 1 ? (
          <div className="flex gap-1 overflow-x-auto overscroll-x-contain pb-0.5">
            {availableD4Groups.map((group) => {
              const active = d4PlayGroup === group.id;
              return (
                <button
                  key={group.id}
                  type="button"
                  onClick={() => setD4PlayGroup(group.id)}
                  className={cn(
                    "shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
                    active
                      ? "bg-[#07459f] text-white"
                      : "border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f4f7fb]",
                  )}
                >
                  {t(group.labelKey)}
                </button>
              );
            })}
          </div>
        ) : null}

        {jackpot?.enabled ? (
          <div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
            <div className="flex flex-wrap items-center justify-between gap-2">
              <div>
                <p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
                  {t("results.jackpotLabel", { defaultValue: "Jackpot" })}
                </p>
                <p className="font-mono text-xl font-black tabular-nums text-[#07459f]">
                  {formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)}
                </p>
              </div>
              {jackpot.draws_since_last_burst !== null ? (
                <p className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
                  {t("results.jackpotGap", {
                    count: jackpot.draws_since_last_burst,
                  })}
                </p>
              ) : null}
            </div>
          </div>
        ) : null}

        <div className="rounded-xl border border-[#e3ebf7] bg-white px-3 py-3 shadow-[0_8px_24px_rgba(15,23,42,0.045)]">
          <div className="flex items-start justify-between gap-3">
            <div className="min-w-0">
              <p className="text-sm font-bold leading-5 text-slate-950">
                {t("hall.quickFill.title")}
              </p>
              <p className="mt-0.5 text-xs leading-5 text-slate-500">
                {t("hall.quickFill.description")}
              </p>
            </div>
            <div className="flex shrink-0 items-center gap-1.5">
              {activeRow?.number ? (
                    <Button
                      type="button"
                      variant="ghost"
                      size="icon"
                      aria-label={
                        favorites.includes(activeRow.number)
                          ? t("hall.quickFill.unfavorite")
                          : t("hall.quickFill.favorite")
                      }
                      aria-pressed={favorites.includes(activeRow.number)}
                      title={
                        favorites.includes(activeRow.number)
                          ? t("hall.quickFill.unfavorite")
                          : t("hall.quickFill.favorite")
                      }
                      className={cn(
                        "size-8 rounded-full border border-[#ffd7db] bg-[#fff7f8] text-[#d81435] hover:bg-[#fff1f3] hover:text-[#b80f2b]",
                        favorites.includes(activeRow.number) &&
                          "border-[#d81435] bg-[#d81435] text-white hover:bg-[#c51230] hover:text-white",
                      )}
                      onClick={() => toggleFavoriteNumber(activeRow.number)}
                    >
                      <Star
                        className={cn(
                          "size-4",
                          favorites.includes(activeRow.number) && "fill-current",
                        )}
                        aria-hidden
                      />
                    </Button>
                  ) : null}
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon"
                    aria-label={t("hall.quickFill.clearAll")}
                    title={t("hall.quickFill.clearAll")}
                    className="size-8 rounded-full border border-[#dfe6f0] bg-[#f8fafc] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
                    onClick={clearAllRows}
                  >
                    <Trash2 className="size-4" aria-hidden />
                  </Button>
                </div>
              </div>

              <div className="mt-3 space-y-2">
                {favoriteChips.length > 0 ? (
                  <div className="flex flex-wrap items-center gap-1.5">
                    <span className="mr-0.5 text-[11px] font-semibold text-[#d81435]">
                      {t("hall.quickFill.favorites")}
                    </span>
                    {favoriteChips.map((number) => (
                      <button
                        key={`fav-${number}`}
                        type="button"
                        className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-full border border-[#ffd7db] bg-[#fff3f5] px-2.5 text-xs font-semibold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
                        onPointerDown={() => {
                          const current = holdFavoriteRef.current;
                          current.number = number;
                          current.longPress = false;
                          if (current.timer) window.clearTimeout(current.timer);
                          current.timer = window.setTimeout(() => {
                            current.longPress = true;
                            toggleFavoriteNumber(number);
                          }, 500);
                        }}
                        onPointerUp={() => {
                          const current = holdFavoriteRef.current;
                          if (current.timer) {
                            window.clearTimeout(current.timer);
                            current.timer = null;
                          }
                          if (!current.longPress) {
                            fillCurrentRow(number);
                          }
                          current.longPress = false;
                        }}
                        onPointerLeave={() => {
                          const current = holdFavoriteRef.current;
                          if (current.timer) {
                            window.clearTimeout(current.timer);
                            current.timer = null;
                          }
                          current.longPress = false;
                        }}
                      >
                        {number}
                        <span className="text-[11px] font-medium opacity-70">
                          {t("hall.quickFill.tapHold")}
                        </span>
                      </button>
                    ))}
                  </div>
                ) : null}

                <div className="flex flex-wrap items-center gap-1.5">
                  <span className="mr-0.5 text-[11px] font-semibold text-slate-400">
                    {t("hall.quickFill.history")}
                  </span>
                  {historyChips.length > 0 ? (
                    historyChips.map((number) => (
                      <button
                        key={`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>
              </div>
            </div>

            {categoryPlays.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}

            {showWideTableHint ? (
              <p className="mb-2 text-xs leading-5 text-[#58709d]">{t("hall.table.scrollHint")}</p>
            ) : null}

            <div
              className={cn(
                "overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
                tableDisabled && "opacity-55",
                showWideTableHint && "player-table-scroll-wrap",
              )}
            >
              <div className="player-table-scroll overflow-x-auto overscroll-x-contain">
                <table
                  className="w-full border-collapse text-[11px]"
                  style={{ minWidth: tableMinWidthPx }}
                >
                  <thead>
                    <tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
                      <th className="sticky left-0 z-30 w-10 min-w-10 bg-[#f8fafd] px-1 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]">
                        {t("hall.table.no", { defaultValue: "No." })}
                      </th>
                      <th
                        className={cn(
                          "sticky left-10 z-30 bg-[#f8fafd] px-1.5 py-2 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
                          activeCategory === "D4"
                            ? "w-28 min-w-28"
                            : activeCategory === "D3"
                              ? "w-[5.5rem] min-w-[5.5rem]"
                              : "w-[4.5rem] min-w-[4.5rem]",
                        )}
                      >
                        <span className="block text-xs">{t("hall.table.number", { defaultValue: "Number" })}</span>
                        <span className="mt-0.5 block font-mono text-[11px] font-medium text-[#9aa8bd]">
                          {numberPlaceholder}
                        </span>
                      </th>
                      {playColumns.map((column) => (
                        <th
                          key={column.key}
                          className={cn(amountColClass, "px-0.5 py-2 text-center font-bold")}
                        >
                          <span className="block whitespace-nowrap text-[11px] leading-tight">
                            {playColumnHeaderLabel(
                              column.play,
                              activeCategory as Exclude<HallCategory, "JACKPOT">,
                              column.digitSlot,
                              t,
                            )}
                          </span>
                          <span className="mt-0.5 block text-[10px] font-medium text-[#9aa8bd]">
                            {t("hall.table.amountPlaceholder")}
                          </span>
                        </th>
                      ))}
                      <th className="w-9 min-w-9 px-0.5 py-2" aria-label={t("hall.table.delete")} />
                    </tr>
                  </thead>
                  <tbody>
                    {rows.map((row, index) => {
                      const rowKey = row.id;
                      const rowActive = activeRowId === row.id;
                      return (
                        <tr
                          key={rowKey}
                          className={cn(
                            "border-b border-[#f0f3f8] last:border-b-0",
                            rowActive && "bg-[#f5f9ff]/80",
                          )}
                        >
                          <td
                            className={cn(
                              "sticky left-0 z-20 w-10 min-w-10 px-1 py-2 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
                              rowActive ? "bg-[#f5f9ff]" : "bg-white",
                            )}
                          >
                            {index + 1}
                          </td>
                          <td
                            className={cn(
                              "sticky left-10 z-20 px-1.5 py-2 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
                              activeCategory === "D4"
                                ? "w-28 min-w-28"
                                : activeCategory === "D3"
                                  ? "w-[5.5rem] min-w-[5.5rem]"
                                  : "w-[4.5rem] min-w-[4.5rem]",
                              rowActive ? "bg-[#f5f9ff]" : "bg-white",
                            )}
                          >
                            <Input
                              value={row.number}
                              disabled={tableDisabled}
                              inputMode="text"
                              placeholder={numberPlaceholder}
                              onFocus={() => setActiveRowId(row.id)}
                              onClick={() => setActiveRowId(row.id)}
                              onChange={(event) => updateRowNumber(row.id, event.target.value)}
                              className={cn(
                                "h-9 w-full rounded-md border-[#e1e8f3] bg-white px-2 text-center font-mono text-base font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
                                activeCategory === "D4" && "tracking-[0.2em]",
                              )}
                            />
                          </td>
                          {playColumns.map((column) => {
                            const { play } = column;
                            const amountText = row.amounts[column.key] ?? "";
                            const status = cellRiskState(
                              play,
                              row.number,
                              activeCategory as Exclude<HallCategory, "JACKPOT">,
                              alertRows,
                              liveSoldOutNumbers,
                              liveWarningNumbers,
                              column.digitSlot,
                            );
                            const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
                            const hasAmount = amountText.trim().length > 0;

                            return (
                              <td
                                key={`${rowKey}-${column.key}`}
                                className={cn(
                                  "px-1 py-2 align-top",
                                  status === "warning" && "bg-amber-50/70",
                                  status === "sold_out" && "bg-slate-100 text-slate-400",
                                )}
                              >
                                <Input
                                  value={amountText}
                                  disabled={disabled}
                                  inputMode="decimal"
                                  placeholder={
                                    status === "sold_out"
                                      ? t("hall.table.soldOut")
                                      : "-"
                                  }
                                  onFocus={() => setActiveRowId(row.id)}
                                  onClick={() => setActiveRowId(row.id)}
                                  onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
                                  className={cn(
                                    "h-9 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center text-base font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
                                    hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
                                    status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
                                    status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
                                  )}
                                />
                                {status === "sold_out" ? (
                                  <p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
                                    {t("hall.table.soldOut")}
                                  </p>
                                ) : status === "warning" ? (
                                  <p className="mt-0.5 text-center text-[10px] font-bold text-amber-700">
                                    {t("hall.table.warning")}
                                  </p>
                                ) : null}
                              </td>
                            );
                          })}
                          <td className="px-1 py-2 text-center align-middle">
                            <button
                              type="button"
                              disabled={tableDisabled || rows.length <= 1}
                              onClick={() => removeRow(row.id)}
                              className="inline-flex size-8 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
                              aria-label={t("actions.deleteRow", { row: index + 1 })}
                            >
                              <Trash2 className="size-3.5" aria-hidden />
                            </button>
                          </td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              </div>

              <button
                type="button"
                disabled={tableDisabled || rows.length >= MAX_ROWS}
                onClick={addRow}
                className="flex h-10 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] bg-white text-xs font-bold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
              >
                <CirclePlus className="size-4" aria-hidden />
                {t("hall.table.addRow")}
              </button>
            </div>

            <div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-3 py-2.5 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
              <span className="text-xs font-medium text-slate-500">
                {t("hall.table.draftTotal")}
              </span>
              <span className="text-base font-black tabular-nums text-[#0b3f96]">
                {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
              </span>
            </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}

            <Button
              type="button"
              disabled={!canSubmit || previewLoading}
              onClick={() => void handlePreview()}
              className={cn(
                "h-12 w-full gap-2 rounded-xl border-0 text-base font-bold text-white",
                !isBettable
                  ? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
                  : "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
              )}
            >
              {!isBettable ? (
                <Lock className="size-5 shrink-0" aria-hidden />
              ) : (
                <Ticket className="size-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>
      </section>

      <HallBetPreviewDialog
        open={previewOpen}
        onOpenChange={(open) => {
          setPreviewOpen(open);
          if (!open) {
            setPreviewData(null);
            if (!placeLoading) {
              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}
      />
    </>
  );
}
