import javax.swing.*;
import javax.swing.border.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.AffineTransform;
import java.awt.geom.RoundRectangle2D;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.List;
import java.util.regex.*;

public class BlackjackSessionReplay extends JFrame {
    private static final String APP_VERSION = "15.10.59";
    // v15.10.45 embeds CLI V9.2.18-UAT unchanged. Stats Corner story presentation only: per-influence outcome markers (✓/✗/—) clarify realised consequences, and a below-table-minimum Session 4 ending is presented as a forced bankroll exit rather than a voluntary walk. No Stat-Watching behavioural, evidence, replay, Frozen or Casual logic changed.
    private static final Color TABLE = new Color(16, 92, 72);
    private static final Color TABLE_DARK = new Color(9, 63, 51);
    private static final Color NAVY = new Color(10, 41, 75);
    private static final Color GOLD = new Color(220, 183, 74);
    private static final Color PANEL = new Color(245, 248, 250);

    private final JTextArea sessionLabel = new JTextArea("No session loaded");
    private final JLabel handLabel = new JLabel("HAND -- / --");
    private final JLabel bankrollLabel = new JLabel("Bankroll --");
    private final JLabel wagerLabel = new JLabel("Wager --");
    private final JLabel actionLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel resultLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel playerOutcomeLabel = new JLabel(" ", SwingConstants.CENTER);
    private final JLabel dealerTotalLabel = new JLabel("TOTAL --", SwingConstants.CENTER);
    private final JLabel playerTotalLabel = new JLabel("TOTAL --", SwingConstants.CENTER);
    private final ChipDisplayPanel chipDisplay = new ChipDisplayPanel();
    private final ShoePanel shoePanel = new ShoePanel();
    private final PreviousPanel previousPanel = new PreviousPanel();
    private final JPanel dealerCards = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 8));
    private final JPanel playerCards = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 8));
    private final JPanel splitCards = new JPanel(new GridLayout(1,2,15,0));
    private final JButton playButton = new JButton("▶ PLAY");
    private final JButton pauseButton = new JButton("Ⅱ PAUSE");
    private final JButton nextButton = new JButton("NEXT HAND");
    private final JButton compareButton = new JButton("COMPARE");
    private final JButton openButton = new JButton("OPEN output.txt");
    private final JButton liveButton = new JButton("LIVE PLAY UAT");
    private final JComboBox<String> sessionBox = new JComboBox<>();
    private final JSlider speedSlider = new JSlider(1, 10, 4);
    private final JLabel speedValueLabel = new JLabel("1.55 s / step");
    private final RecentExperiencePanel journeyStrip = new RecentExperiencePanel();

    private final java.util.List<Session> sessions = new ArrayList<>();
    private Session currentSession;
    private int handIndex = -1;
    private int stage = 0;
    private int playerRevealIndex = 0;
    private int dealerRevealIndex = 0;
    private int splitARevealIndex = 0;
    private int splitBRevealIndex = 0;
    private int openingStreamRevealCount = 0;
    private javax.swing.Timer timer;
    private String loadedOutputText = "";
    private final boolean suppressOpeningMemorySplash;

    public BlackjackSessionReplay() {
        this(false);
    }

    private BlackjackSessionReplay(boolean suppressOpeningMemorySplash) {
        super("Deterministic Blackjack — Historical Session Replay • v" + APP_VERSION);
        this.suppressOpeningMemorySplash = suppressOpeningMemorySplash;
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setMinimumSize(new Dimension(1220, 680));
        setSize(1540, 860);
        setLocationRelativeTo(null);
        setLayout(new BorderLayout());

        // Keep all interactive controls permanently visible at the top.  The
        // first proof-of-concept placed them at BorderLayout.SOUTH; on some
        // Windows DPI/display configurations the table's preferred height
        // could push that strip below the usable window area.
        JPanel north = new JPanel(new BorderLayout());
        north.add(buildTop(), BorderLayout.NORTH);
        north.add(buildControls(), BorderLayout.SOUTH);
        add(north, BorderLayout.NORTH);
        JPanel body=new JPanel(new BorderLayout(10,0)); body.setBackground(TABLE_DARK); body.setBorder(new EmptyBorder(0,10,10,10));
        shoePanel.setPreferredSize(new Dimension(215,650)); previousPanel.setPreferredSize(new Dimension(415,650));
        body.add(shoePanel,BorderLayout.WEST); body.add(buildTable(),BorderLayout.CENTER); body.add(previousPanel,BorderLayout.EAST); add(body,BorderLayout.CENTER);

        timer = new javax.swing.Timer(delayFromSpeed(speedSlider.getValue()), e -> tick());
        timer.setInitialDelay(delayFromSpeed(speedSlider.getValue()));
        speedSlider.addChangeListener(e -> {
            int d = delayFromSpeed(speedSlider.getValue());
            timer.setDelay(d);
            timer.setInitialDelay(d);
            speedValueLabel.setText(String.format("%.2f s / step", d / 1000.0));
        });
        playButton.addActionListener(e -> { if (currentSession != null) { timer.start(); if(handIndex < 0) nextHand(); }});
        pauseButton.addActionListener(e -> timer.stop());
        nextButton.addActionListener(e -> { timer.stop(); nextHand(); });
        compareButton.addActionListener(e -> { timer.stop(); openCasualComparison(); });
        openButton.addActionListener(e -> chooseFile());
        liveButton.addActionListener(e -> new BlackjackLiveGuiUAT().setVisible(true));
        sessionBox.addActionListener(e -> {
            int i = sessionBox.getSelectedIndex();
            if (i >= 0 && i < sessions.size()) selectSession(sessions.get(i));
        });

        // Shared repository default: GUI and CLI are sibling folders; CLI owns authoritative research data.
        Path p = dataDirectory().resolve("output.txt");
        if (Files.exists(p)) {
            loadFile(p);
        } else {
            sessionLabel.setText("output.txt not found — use OPEN output.txt");
        }
    }

    private Path applicationDirectory() {
        try {
            Path location = Paths.get(BlackjackSessionReplay.class.getProtectionDomain()
                    .getCodeSource().getLocation().toURI()).toAbsolutePath();
            return Files.isDirectory(location) ? location : location.getParent();
        } catch (Exception ex) {
            // Safe fallback for IDE/source launches. RUN_REPLAY.bat also starts in the app folder.
            return Paths.get("").toAbsolutePath();
        }
    }

    private Path dataDirectory() {
        Path app = applicationDirectory();
        Path parent = app.getParent();
        return parent != null ? parent.resolve("CLI") : app;
    }

    private JComponent buildTop() {
        JPanel top = new JPanel();
        top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
        top.setBackground(NAVY);
        top.setBorder(new EmptyBorder(12,22,10,22));

        JLabel title = new JLabel("DETERMINISTIC BLACKJACK • GRAPHICAL SESSION REPLAY • v" + APP_VERSION);
        title.setForeground(Color.WHITE);
        title.setFont(new Font("SansSerif", Font.BOLD, 24));

        JButton statsButton = new JButton("STATS CORNER");
        statsButton.setFont(new Font("SansSerif", Font.BOLD, 11));
        statsButton.setForeground(NAVY);
        statsButton.setBackground(new Color(245, 248, 251));
        statsButton.setFocusPainted(false);
        statsButton.setPreferredSize(new Dimension(132, 30));
        statsButton.setToolTipText("Open Fascinating Stats Corner without restarting the replay application");
        statsButton.addActionListener(e -> showStatsCornerDialog(this));

        JPanel titleRow = new JPanel(new BorderLayout(14, 0));
        titleRow.setOpaque(false);
        titleRow.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleRow.setMaximumSize(new Dimension(Integer.MAX_VALUE, 34));
        titleRow.add(title, BorderLayout.WEST);
        titleRow.add(statsButton, BorderLayout.EAST);

        JLabel sub = new JLabel("Historical playback only • cards and outcomes are read from output.txt • no cards are invented");
        sub.setForeground(new Color(205,220,235));
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);

        // v13.3: session description is experimental provenance, so give it the
        // entire header width and allow long names to wrap instead of truncating.
        sessionLabel.setForeground(Color.WHITE);
        sessionLabel.setFont(new Font("SansSerif", Font.BOLD, 15));
        sessionLabel.setOpaque(false);
        sessionLabel.setEditable(false);
        sessionLabel.setFocusable(false);
        sessionLabel.setLineWrap(true);
        sessionLabel.setWrapStyleWord(true);
        sessionLabel.setBorder(null);
        sessionLabel.setMargin(new Insets(0,0,0,0));
        sessionLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
        sessionLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 46));
        sessionLabel.setPreferredSize(new Dimension(1200, 38));

        top.add(titleRow);
        top.add(Box.createVerticalStrut(2));
        top.add(sub);
        top.add(Box.createVerticalStrut(5));
        top.add(sessionLabel);
        return top;
    }

    private JComponent buildTable() {
        JPanel table = new JPanel();
        table.setBackground(TABLE);
        table.setLayout(new BoxLayout(table, BoxLayout.Y_AXIS));
        table.setBorder(new EmptyBorder(14,30,16,30));

        JPanel meta = new JPanel(new FlowLayout(FlowLayout.CENTER,45,5)); meta.setOpaque(false);
        for (JLabel l : new JLabel[]{handLabel, bankrollLabel, wagerLabel}) {
            l.setForeground(Color.WHITE); l.setFont(new Font("SansSerif", Font.BOLD, 18)); meta.add(l);
        }
        table.add(meta);

        journeyStrip.setAlignmentX(Component.CENTER_ALIGNMENT);
        journeyStrip.setMaximumSize(new Dimension(Integer.MAX_VALUE, 70));
        journeyStrip.setPreferredSize(new Dimension(1000,70));
        table.add(journeyStrip);
        table.add(Box.createVerticalStrut(4));

        chipDisplay.setOpaque(false);
        chipDisplay.setPreferredSize(new Dimension(1100,125));
        chipDisplay.setMaximumSize(new Dimension(1100,125));
        chipDisplay.setAlignmentX(Component.CENTER_ALIGNMENT);

        table.add(zoneHeader("DEALER", dealerTotalLabel));
        dealerCards.setOpaque(false); dealerCards.setPreferredSize(new Dimension(1000,145)); dealerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,145)); table.add(dealerCards);

        actionLabel.setForeground(new Color(255,245,190)); actionLabel.setFont(new Font("SansSerif", Font.BOLD, 19));
        actionLabel.setAlignmentX(Component.CENTER_ALIGNMENT); table.add(actionLabel);

        table.add(zoneHeader("PLAYER", playerTotalLabel));
        playerCards.setOpaque(false); playerCards.setPreferredSize(new Dimension(1000,135)); playerCards.setMaximumSize(new Dimension(Integer.MAX_VALUE,135)); table.add(playerCards);
        playerOutcomeLabel.setForeground(new Color(255, 246, 196)); playerOutcomeLabel.setFont(new Font("SansSerif", Font.BOLD, 24));
        playerOutcomeLabel.setAlignmentX(Component.CENTER_ALIGNMENT); table.add(playerOutcomeLabel);
        splitCards.setOpaque(false); splitCards.setPreferredSize(new Dimension(900,165)); splitCards.setMaximumSize(new Dimension(900,165)); splitCards.setVisible(false); table.add(splitCards);

        // Casino-style chip area belongs with the player, not above the dealer.
        // Keep it low on the felt so the bankroll/stake feels physically attached to the player's position.
        table.add(chipDisplay);

        resultLabel.setForeground(Color.WHITE); resultLabel.setFont(new Font("SansSerif", Font.BOLD, 26));
        resultLabel.setAlignmentX(Component.CENTER_ALIGNMENT); table.add(resultLabel);
        return table;
    }

    private JLabel zoneTitle(String s) {
        JLabel l = new JLabel(s, SwingConstants.CENTER); l.setForeground(Color.WHITE);
        l.setFont(new Font("SansSerif", Font.BOLD, 16)); l.setAlignmentX(Component.CENTER_ALIGNMENT); return l;
    }

    private JComponent zoneHeader(String name, JLabel total) {
        JPanel p = new JPanel(new FlowLayout(FlowLayout.CENTER, 18, 0));
        p.setOpaque(false);
        JLabel n = zoneTitle(name);
        total.setForeground(new Color(255, 235, 160));
        total.setFont(new Font("SansSerif", Font.BOLD, 16));
        p.add(n); p.add(total);
        p.setAlignmentX(Component.CENTER_ALIGNMENT);
        return p;
    }

    private JComponent buildControls() {
        JPanel p = new JPanel(new BorderLayout(15,0));
        p.setBackground(PANEL); p.setBorder(new CompoundBorder(new MatteBorder(1,0,1,0,new Color(210,216,220)), new EmptyBorder(9,16,9,16)));
        JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT,6,0)); left.setOpaque(false);
        left.add(openButton); left.add(liveButton); left.add(new JLabel("Session:")); sessionBox.setPreferredSize(new Dimension(220,30)); left.add(sessionBox);

        // Keep the replay controls on ONE row.  FlowLayout previously wrapped the
        // comparison button onto a clipped second line on common 1600px Windows
        // layouts, making the feature look as though it was missing.
        JPanel centre = new JPanel();
        centre.setOpaque(false);
        centre.setLayout(new BoxLayout(centre, BoxLayout.X_AXIS));
        centre.add(playButton); centre.add(Box.createHorizontalStrut(6));
        centre.add(pauseButton); centre.add(Box.createHorizontalStrut(6));
        centre.add(nextButton); centre.add(Box.createHorizontalStrut(6));
        compareButton.setFont(new Font("SansSerif",Font.BOLD,11));
        compareButton.setPreferredSize(new Dimension(105,30));
        compareButton.setMinimumSize(new Dimension(105,30));
        compareButton.setMaximumSize(new Dimension(105,30));
        centre.add(compareButton);

        JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT,6,0)); right.setOpaque(false);
        right.add(new JLabel("Playback speed:"));
        JLabel slow = new JLabel("SLOW"); slow.setFont(new Font("SansSerif", Font.BOLD, 11)); right.add(slow);
        speedSlider.setOpaque(false); speedSlider.setPreferredSize(new Dimension(105,30));
        speedSlider.setMajorTickSpacing(1); speedSlider.setSnapToTicks(true);
        speedSlider.setToolTipText("Move left for slower playback, right for faster playback");
        right.add(speedSlider);
        JLabel fast = new JLabel("FAST"); fast.setFont(new Font("SansSerif", Font.BOLD, 11)); right.add(fast);
        speedValueLabel.setPreferredSize(new Dimension(78,24)); right.add(speedValueLabel);
        p.add(left,BorderLayout.WEST); p.add(centre,BorderLayout.CENTER); p.add(right,BorderLayout.EAST); return p;
    }


    private static int delayFromSpeed(int speed) {
        // Human-friendly speed control: 1 = slowest, 10 = fastest.
        // Delay is intentionally non-linear enough to make the slow end visibly slower.
        int[] delays = {0, 3200, 2600, 2050, 1550, 1200, 900, 700, 520, 380, 260};
        int s = Math.max(1, Math.min(10, speed));
        return delays[s];
    }

    private void chooseFile() {
        JFileChooser fc = new JFileChooser(); fc.setDialogTitle("Select blackjack output.txt");
        if (fc.showOpenDialog(this)==JFileChooser.APPROVE_OPTION) loadFile(fc.getSelectedFile().toPath());
    }

    private void loadFile(Path path) {
        try {
            String text = Files.readString(path, StandardCharsets.UTF_8);
            loadedOutputText = text;
            List<Session> parsed = Parser.parse(text);
            if (parsed.isEmpty()) throw new IOException("No completed session blocks with HAND summary records were found.");
            sessions.clear(); sessions.addAll(parsed);
            sessionBox.removeAllItems();
            for (Session s: sessions) sessionBox.addItem(s.id + "  •  " + s.hands.size() + " hands");
            int preferred=0;
            for(int i=0;i<sessions.size();i++) if(sessions.get(i).id.contains("20260903_004704")){preferred=i;break;}
            sessionBox.setSelectedIndex(preferred);
            selectSession(sessions.get(preferred));
            updateCompareAvailability();
            if (!suppressOpeningMemorySplash) showOpeningMemorySplash();
        } catch(Exception ex) {
            JOptionPane.showMessageDialog(this, ex.getMessage(), "Could not load replay", JOptionPane.ERROR_MESSAGE);
        }
    }

    private void showOpeningMemorySplash() {
        java.util.List<OpeningFrequency> top = openingFrequencies(sessions);
        JDialog d = new JDialog(this, "Most Frequent Openings • v" + APP_VERSION, true);
        d.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        d.setMinimumSize(new Dimension(980, 560));
        Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
        int splashW = Math.min(1180, Math.max(980, screen.width - 140));
        int splashH = Math.min(790, Math.max(620, screen.height - 150));
        d.setSize(splashW, splashH);
        d.setLocationRelativeTo(this);

        JPanel root = new JPanel(new BorderLayout(0, 14));
        root.setBackground(new Color(248, 250, 252));
        root.setBorder(new EmptyBorder(22, 26, 20, 26));

        int totalHands = totalHandsDealt(sessions);
        double avgDecisionsPerSession = averageDecisionsPerSession(sessions);

        JPanel head = new JPanel(new BorderLayout(18, 0));
        head.setOpaque(false);

        JPanel titleBlock = new JPanel();
        titleBlock.setOpaque(false);
        titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("MOST FREQUENT OPENINGS  •  v" + APP_VERSION);
        title.setFont(new Font("SansSerif", Font.BOLD, 24));
        title.setForeground(NAVY);
        title.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel sub = new JLabel("Memory lane • repeated player total vs dealer upcard • 2+ recorded occurrences");
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setForeground(new Color(80, 92, 104));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleBlock.add(title); titleBlock.add(Box.createVerticalStrut(3)); titleBlock.add(sub);
        head.add(titleBlock, BorderLayout.CENTER);

        JPanel metrics = new JPanel(new GridLayout(1, 2, 10, 0));
        metrics.setOpaque(false);
        metrics.add(openingMetricCard("AVG DECISIONS / SESSION", String.format(Locale.ROOT, "%.2f", avgDecisionsPerSession)));
        metrics.add(openingMetricCard("TOTAL HANDS DEALT", String.valueOf(totalHands)));
        head.add(metrics, BorderLayout.EAST);
        root.add(head, BorderLayout.NORTH);

        JPanel list = new JPanel();
        list.setOpaque(false);
        list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
        if (top.isEmpty()) {
            JLabel empty = new JLabel("No opening state has yet been recorded twice.");
            empty.setFont(new Font("SansSerif", Font.BOLD, 14));
            empty.setForeground(new Color(75, 85, 95));
            empty.setBorder(new EmptyBorder(28, 4, 28, 4));
            list.add(empty);
        } else {
            JPanel hdr = openingHeaderRow();
            list.add(hdr);
            list.add(Box.createVerticalStrut(4));
            for (int i = 0; i < top.size(); i++) {
                list.add(openingRow(i + 1, top.get(i)));
                if (i + 1 < top.size()) list.add(Box.createVerticalStrut(3));
            }
        }
        JScrollPane sp = new JScrollPane(list);
        sp.setBorder(BorderFactory.createEmptyBorder());
        sp.getViewport().setBackground(root.getBackground());
        sp.getVerticalScrollBar().setUnitIncrement(18);
        sp.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);

        // v15.10.11: Memory Lane is intentionally narrower so the statistics area
        // remains readable as the number of sessions grows.
        JPanel centre = new JPanel(new GridBagLayout());
        centre.setOpaque(false);
        GridBagConstraints lc = new GridBagConstraints();
        lc.gridx=0; lc.gridy=0; lc.weightx=0.34; lc.weighty=1.0; lc.fill=GridBagConstraints.BOTH; lc.insets=new Insets(0,0,0,18);
        centre.add(sp, lc);
        GridBagConstraints rc = new GridBagConstraints();
        rc.gridx=1; rc.gridy=0; rc.weightx=0.66; rc.weighty=1.0; rc.fill=GridBagConstraints.BOTH;
        centre.add(buildFascinatingStatsCorner(), rc);
        root.add(centre, BorderLayout.CENTER);

        JButton enter = new JButton("OPEN SESSION REPLAYS");
        enter.setFont(new Font("SansSerif", Font.BOLD, 13));
        enter.setPreferredSize(new Dimension(210, 38));
        enter.addActionListener(e -> d.dispose());
        JPanel foot = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
        foot.setOpaque(false); foot.add(enter); root.add(foot, BorderLayout.SOUTH);

        d.setContentPane(root);
        SwingUtilities.invokeLater(() -> d.setVisible(true));
    }


    public void showStatsCornerDialog(Component owner) {
        if (sessions.isEmpty()) {
            JOptionPane.showMessageDialog(owner, "No session evidence is loaded yet.", "Stats Corner", JOptionPane.INFORMATION_MESSAGE);
            return;
        }
        Window ownerWindow = owner == null ? this : SwingUtilities.getWindowAncestor(owner);
        JDialog d = new JDialog(ownerWindow, "Fascinating Stats Corner • v" + APP_VERSION, Dialog.ModalityType.DOCUMENT_MODAL);
        d.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        d.setMinimumSize(new Dimension(620, 620));
        Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
        d.setSize(Math.min(820, Math.max(680, screen.width - 300)), Math.min(860, Math.max(680, screen.height - 140)));

        JPanel root = new JPanel(new BorderLayout(0, 8));
        root.setBackground(new Color(248, 250, 252));
        root.setBorder(new EmptyBorder(12, 12, 10, 12));
        root.add(buildFascinatingStatsCorner(), BorderLayout.CENTER);

        JButton close = new JButton("RETURN");
        close.setFont(new Font("SansSerif", Font.BOLD, 11));
        close.addActionListener(e -> d.dispose());
        JPanel foot = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
        foot.setOpaque(false);
        foot.add(close);
        root.add(foot, BorderLayout.SOUTH);

        d.setContentPane(root);
        d.setLocationRelativeTo(owner == null ? this : owner);
        d.setVisible(true);
    }

    public static void openStatsCornerFromLive(Component owner) {
        BlackjackSessionReplay host = new BlackjackSessionReplay(true);
        host.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        try {
            host.showStatsCornerDialog(owner);
        } finally {
            host.dispose();
        }
    }

    private JComponent buildFascinatingStatsCorner() {
        FascinatingStats stats = fascinatingStats(sessions);
        JPanel outer = new JPanel(new BorderLayout(0, 10));
        outer.setBackground(Color.WHITE);
        outer.setBorder(new CompoundBorder(
                new LineBorder(new Color(208, 218, 226)),
                new EmptyBorder(14, 14, 14, 14)));

        JPanel titleBlock = new JPanel();
        titleBlock.setOpaque(false);
        titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
        JLabel title = new JLabel("FASCINATING STATS CORNER");
        title.setFont(new Font("SansSerif", Font.BOLD, 18));
        title.setForeground(NAVY);
        title.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel sub = new JLabel("Observed-session curiosities • descriptive only");
        sub.setFont(new Font("SansSerif", Font.PLAIN, 13));
        sub.setForeground(new Color(92, 103, 113));
        sub.setAlignmentX(Component.LEFT_ALIGNMENT);
        titleBlock.add(title); titleBlock.add(Box.createVerticalStrut(2)); titleBlock.add(sub);
        outer.add(titleBlock, BorderLayout.NORTH);

        JPanel content = new ViewportWidthPanel();
        content.setOpaque(false);
        content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS));

        JPanel cards = new JPanel(new GridLayout(1, 2, 9, 0));
        cards.setOpaque(false);
        cards.add(fascinatingMetricCard("DEALER 10 + 10", String.valueOf(stats.dealerTenTen),
                "first two dealer cards"));
        cards.add(fascinatingMetricCard("PLAYER 10-CARD BUSTS", String.valueOf(stats.totalPlayerTenBusts),
                "10/J/Q/K caused bust"));
        cards.setMaximumSize(new Dimension(Integer.MAX_VALUE, 84));
        content.add(cards);
        content.add(Box.createVerticalStrut(9));

        JPanel positive = new JPanel(new BorderLayout(8, 0));
        positive.setBackground(new Color(247, 249, 251));
        positive.setBorder(new CompoundBorder(new LineBorder(new Color(224, 230, 235)), new EmptyBorder(8, 10, 8, 10)));
        JLabel ph = new JLabel("PLAYER 10-CARD BUSTS OCCURRED IN " + stats.sessionsWithTenBust + " SESSIONS");
        ph.setFont(new Font("SansSerif", Font.BOLD, 12));
        ph.setForeground(new Color(58, 72, 86));
        JLabel pv = new JLabel(stats.positiveWithTenBust + " OF THOSE SESSIONS FINISHED IN PROFIT", SwingConstants.RIGHT);
        pv.setFont(new Font("SansSerif", Font.BOLD, 14));
        pv.setForeground(NAVY);
        positive.add(ph, BorderLayout.WEST); positive.add(pv, BorderLayout.EAST);
        positive.setMaximumSize(new Dimension(Integer.MAX_VALUE, 42));
        content.add(positive);
        content.add(Box.createVerticalStrut(10));

        // v15.10.46: rolling research-news panel.  This is deliberately editorial/descriptive:
        // it interrogates currently loaded evidence, may replace older curiosities as new sessions
        // arrive, and never changes Frozen/Casual/Stat-Watching behaviour or formal metrics.
        content.add(buildResearchNewsPanel(stats));
        content.add(Box.createVerticalStrut(12));

        JLabel chartTitle = new JLabel("PLAYER 10-CARD BUSTS BY SESSION");
        chartTitle.setFont(new Font("SansSerif", Font.BOLD, 14));
        chartTitle.setForeground(new Color(45, 57, 68));
        chartTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(chartTitle);
        JLabel chartSub = new JLabel("Bar height = bust count • session profit/loss shown below each session");
        chartSub.setFont(new Font("SansSerif", Font.PLAIN, 12));
        chartSub.setForeground(new Color(100, 108, 116));
        chartSub.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(chartSub);
        content.add(Box.createVerticalStrut(4));

        TenBustBarPanel chart = new TenBustBarPanel(stats.points);
        chart.setPreferredSize(new Dimension(430, 280));
        chart.setMinimumSize(new Dimension(330, 220));
        chart.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
        chart.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(chart);
        content.add(Box.createVerticalStrut(12));

        JLabel casualTitle = new JLabel("CASUAL PLAYER 10-CARD BUSTS BY SESSION");
        casualTitle.setFont(new Font("SansSerif", Font.BOLD, 14));
        casualTitle.setForeground(new Color(45, 57, 68));
        casualTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(casualTitle);
        JLabel casualSub = new JLabel("<html><div style='width:360px'>Same definition • measured where an exact Casual hand trace is available • TRACE N/A = historical Casual exists but exact card trace is not in current runtime evidence</div></html>");
        casualSub.setFont(new Font("SansSerif", Font.PLAIN, 12));
        casualSub.setForeground(new Color(100, 108, 116));
        casualSub.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(casualSub);
        content.add(Box.createVerticalStrut(4));
        TenBustBarPanel casualChart = new TenBustBarPanel(stats.casualPoints);
        casualChart.setPreferredSize(new Dimension(430, 280));
        casualChart.setMinimumSize(new Dimension(330, 220));
        casualChart.setMaximumSize(new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE));
        casualChart.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(casualChart);
        content.add(Box.createVerticalStrut(14));

        JLabel swTitle = new JLabel("STAT-WATCHING CASUAL — EXPLORATORY");
        swTitle.setFont(new Font("SansSerif", Font.BOLD, 14));
        swTitle.setForeground(new Color(45, 57, 68));
        swTitle.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(swTitle);
        JLabel swSub = new JLabel("<html><div style='width:360px'>Deterministic side experiment • reacts only to evidence already visible to the player • Frozen and established Casual remain unchanged</div></html>");
        swSub.setFont(new Font("SansSerif", Font.PLAIN, 12));
        swSub.setForeground(new Color(100,108,116)); swSub.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(swSub); content.add(Box.createVerticalStrut(5));
        if(!stats.statWatchPoints.isEmpty()){
            JLabel swChartTitle=new JLabel("FINAL BANKROLL — CHOSEN VS IF STAYED");
            swChartTitle.setFont(new Font("SansSerif",Font.BOLD,11)); swChartTitle.setForeground(NAVY); swChartTitle.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swChartTitle);
            JLabel swChartLegend=new JLabel("solid = chosen final bankroll  •  outline = same personality with walk-away suppressed");
            swChartLegend.setFont(new Font("SansSerif",Font.PLAIN,9)); swChartLegend.setForeground(new Color(92,102,110)); swChartLegend.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swChartLegend);
            content.add(Box.createVerticalStrut(3));
            // v15.10.38: preserve the accepted five-session density. Additional eligible sessions
            // create another full-size chart beneath the previous one inside the existing scroll pane.
            // Running-profit summaries remain rolling/cumulative through the last session on each chart.
            final int STAT_WATCH_SESSIONS_PER_CHART=5;
            for(int pageStart=0,pageNo=1;pageStart<stats.statWatchPoints.size();pageStart+=STAT_WATCH_SESSIONS_PER_CHART,pageNo++){
                int pageEnd=Math.min(stats.statWatchPoints.size(),pageStart+STAT_WATCH_SESSIONS_PER_CHART);
                java.util.List<StatWatchView> pagePoints=new ArrayList<>(stats.statWatchPoints.subList(pageStart,pageEnd));
                java.util.List<StatWatchView> cumulativePoints=new ArrayList<>(stats.statWatchPoints.subList(0,pageEnd));
                if(pageNo>1){
                    JLabel pageTitle=new JLabel("CONTINUED — SESSIONS "+pagePoints.get(0).label+" TO "+pagePoints.get(pagePoints.size()-1).label);
                    pageTitle.setFont(new Font("SansSerif",Font.BOLD,10)); pageTitle.setForeground(NAVY); pageTitle.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(pageTitle);
                    content.add(Box.createVerticalStrut(3));
                }
                StatWatchBarPanel swp=new StatWatchBarPanel(pagePoints,cumulativePoints);
                swp.setPreferredSize(new Dimension(430,430)); swp.setMinimumSize(new Dimension(330,400));
                swp.setMaximumSize(new Dimension(Integer.MAX_VALUE,490)); swp.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(swp);
                content.add(Box.createVerticalStrut(pageEnd<stats.statWatchPoints.size()?12:8));
            }
            JLabel stories=new JLabel("SESSION STORIES — WHAT CAUGHT HIS EYE"); stories.setFont(new Font("SansSerif",Font.BOLD,10)); stories.setForeground(NAVY); stories.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(stories);
            JLabel storiesSub=new JLabel("<html><div style='width:285px'>A simple account of what the Stat-Watching player noticed, what he did, and what happened.</div></html>"); storiesSub.setFont(new Font("SansSerif",Font.PLAIN,9)); storiesSub.setForeground(new Color(96,105,113)); storiesSub.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(storiesSub);
            content.add(Box.createVerticalStrut(5));
            for(StatWatchView sv:stats.statWatchPoints){content.add(statWatchStoryCard(sv));content.add(Box.createVerticalStrut(6));}
            if(stats.latestStatWatch!=null){
                content.add(Box.createVerticalStrut(2));
                JLabel why=new JLabel("EXAMPLES OF WHAT INFLUENCED HIM — LATEST AVAILABLE SESSION"); why.setFont(new Font("SansSerif",Font.BOLD,10)); why.setForeground(NAVY); why.setAlignmentX(Component.LEFT_ALIGNMENT); content.add(why);
                java.util.List<String> inf=stats.latestStatWatch.influences;
                if(inf.isEmpty()){JLabel z=new JLabel("No unusual wager, hunch or walk-away trigger fired in this session.");z.setFont(new Font("SansSerif",Font.PLAIN,10));z.setForeground(new Color(95,104,112));z.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(z);}
                else for(int i=0;i<Math.min(6,inf.size());i++){JLabel q=new JLabel("<html><div style='width:365px'>• "+html(inf.get(i))+"</div></html>");q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(76,87,97));q.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(q);}
            }
        }else{
            JLabel na=new JLabel("No S-generated Stat-Watching Casual evidence is available yet."); na.setFont(new Font("SansSerif",Font.PLAIN,10));na.setForeground(new Color(105,112,119));na.setAlignmentX(Component.LEFT_ALIGNMENT);content.add(na);
        }
        content.add(Box.createVerticalStrut(10));

        JLabel foot = new JLabel("<html><div style='width:360px'>Positive session = final research bankroll above £100.<br>Dealer 10 + 10 includes any two 10-value cards (10/J/Q/K). Casual TRACE N/A/N/A is never treated as zero.</div></html>");
        foot.setFont(new Font("SansSerif", Font.PLAIN, 10));
        foot.setForeground(new Color(95, 104, 112));
        foot.setAlignmentX(Component.LEFT_ALIGNMENT);
        content.add(foot);

        // v13.2: the Casual chart remains vertically scrollable, while its body now tracks
        // the viewport width so the rightmost sessions and explanatory text cannot be clipped.
        // Keep the heading fixed and the statistics body independently scrollable.
        JScrollPane statsScroll = new JScrollPane(content);
        statsScroll.setBorder(BorderFactory.createEmptyBorder());
        statsScroll.getViewport().setBackground(Color.WHITE);
        statsScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        statsScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED);
        statsScroll.getVerticalScrollBar().setUnitIncrement(18);
        outer.add(statsScroll, BorderLayout.CENTER);
        return outer;
    }

    private JComponent statWatchStoryCard(StatWatchView v) {
        JPanel p=new JPanel(); p.setOpaque(true); p.setBackground(new Color(248,250,252));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(222,228,233)),new EmptyBorder(8,9,8,9)));
        p.setLayout(new BoxLayout(p,BoxLayout.Y_AXIS)); p.setAlignmentX(Component.LEFT_ALIGNMENT); p.setMaximumSize(new Dimension(Integer.MAX_VALUE,900));
        String sessionName=v.label!=null&&v.label.matches("S\\d+")?"SESSION "+v.label.substring(1):v.label;
        double chosenProfit=v.chosenBank-100.0;
        String profitText=Double.isNaN(v.chosenBank)?"":String.format(Locale.ROOT," (PROFIT: %s£%.2f)",chosenProfit>0.004?"+":chosenProfit<-0.004?"-":"",Math.abs(chosenProfit));
        String profitColour=chosenProfit>0.004?"#1769aa":chosenProfit<-0.004?"#c62828":"#304152";
        JLabel h=new JLabel("<html>STORY OF "+html(sessionName)+" — STAT-WATCHING PLAYER <span style='color:"+profitColour+"'>"+html(profitText)+"</span></html>"); h.setFont(new Font("SansSerif",Font.BOLD,10)); h.setForeground(new Color(48,65,82)); h.setAlignmentX(Component.LEFT_ALIGNMENT); p.add(h); p.add(Box.createVerticalStrut(3));
        for(String line:statWatchStoryLines(v)){JLabel q=new JLabel("<html><div style='width:275px'>"+storyHtml(line)+"</div></html>");q.setFont(new Font("SansSerif",Font.PLAIN,10));q.setForeground(new Color(78,88,98));q.setAlignmentX(Component.LEFT_ALIGNMENT);p.add(q);p.add(Box.createVerticalStrut(2));}
        return p;
    }

    private static java.util.List<String> statWatchStoryLines(StatWatchView v){
        java.util.List<String> out=new ArrayList<>();
        out.add(v.preambleStart?"He entered with the observed preamble already part of his visible table experience, so his impressions began before formal Hand 1.":"He entered at the formal-session start and built his impressions only from completed hands he had already seen.");
        out.add("Markers: ✓ = worked out well on this hand/path; ✗ = worked out badly; — = neutral or forced.");
        java.util.List<String> sparks=new ArrayList<>();
        for(String x:v.influences){if(x.contains("— EXIT:"))continue;String y=x.replaceFirst("^Hand \\d+ — ","");sparks.add(y);if(sparks.size()>=2)break;}
        if(sparks.isEmpty())out.add("What caught his eye: nothing unusual was strong enough to trigger a bigger wager or a hunch. He mainly followed the recent table pattern he had seen.");
        else out.add("What caught his eye: "+String.join("  •  ",sparks));
        if(!Double.isNaN(v.meanInitialWager)&&!Double.isNaN(v.maxInitialWager))
            out.add("Wagers: average £"+String.format(Locale.ROOT,"%.2f",v.meanInitialWager)+" • highest £"+String.format(Locale.ROOT,"%.2f",v.maxInitialWager)+".");

        // Audit each evidence-triggered wager press against the realised bankroll movement on that hand.
        for(String x:v.influences){
            Matcher wm=Pattern.compile("Hand (\\d+) — WAGER: (\\d+)x wager \\(£([0-9.]+)\\) • (.+)").matcher(x);
            if(!wm.find())continue;
            int h=Integer.parseInt(wm.group(1)); int mult=Integer.parseInt(wm.group(2)); double wager=Double.parseDouble(wm.group(3));
            String signal=wm.group(4);
            if(h<=0||h>=v.chosenPath.size())continue;
            double before=v.chosenPath.get(h-1), after=v.chosenPath.get(h), delta=after-before;
            String marker=outcomeMarker(delta);
            String verdict;
            if(delta>0.004) verdict="The hand went his way: bankroll rose by £"+String.format(Locale.ROOT,"%.2f",delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else if(delta<-0.004) verdict="The hand went against him: bankroll fell by £"+String.format(Locale.ROOT,"%.2f",-delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else verdict="The hand was neutral: bankroll stayed at £"+String.format(Locale.ROOT,"%.2f",after)+".";
            out.add(marker+" Wager outcome: Hand "+h+": what he had seen ("+signal+") made him press to "+mult+"x / £"+String.format(Locale.ROOT,"%.2f",wager)+". "+verdict);
        }

        // Hunches change the play action.  The retained evidence supports the realised hand result,
        // but not a separate alternate-action replay, so the marker describes only what happened after the hunch.
        for(String x:v.influences){
            Matcher hm=Pattern.compile("Hand (\\d+) — HUNCH: (.+)").matcher(x);
            if(!hm.find())continue;
            int h=Integer.parseInt(hm.group(1)); String detail=hm.group(2);
            if(h<=0||h>=v.chosenPath.size())continue;
            double before=v.chosenPath.get(h-1), after=v.chosenPath.get(h), delta=after-before;
            String marker=outcomeMarker(delta);
            String verdict;
            if(delta>0.004) verdict="That hand went his way, with bankroll rising by £"+String.format(Locale.ROOT,"%.2f",delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else if(delta<-0.004) verdict="That hand went against him, with bankroll falling by £"+String.format(Locale.ROOT,"%.2f",-delta)+" to £"+String.format(Locale.ROOT,"%.2f",after)+".";
            else verdict="That hand was neutral, with bankroll unchanged at £"+String.format(Locale.ROOT,"%.2f",after)+".";
            out.add(marker+" Hunch outcome: Hand "+h+": "+detail+". "+verdict+"");
        }

        if(v.label.equalsIgnoreCase("Session 3"))
            out.add("Nothing tempted him to raise above £15. He reached £167.50 using ordinary wagers. If he had kept playing instead of walking, the same Stat-Watching style reached £265.00 after 30 hands, but only after another 22 hands of play.");
        if(v.label.equalsIgnoreCase("Session 4"))
            out.add("What caught his eye: the recent table looked low-card heavy (48%) and 10-value light (16%). That was enough to tempt a £30 wager despite a recent 0/2 record. The £30 hand then lost.");
        if(v.label.equalsIgnoreCase("Session 5")&&v.chosenPath.size()>4)
            out.add("An earlier walk would have been better here. His bankroll had already reached £152.50 after Hand 4. He eventually walked on Hand 11 with £92.50. Staying to the end would have finished at £62.50.");

        boolean forced=isForcedBankrollExit(v);
        if(forced){
            out.add("— Forced bankroll exit: Hand "+v.exitHand+": bankroll was £"+String.format(Locale.ROOT,"%.2f",v.exitBank)+", below the £15 table minimum. He could not place another £15 table-minimum wager, so this was forced rather than a walk-away choice.");
            out.add("If he stayed: the replay also ends at £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+" on Hand "+v.stayHands+" at the same bankroll boundary. Difference = £0.00.");
        } else if(v.exitHand>0){
            String marker="—";
            if(v.staySourceComplete&&!Double.isNaN(v.stayBank)) marker=outcomeMarker(v.exitBank-v.stayBank);
            out.add(marker+" Exit outcome: Hand "+v.exitHand+": he walked with £"+String.format(Locale.ROOT,"%.2f",v.exitBank)+" because the recent table and bankroll movement were enough to make him stop.");
            String base="If he ignored that walk-away choice and kept the same Stat-Watching style, he would reach £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+" after "+v.stayHands+" hands";
            if(v.staySourceComplete){double d=v.stayBank-v.exitBank;base+=d>0.004?" ( £"+String.format(Locale.ROOT,"%.2f",d)+" more than his exit )":d<-0.004?" ( £"+String.format(Locale.ROOT,"%.2f",-d)+" less than his exit )":" (the same bankroll as his exit)";base+=".";}
            else base+=" before the recorded cards ran out.";
            out.add(base);
        } else {
            out.add("Why he stopped: he never chose an early walk-away. The recorded path ended at "+friendlyTermination(v.chosenTermination)+" after "+v.chosenHands+" hands.");
            if(!Double.isNaN(v.stayBank)) out.add("The stay replay keeps the same Stat-Watching style and simply removes the walk-away choice.");
        }
        return out;
    }

    private static String outcomeMarker(double delta){return delta>0.004?"✓":delta<-0.004?"✗":"—";}
    private static boolean isForcedBankrollExit(StatWatchView v){
        return v!=null && v.exitHand>0 && !Double.isNaN(v.exitBank) && v.exitBank<15.0-0.004
            && "BANKROLL".equals(v.stayTermination) && v.stayHands==v.exitHand
            && !Double.isNaN(v.stayBank) && Math.abs(v.stayBank-v.exitBank)<0.005;
    }

    private static String friendlyTermination(String x){if(x==null||x.isBlank())return "the recorded boundary";if(x.equals("COMPLETED"))return "the 30-hand boundary";if(x.equals("BANKROLL"))return "the bankroll/table-minimum boundary";if(x.equals("SOURCE_EXHAUSTED"))return "captured-cardstream exhaustion";if(x.equals("BEHAVIOURAL_EXIT"))return "his behavioural walk-away rule";return x.toLowerCase(Locale.ROOT).replace('_',' ');}

    // Tracks the viewport width so long labels cannot silently make the chart wider than
    // the visible Stats Corner.  Vertical scrolling remains available; horizontal clipping
    // is therefore removed rather than hidden behind a disabled horizontal scrollbar.
    private static class ViewportWidthPanel extends JPanel implements Scrollable {
        @Override public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); }
        @Override public int getScrollableUnitIncrement(Rectangle visibleRect,int orientation,int direction){ return 18; }
        @Override public int getScrollableBlockIncrement(Rectangle visibleRect,int orientation,int direction){ return Math.max(36, visibleRect.height-36); }
        @Override public boolean getScrollableTracksViewportWidth() { return true; }
        @Override public boolean getScrollableTracksViewportHeight() { return false; }
    }

    private JComponent buildResearchNewsPanel(FascinatingStats stats) {
        JPanel wrap=new JPanel(new BorderLayout(0,6)); wrap.setOpaque(true); wrap.setBackground(Color.WHITE);
        wrap.setBorder(new CompoundBorder(new LineBorder(new Color(218,225,232)),new EmptyBorder(10,12,11,12)));
        JLabel h=new JLabel("OBSERVATIONAL CURIOSITY — RETROSPECTIVE ONLY");
        h.setFont(new Font("SansSerif",Font.BOLD,14)); h.setForeground(NAVY); wrap.add(h,BorderLayout.NORTH);

        JPanel body=new JPanel(); body.setOpaque(false); body.setLayout(new BoxLayout(body,BoxLayout.Y_AXIS));
        JLabel marker=new JLabel("◆"); marker.setFont(new Font("SansSerif",Font.BOLD,20)); marker.setForeground(new Color(112,72,155)); marker.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel headline=new JLabel(" "); headline.setFont(new Font("SansSerif",Font.BOLD,16)); headline.setForeground(new Color(45,57,68)); headline.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel detail=new JLabel(" "); detail.setFont(new Font("SansSerif",Font.PLAIN,14)); detail.setForeground(new Color(74,84,94)); detail.setAlignmentX(Component.LEFT_ALIGNMENT);
        JLabel source=new JLabel(" "); source.setFont(new Font("SansSerif",Font.BOLD,12)); source.setForeground(new Color(92,103,113)); source.setAlignmentX(Component.LEFT_ALIGNMENT);
        body.add(marker); body.add(headline); body.add(Box.createVerticalStrut(3)); body.add(detail); body.add(Box.createVerticalStrut(4)); body.add(source);
        wrap.add(body,BorderLayout.CENTER);
        wrap.setMaximumSize(new Dimension(Integer.MAX_VALUE,168));

        if(!stats.curiosityNews.isEmpty()){
            final int[] idx={0};
            Runnable show=()->{
                CuriosityNews q=stats.curiosityNews.get(idx[0]%stats.curiosityNews.size());
                marker.setForeground(q.colour);
                headline.setText("<html><b>"+html(q.headline)+"</b></html>");
                detail.setText("<html><div style='width:700px'>"+html(q.detail)+"</div></html>");
                source.setText(q.source + (q.displayMs>10000 ? "  •  HELD FOR 20 SECONDS" : ""));
                Object timerObj=wrap.getClientProperty("curiosityTimer");
                if(timerObj instanceof javax.swing.Timer)((javax.swing.Timer)timerObj).setDelay(q.displayMs);
                idx[0]=(idx[0]+1)%stats.curiosityNews.size();
            };
            javax.swing.Timer timer=new javax.swing.Timer(stats.curiosityNews.get(0).displayMs,e->show.run());
            timer.setRepeats(true);
            wrap.putClientProperty("curiosityTimer",timer); // retain timer with panel
            show.run(); timer.start();
        }
        // Empty is intentional: heading remains, but no N/A/filler is manufactured.
        return wrap;
    }

    private static void buildRollingResearchNews(FascinatingStats out,java.util.List<Session> all){
        // v15.10.47: broad descriptive curiosity library.  It deliberately favours exact
        // observed/Frozen card evidence because that is the richest complete source.  Each
        // headline names its evidence/player.  No causal or predictive interpretation is made.
        Session mostRed=null,mostBlack=null,mostCardsSession=null,mostPlayerBJ=null,mostDealerBJ=null,mostPush=null;
        int mostRedN=-1,mostBlackN=-1,mostCardsN=-1,mostPlayerBJN=-1,mostDealerBJN=-1,mostPushN=-1;
        int longestPlayerCards=-1,longestDealerCards=-1,longestRoundCards=-1;
        Session lpS=null,ldS=null,lrS=null; int lpH=0,ldH=0,lrH=0;
        int maxTenPlayerRun=0,maxTenDealerRun=0,maxPlayerBJRun=0,maxDealerBJRun=0,maxWinRun=0,maxLossRun=0,maxPushRun=0;
        Session tenPS=null,tenDS=null,pbjS=null,dbjS=null,winS=null,lossS=null,pushS=null;
        int tenPH=0,tenDH=0,pbjH=0,dbjH=0,winH=0,lossH=0,pushH=0;
        int collisionBJ=0,collision20=0,collision21=0; Session collisionBJS=null,collision20S=null,collision21S=null;
        int collisionBJH=0,collision20H=0,collision21H=0;
        double highRedPct=-1,highBlackPct=-1; Session highRedS=null,highBlackS=null;
        int maxRedPlayerRun=0,maxBlackPlayerRun=0,maxRedDealerRun=0,maxBlackDealerRun=0;
        Session redPS=null,blackPS=null,redDS=null,blackDS=null; int redPH=0,blackPH=0,redDH=0,blackDH=0;
        int playerWinsRed=0,playerWinsBlack=0,playerWinsEqual=0,playerWinsColourKnown=0;
        int dealerWinsRed=0,dealerWinsBlack=0,dealerWinsEqual=0,dealerWinsColourKnown=0;
        int playerAllRedWins=0,playerAllBlackWins=0,dealerAllRedWins=0,dealerAllBlackWins=0;
        int playerThreeCardResolved=0,playerThreeCardWins=0,playerFourPlusResolved=0,playerFourPlusWins=0;
        int hearts=0,diamonds=0,clubs=0,spades=0,aces=0;

        for(Session s:all){
            if(s.publicationNumber<=0||s.hands==null||s.hands.isEmpty())continue;
            int red=0,black=0,total=0,pbj=0,dbj=0,pushes=0,cardsSession=0;
            int curPBJ=0,curDBJ=0,curW=0,curL=0,curP=0;
            for(Hand h:s.hands){
                java.util.List<String> cards=allVisibleCardsForHeadline(h); cardsSession+=cards.size();
                for(String c:cards){
                    if(isRedCard(c))red++;else if(isBlackCard(c))black++; total++;
                    if(c!=null){if(c.contains("♥"))hearts++;else if(c.contains("♦"))diamonds++;else if(c.contains("♣"))clubs++;else if(c.contains("♠"))spades++;}
                    if("A".equals(openingRank(c)))aces++;
                }
                java.util.List<String> pCards=playerCardsForHeadline(h), dCards=dealerCardsForHeadline(h);
                int rpr=longestColourRun(pCards,true), bpr=longestColourRun(pCards,false), rdr=longestColourRun(dCards,true), bdr=longestColourRun(dCards,false);
                if(rpr>maxRedPlayerRun){maxRedPlayerRun=rpr;redPS=s;redPH=h.number;} if(bpr>maxBlackPlayerRun){maxBlackPlayerRun=bpr;blackPS=s;blackPH=h.number;}
                if(rdr>maxRedDealerRun){maxRedDealerRun=rdr;redDS=s;redDH=h.number;} if(bdr>maxBlackDealerRun){maxBlackDealerRun=bdr;blackDS=s;blackDH=h.number;}
                int pc=playerCardCountForHeadline(h), dc=dealerCardCountForHeadline(h), rc=pc+dc;
                if(pc>longestPlayerCards){longestPlayerCards=pc;lpS=s;lpH=h.number;}
                if(dc>longestDealerCards){longestDealerCards=dc;ldS=s;ldH=h.number;}
                if(rc>longestRoundCards){longestRoundCards=rc;lrS=s;lrH=h.number;}
                int pr=longestTenValueRun(playerCardsForHeadline(h)); if(pr>maxTenPlayerRun){maxTenPlayerRun=pr;tenPS=s;tenPH=h.number;}
                int dr=longestTenValueRun(dealerCardsForHeadline(h)); if(dr>maxTenDealerRun){maxTenDealerRun=dr;tenDS=s;tenDH=h.number;}
                boolean pnat=playerNaturalForHeadline(h), dnat=dealerNaturalForHeadline(h);
                if(pnat){pbj++;curPBJ++;}else curPBJ=0; if(curPBJ>maxPlayerBJRun){maxPlayerBJRun=curPBJ;pbjS=s;pbjH=h.number;}
                if(dnat){dbj++;curDBJ++;}else curDBJ=0; if(curDBJ>maxDealerBJRun){maxDealerBJRun=curDBJ;dbjS=s;dbjH=h.number;}
                if(pnat&&dnat){collisionBJ++;collisionBJS=s;collisionBJH=h.number;}
                int pt=playerFinalTotalForHeadline(h),dt=dealerFinalTotalForHeadline(h);
                if(pt==20&&dt==20){collision20++;collision20S=s;collision20H=h.number;}
                if(pt==21&&dt==21){collision21++;collision21S=s;collision21H=h.number;}
                String r=h.result==null?"":h.result.toUpperCase(Locale.ROOT);
                // Simple player-card-count observations: non-split resolved hands only.
                // These are descriptive rates, not action advice.
                if(!h.split && (r.contains("WIN")||r.contains("LOSS")||r.contains("PUSH"))){
                    if(pc==3){playerThreeCardResolved++; if(r.contains("WIN"))playerThreeCardWins++;}
                    if(pc>=4){playerFourPlusResolved++; if(r.contains("WIN"))playerFourPlusWins++;}
                }
                if(r.contains("WIN")){
                    int bal=colourBalance(pCards); if(bal==1)playerWinsRed++;else if(bal==-1)playerWinsBlack++;else if(bal==0)playerWinsEqual++; if(bal!=99)playerWinsColourKnown++;
                    if(allOneColour(pCards,true))playerAllRedWins++; if(allOneColour(pCards,false))playerAllBlackWins++;
                } else if(r.contains("LOSS")){
                    int bal=colourBalance(dCards); if(bal==1)dealerWinsRed++;else if(bal==-1)dealerWinsBlack++;else if(bal==0)dealerWinsEqual++; if(bal!=99)dealerWinsColourKnown++;
                    if(allOneColour(dCards,true))dealerAllRedWins++; if(allOneColour(dCards,false))dealerAllBlackWins++;
                }
                if(r.contains("PUSH")){pushes++;curP++;curW=curL=0;}else if(r.contains("WIN")){curW++;curL=curP=0;}else if(r.contains("LOSS")){curL++;curW=curP=0;}else{curW=curL=curP=0;}
                if(curW>maxWinRun){maxWinRun=curW;winS=s;winH=h.number;} if(curL>maxLossRun){maxLossRun=curL;lossS=s;lossH=h.number;} if(curP>maxPushRun){maxPushRun=curP;pushS=s;pushH=h.number;}
            }
            if(red>mostRedN){mostRedN=red;mostRed=s;} if(black>mostBlackN){mostBlackN=black;mostBlack=s;} if(cardsSession>mostCardsN){mostCardsN=cardsSession;mostCardsSession=s;}
            if(pbj>mostPlayerBJN){mostPlayerBJN=pbj;mostPlayerBJ=s;} if(dbj>mostDealerBJN){mostDealerBJN=dbj;mostDealerBJ=s;} if(pushes>mostPushN){mostPushN=pushes;mostPush=s;}
            if(total>0){double rp=100.0*red/total,bp=100.0*black/total;if(rp>highRedPct){highRedPct=rp;highRedS=s;}if(bp>highBlackPct){highBlackPct=bp;highBlackS=s;}}
        }
        if(playerThreeCardResolved>0)addCuriosity(out,"PLAYER 3-CARD WIN RATE",String.format(Locale.ROOT,"%.1f%% — %d wins from %d resolved player hands that finished with exactly 3 cards.",100.0*playerThreeCardWins/playerThreeCardResolved,playerThreeCardWins,playerThreeCardResolved),"OBSERVED / FROZEN PLAYER — DESCRIPTIVE",new Color(30,105,170),20000);
        if(playerFourPlusResolved>0)addCuriosity(out,"PLAYER 4+ CARD WIN RATE",String.format(Locale.ROOT,"%.1f%% — %d wins from %d resolved player hands that finished with 4 or more cards.",100.0*playerFourPlusWins/playerFourPlusResolved,playerFourPlusWins,playerFourPlusResolved),"OBSERVED / FROZEN PLAYER — DESCRIPTIVE",new Color(30,105,170),20000);
        addCuriosity(out,"MOST RED CARDS IN A SESSION",mostRed==null?"":String.format(Locale.ROOT,"Session %d recorded %d red cards (final bankroll %s).",mostRed.publicationNumber,mostRedN,profitLossLabel(mostRed)),"OBSERVED / FROZEN PLAYER",new Color(190,65,55));
        addCuriosity(out,"MOST BLACK CARDS IN A SESSION",mostBlack==null?"":String.format(Locale.ROOT,"Session %d recorded %d black cards (final bankroll %s).",mostBlack.publicationNumber,mostBlackN,profitLossLabel(mostBlack)),"OBSERVED / FROZEN PLAYER",new Color(55,65,78));
        addCuriosity(out,"HIGHEST RED-CARD SHARE",highRedS==null?"":String.format(Locale.ROOT,"Session %d had %.1f%% red cards (final bankroll %s).",highRedS.publicationNumber,highRedPct,profitLossLabel(highRedS)),"OBSERVED / FROZEN PLAYER",new Color(190,65,55));
        addCuriosity(out,"HIGHEST BLACK-CARD SHARE",highBlackS==null?"":String.format(Locale.ROOT,"Session %d had %.1f%% black cards (final bankroll %s).",highBlackS.publicationNumber,highBlackPct,profitLossLabel(highBlackS)),"OBSERVED / FROZEN PLAYER",new Color(55,65,78));
        if(maxRedPlayerRun>=2)addCuriosity(out,"LONGEST RED-CARD STREAK — PLAYER HAND",String.format(Locale.ROOT,"%d consecutive red cards within one recorded player hand — Session %d, Hand %d.",maxRedPlayerRun,redPS.publicationNumber,redPH),"OBSERVED / FROZEN PLAYER",new Color(190,65,55));
        if(maxBlackPlayerRun>=2)addCuriosity(out,"LONGEST BLACK-CARD STREAK — PLAYER HAND",String.format(Locale.ROOT,"%d consecutive black cards within one recorded player hand — Session %d, Hand %d.",maxBlackPlayerRun,blackPS.publicationNumber,blackPH),"OBSERVED / FROZEN PLAYER",new Color(55,65,78));
        if(maxRedDealerRun>=2)addCuriosity(out,"LONGEST RED-CARD STREAK — DEALER HAND",String.format(Locale.ROOT,"%d consecutive red cards within one recorded dealer hand — Session %d, Hand %d.",maxRedDealerRun,redDS.publicationNumber,redDH),"OBSERVED DEALER",new Color(190,65,55));
        if(maxBlackDealerRun>=2)addCuriosity(out,"LONGEST BLACK-CARD STREAK — DEALER HAND",String.format(Locale.ROOT,"%d consecutive black cards within one recorded dealer hand — Session %d, Hand %d.",maxBlackDealerRun,blackDS.publicationNumber,blackDH),"OBSERVED DEALER",new Color(55,65,78));
        if(playerWinsColourKnown>0)addCuriosity(out,"PLAYER WINS — COLOUR BALANCE",String.format(Locale.ROOT,"Of %d player-winning hands with colour evidence: %.1f%% had more red cards, %.1f%% were equal, and %.1f%% had more black cards.",playerWinsColourKnown,100.0*playerWinsRed/playerWinsColourKnown,100.0*playerWinsEqual/playerWinsColourKnown,100.0*playerWinsBlack/playerWinsColourKnown),"OBSERVED / FROZEN PLAYER — DESCRIPTIVE, NOT PREDICTIVE",new Color(112,72,155));
        if(dealerWinsColourKnown>0)addCuriosity(out,"DEALER WINS — COLOUR BALANCE",String.format(Locale.ROOT,"Of %d dealer-winning hands with colour evidence: %.1f%% had more red cards, %.1f%% were equal, and %.1f%% had more black cards.",dealerWinsColourKnown,100.0*dealerWinsRed/dealerWinsColourKnown,100.0*dealerWinsEqual/dealerWinsColourKnown,100.0*dealerWinsBlack/dealerWinsColourKnown),"OBSERVED DEALER — DESCRIPTIVE, NOT PREDICTIVE",new Color(112,72,155));
        if(playerAllRedWins>0)addCuriosity(out,"ALL-RED PLAYER WINS",String.format(Locale.ROOT,"%d recorded player-winning hand%s contained only red cards.",playerAllRedWins,playerAllRedWins==1?"":"s"),"OBSERVED / FROZEN PLAYER",new Color(190,65,55));
        if(playerAllBlackWins>0)addCuriosity(out,"ALL-BLACK PLAYER WINS",String.format(Locale.ROOT,"%d recorded player-winning hand%s contained only black cards.",playerAllBlackWins,playerAllBlackWins==1?"":"s"),"OBSERVED / FROZEN PLAYER",new Color(55,65,78));
        if(dealerAllRedWins>0)addCuriosity(out,"ALL-RED DEALER WINS",String.format(Locale.ROOT,"%d recorded dealer-winning hand%s contained only red cards.",dealerAllRedWins,dealerAllRedWins==1?"":"s"),"OBSERVED DEALER",new Color(190,65,55));
        if(dealerAllBlackWins>0)addCuriosity(out,"ALL-BLACK DEALER WINS",String.format(Locale.ROOT,"%d recorded dealer-winning hand%s contained only black cards.",dealerAllBlackWins,dealerAllBlackWins==1?"":"s"),"OBSERVED DEALER",new Color(55,65,78));
        int suitMax=Math.max(Math.max(hearts,diamonds),Math.max(clubs,spades));
        String suitName=suitMax==hearts?"HEARTS ♥":suitMax==diamonds?"DIAMONDS ♦":suitMax==clubs?"CLUBS ♣":"SPADES ♠";
        if(suitMax>0)addCuriosity(out,"MOST COMMON RECORDED SUIT",String.format(Locale.ROOT,"%s currently leads the observed evidence with %d recorded cards.",suitName,suitMax),"OBSERVED TABLE — ALL NUMBERED SESSIONS",new Color(112,72,155));
        if(aces>0)addCuriosity(out,"ACE COUNT",String.format(Locale.ROOT,"%d aces have appeared in the currently loaded observed session evidence.",aces),"OBSERVED TABLE — ALL NUMBERED SESSIONS",new Color(150,95,25));
        addCuriosity(out,"MOST CARDS RECORDED IN ONE SESSION",mostCardsSession==null?"":String.format(Locale.ROOT,"Session %d contains %d recorded player/dealer cards.",mostCardsSession.publicationNumber,mostCardsN),"OBSERVED TABLE",new Color(112,72,155));
        addCuriosity(out,"MOST CARDS IN A PLAYER HAND",lpS==null?"":String.format(Locale.ROOT,"%d cards — Session %d, Hand %d.",longestPlayerCards,lpS.publicationNumber,lpH),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        addCuriosity(out,"MOST CARDS IN A DEALER HAND",ldS==null?"":String.format(Locale.ROOT,"%d cards — Session %d, Hand %d.",longestDealerCards,ldS.publicationNumber,ldH),"OBSERVED DEALER",new Color(50,125,100));
        addCuriosity(out,"MOST CARDS IN A SINGLE ROUND",lrS==null?"":String.format(Locale.ROOT,"%d combined player/dealer cards — Session %d, Hand %d.",longestRoundCards,lrS.publicationNumber,lrH),"OBSERVED TABLE",new Color(112,72,155));
        addCuriosity(out,"MOST PLAYER BLACKJACKS IN A SESSION",mostPlayerBJ==null?"":String.format(Locale.ROOT,"Session %d recorded %d player naturals (final bankroll %s).",mostPlayerBJ.publicationNumber,mostPlayerBJN,profitLossLabel(mostPlayerBJ)),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        addCuriosity(out,"MOST DEALER BLACKJACKS IN A SESSION",mostDealerBJ==null?"":String.format(Locale.ROOT,"Session %d recorded %d dealer naturals.",mostDealerBJ.publicationNumber,mostDealerBJN),"OBSERVED DEALER",new Color(50,125,100));
        if(maxPlayerBJRun>=2)addCuriosity(out,"PLAYER BLACKJACK STREAK",String.format(Locale.ROOT,"%d consecutive player naturals ending at Session %d, Hand %d.",maxPlayerBJRun,pbjS.publicationNumber,pbjH),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        if(maxDealerBJRun>=2)addCuriosity(out,"DEALER BLACKJACK STREAK",String.format(Locale.ROOT,"%d consecutive dealer naturals ending at Session %d, Hand %d.",maxDealerBJRun,dbjS.publicationNumber,dbjH),"OBSERVED DEALER",new Color(50,125,100));
        if(collisionBJ>0)addCuriosity(out,"BLACKJACK COLLISION",String.format(Locale.ROOT,"Player and dealer were both dealt natural blackjack %d time%s; latest at Session %d, Hand %d.",collisionBJ,collisionBJ==1?"":"s",collisionBJS.publicationNumber,collisionBJH),"OBSERVED TABLE",new Color(150,95,25));
        if(collision20>0)addCuriosity(out,"20 vs 20 COLLISION",String.format(Locale.ROOT,"Player and dealer both finished on 20 in %d observed hand%s; latest at Session %d, Hand %d.",collision20,collision20==1?"":"s",collision20S.publicationNumber,collision20H),"OBSERVED TABLE",new Color(112,72,155));
        if(collision21>0)addCuriosity(out,"21 vs 21 COLLISION",String.format(Locale.ROOT,"Player and dealer both finished on 21 in %d observed hand%s; latest at Session %d, Hand %d.",collision21,collision21==1?"":"s",collision21S.publicationNumber,collision21H),"OBSERVED TABLE",new Color(112,72,155));
        if(maxTenPlayerRun>=2)addCuriosity(out,"PLAYER 10-VALUE RUN",String.format(Locale.ROOT,"%d consecutive 10/J/Q/K cards within one player hand — Session %d, Hand %d.",maxTenPlayerRun,tenPS.publicationNumber,tenPH),"OBSERVED / FROZEN PLAYER",new Color(30,105,170));
        if(maxTenDealerRun>=2)addCuriosity(out,"DEALER 10-VALUE RUN",String.format(Locale.ROOT,"%d consecutive 10/J/Q/K cards within one dealer hand — Session %d, Hand %d.",maxTenDealerRun,tenDS.publicationNumber,tenDH),"OBSERVED DEALER",new Color(50,125,100));
        if(maxWinRun>=2)addCuriosity(out,"LONGEST WIN STREAK",String.format(Locale.ROOT,"%d consecutive recorded wins ending at Session %d, Hand %d.",maxWinRun,winS.publicationNumber,winH),"OBSERVED / FROZEN PLAYER",new Color(35,135,90));
        if(maxLossRun>=2)addCuriosity(out,"LONGEST LOSS STREAK",String.format(Locale.ROOT,"%d consecutive recorded losses ending at Session %d, Hand %d.",maxLossRun,lossS.publicationNumber,lossH),"OBSERVED / FROZEN PLAYER",new Color(190,65,55));
        if(maxPushRun>=2)addCuriosity(out,"LONGEST PUSH STREAK",String.format(Locale.ROOT,"%d consecutive pushes ending at Session %d, Hand %d.",maxPushRun,pushS.publicationNumber,pushH),"OBSERVED / FROZEN PLAYER",new Color(100,105,115));
        if(mostPush!=null)addCuriosity(out,"MOST PUSHES IN A SESSION",String.format(Locale.ROOT,"Session %d recorded %d pushes (final bankroll %s).",mostPush.publicationNumber,mostPushN,profitLossLabel(mostPush)),"OBSERVED / FROZEN PLAYER",new Color(100,105,115));

        // A small number of reconstructed-player headlines are allowed when exact runtime evidence exists.
        TenBustPoint cb=null,cw=null; for(TenBustPoint p:out.casualPoints){if(!p.available)continue;if(cb==null||p.finalBankroll>cb.finalBankroll)cb=p;if(cw==null||p.finalBankroll<cw.finalBankroll)cw=p;}
        if(cb!=null)addCuriosity(out,"HIGHEST SOURCE-SUPPORTED CASUAL FINISH",String.format(Locale.ROOT,"%s finished at £%.2f in the exact reconstructed Casual trace.",cb.label,cb.finalBankroll),"CASUAL PLAYER — SOURCE-SUPPORTED TRACE",new Color(205,112,30));
        if(cw!=null&&cw!=cb)addCuriosity(out,"LOWEST SOURCE-SUPPORTED CASUAL FINISH",String.format(Locale.ROOT,"%s finished at £%.2f in the exact reconstructed Casual trace.",cw.label,cw.finalBankroll),"CASUAL PLAYER — SOURCE-SUPPORTED TRACE",new Color(205,112,30));
    }

    private static void addCuriosity(FascinatingStats out,String h,String d,String src,Color c){if(d!=null&&!d.isBlank())out.curiosityNews.add(new CuriosityNews(h,d,src,c));}
    private static void addCuriosity(FascinatingStats out,String h,String d,String src,Color c,int displayMs){if(d!=null&&!d.isBlank())out.curiosityNews.add(new CuriosityNews(h,d,src,c,displayMs));}
    private static boolean isRedCard(String c){return c!=null&&(c.contains("♥")||c.contains("♦"));}
    private static boolean isBlackCard(String c){return c!=null&&(c.contains("♣")||c.contains("♠"));}
    private static java.util.List<String> playerCardsForHeadline(Hand h){java.util.List<String>z=new ArrayList<>();if(h==null)return z;if(h.split){if(h.splitA!=null)z.addAll(h.splitA);if(h.splitB!=null)z.addAll(h.splitB);}else if(h.player!=null)z.addAll(h.player);return z;}
    private static java.util.List<String> dealerCardsForHeadline(Hand h){java.util.List<String>z=new ArrayList<>();if(h==null)return z;if(h.dealerUp!=null&&!h.dealerUp.equals("?"))z.add(h.dealerUp);if(h.dealerHidden!=null)z.addAll(h.dealerHidden);return z;}
    private static int dealerCardCountForHeadline(Hand h){return dealerCardsForHeadline(h).size();}
    private static int longestTenValueRun(java.util.List<String> cards){int best=0,cur=0;for(String c:cards){if(isTenValueCard(c)){cur++;best=Math.max(best,cur);}else cur=0;}return best;}
    private static int longestColourRun(java.util.List<String> cards,boolean red){int best=0,cur=0;for(String c:cards){boolean hit=red?isRedCard(c):isBlackCard(c);if(hit){cur++;best=Math.max(best,cur);}else cur=0;}return best;}
    private static int colourBalance(java.util.List<String> cards){if(cards==null||cards.isEmpty())return 99;int red=0,black=0;for(String c:cards){if(isRedCard(c))red++;else if(isBlackCard(c))black++;}if(red+black==0)return 99;return red>black?1:black>red?-1:0;}
    private static boolean allOneColour(java.util.List<String> cards,boolean red){if(cards==null||cards.isEmpty())return false;for(String c:cards){if(red&&!isRedCard(c))return false;if(!red&&!isBlackCard(c))return false;}return true;}
    private static boolean playerNaturalForHeadline(Hand h){java.util.List<String>c=playerCardsForHeadline(h);return !h.split&&c.size()==2&&cardTotal(c)==21;}
    private static boolean dealerNaturalForHeadline(Hand h){java.util.List<String>c=dealerCardsForHeadline(h);return c.size()>=2&&cardTotal(c.subList(0,2))==21;}
    private static int playerFinalTotalForHeadline(Hand h){if(h==null||h.split)return -1;return cardTotal(h.player);}
    private static int dealerFinalTotalForHeadline(Hand h){java.util.List<String>c=dealerCardsForHeadline(h);return c.isEmpty()?-1:cardTotal(c);}
    private static String profitLossLabel(Session s){double v=s.hands.get(s.hands.size()-1).researchEnd()-100.0;return (v>0?"+£":v<0?"−£":"£")+String.format(Locale.ROOT,"%.2f",Math.abs(v));}

    private static int playerCardCountForHeadline(Hand h){
        if(h==null)return 0; if(h.split)return (h.splitA==null?0:h.splitA.size())+(h.splitB==null?0:h.splitB.size()); return h.player==null?0:h.player.size();
    }
    private static java.util.List<String> allVisibleCardsForHeadline(Hand h){
        java.util.List<String> z=new ArrayList<>(); if(h==null)return z;
        z.addAll(playerCardsForHeadline(h)); z.addAll(dealerCardsForHeadline(h)); return z;
    }

    private JPanel fascinatingMetricCard(String heading, String value, String detail) {
        JPanel p = new JPanel();
        p.setBackground(new Color(238, 244, 249));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(218, 226, 233)), new EmptyBorder(8, 8, 7, 8)));
        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
        JLabel h = new JLabel(heading, SwingConstants.CENTER);
        h.setFont(new Font("SansSerif", Font.BOLD, 11)); h.setForeground(new Color(55, 72, 88)); h.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel v = new JLabel(value, SwingConstants.CENTER);
        v.setFont(new Font("SansSerif", Font.BOLD, 25)); v.setForeground(NAVY); v.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel d = new JLabel(detail, SwingConstants.CENTER);
        d.setFont(new Font("SansSerif", Font.PLAIN, 10)); d.setForeground(new Color(100, 108, 116)); d.setAlignmentX(Component.CENTER_ALIGNMENT);
        p.add(h); p.add(Box.createVerticalStrut(1)); p.add(v); p.add(d);
        return p;
    }

    private static int chartPreambleHands(Session s) {
        Matcher m=Pattern.compile("(?i)PREAMBLE\\s+HANDS\\s*:\s*(\\d+)").matcher(s.sessionName==null?"":s.sessionName);
        if(m.find()) try{return Integer.parseInt(m.group(1));}catch(Exception ignored){}
        // Session 7 preamble was historically recorded outside the formal preamble subsystem.
        if(s.publicationNumber==7) return 13;
        return 0;
    }

    private FascinatingStats fascinatingStats(java.util.List<Session> all) {
        FascinatingStats out = new FascinatingStats();
        for (Session s : all) {
            // The parser can expose retained correction/reconstruction blocks with
            // publicationNumber 0.  Fascinating Stats Corner is intentionally
            // limited to the actual numbered observed sessions so a correction
            // replay is never counted as a second independent session.
            if (s.publicationNumber <= 0) continue;
            int tenBusts = 0;
            for (Hand h : s.hands) {
                if (dealerStartsTenTen(h)) out.dealerTenTen++;
                tenBusts += playerTenCardBustCount(h);
            }
            out.totalPlayerTenBusts += tenBusts;
            if (!s.hands.isEmpty()) {
                double finalBankroll = s.hands.get(s.hands.size()-1).researchEnd();
                boolean positive = finalBankroll > 100.000001;
                if (tenBusts > 0) {
                    out.sessionsWithTenBust++;
                    if (positive) out.positiveWithTenBust++;
                }
                String label = "S" + s.publicationNumber;
                boolean chartPreamble = s.hadPreamble || s.publicationNumber==7;
                int chartPreambleHands = chartPreambleHands(s);
                out.points.add(TenBustPoint.measured(label, finalBankroll, tenBusts, chartPreamble, chartPreambleHands));

                // v13.2: distinguish "no Casual reconstruction" from "a historical Casual
                // replay exists but its hand-by-hand card trace is not present in the current
                // runtime evidence".  A missing trace must never be rendered as zero, and it
                // must not imply that the historical Casual journey itself did not exist.
                if (isSourceVerifiedCasualBlock(loadedOutputText, s.id)) {
                    try {
                        List<CompareHand> casual = buildCasualTrace(loadedOutputText, s.id);
                        int casualBusts = 0;
                        for (CompareHand ch : casual) casualBusts += compareHandTenCardBustCount(ch);
                        if (!casual.isEmpty()) {
                            double casualFinal = casual.get(casual.size()-1).after;
                            out.casualPoints.add(TenBustPoint.measured(label, casualFinal, casualBusts, chartPreamble, chartPreambleHands));
                        } else out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    } catch (Exception ex) {
                        out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    }
                } else {
                    HistoricalCasualMeta hm = historicalCasualMeta(s.id);
                    if (hm != null) {
                        out.casualPoints.add(TenBustPoint.historicalTraceMissing(label, hm.finalBankroll));
                    } else {
                        out.casualPoints.add(TenBustPoint.unavailable(label, "N/A", chartPreamble, chartPreambleHands));
                    }
                }
            }
        }
        buildRollingResearchNews(out, all);
        out.statWatchPoints = allStatWatch(all);
        out.latestStatWatch = out.statWatchPoints.isEmpty()?null:out.statWatchPoints.get(out.statWatchPoints.size()-1);
        return out;
    }

    private java.util.List<StatWatchView> allStatWatch(java.util.List<Session> all) {
        java.util.List<StatWatchView> out=new ArrayList<>();
        for(Session s:all){
            if(s.publicationNumber<=0)continue;
            StatWatchView v=parseStatWatchBlock(loadedOutputText,s.id,"S"+s.publicationNumber);
            if(v!=null&&!Double.isNaN(v.chosenBank)){
                computeStatWatchWagerSummary(v);
                v.frozenComparableBank=frozenComparableBankroll(s,v);
                out.add(v);
            }
        }
        return out;
    }

    private double frozenComparableBankroll(Session s,StatWatchView v){
        if(s==null||v==null)return Double.NaN;
        if(v.preambleStart){
            String sid=Pattern.quote(s.id);
            Pattern p=Pattern.compile("(?s)=+\\s*PREAMBLE-START COUNTERFACTUAL \\| SESSION "+sid+" =+.*?Frozen from preamble start: hands \\d+ \\| final £([0-9.]+)");
            Matcher m=p.matcher(loadedOutputText==null?"":loadedOutputText);
            if(m.find())try{return Double.parseDouble(m.group(1));}catch(Exception ignored){}
            return Double.NaN;
        }
        if(s.hands==null||s.hands.isEmpty())return Double.NaN;
        return s.hands.get(s.hands.size()-1).researchEnd();
    }

    private static void computeStatWatchWagerSummary(StatWatchView v){
        if(v==null||v.chosenHands<=0)return;
        double total=15.0*v.chosenHands, max=15.0;
        for(String x:v.influences){
            Matcher m=Pattern.compile("Hand (\\d+) — WAGER: \\d+x wager \\(£([0-9.]+)\\)").matcher(x);
            if(m.find()){
                try{double wager=Double.parseDouble(m.group(2)); total+=wager-15.0; max=Math.max(max,wager);}catch(Exception ignored){}
            }
        }
        v.meanInitialWager=total/v.chosenHands; v.maxInitialWager=max;
    }

    private static boolean isSourceVerifiedCasualBlock(String text, String sid) {
        if (text == null || sid == null) return false;
        int a = text.indexOf("================ SESSION " + sid + " ================");
        if (a < 0) return false;
        int b = text.indexOf("================ SESSION ", a + 20);
        if (b < 0) b = text.length();
        String block = text.substring(a, b);
        return block.contains("Casual reconstructed:") && block.contains("RECONSTRUCTION STATUS: SOURCE-VERIFIED");
    }

    // Frozen historical register used only to state that an earlier Casual replay existed
    // and to show its already-audited closing bankroll.  It is NOT used to manufacture a
    // 10-card-bust count.  Until an exact historical hand trace is available, that count is
    // shown as TRACE N/A.  Sessions 4, 6 and 7 intentionally have no entry here because their
    // observed-source Casual reconstruction is incomplete/not source-verified.
    private static HistoricalCasualMeta historicalCasualMeta(String sid) {
        if (sid == null) return null;
        if (sid.contains("20260903_004704")) return new HistoricalCasualMeta(347.50);
        if (sid.contains("20260903_221223")) return new HistoricalCasualMeta(160.00);
        if (sid.contains("20260905_174453")) return new HistoricalCasualMeta(182.50);
        return null;
    }

    private static class HistoricalCasualMeta {
        final double finalBankroll;
        HistoricalCasualMeta(double finalBankroll) { this.finalBankroll = finalBankroll; }
    }

    private static int compareHandTenCardBustCount(CompareHand h) {
        int n = 0;
        if (h.branches != null && !h.branches.isEmpty()) {
            for (List<String> seq : h.branches) if (sequenceBustByTen(seq)) n++;
        } else if (sequenceBustByTen(h.player)) n++;
        return n;
    }

    private static boolean dealerStartsTenTen(Hand h) {
        if (!isTenValueCard(h.dealerUp) || h.dealerHidden.isEmpty()) return false;
        return isTenValueCard(h.dealerHidden.get(0));
    }

    private static int playerTenCardBustCount(Hand h) {
        if (h.split) {
            int n = 0;
            if (sequenceBustByTen(h.splitA)) n++;
            if (sequenceBustByTen(h.splitB)) n++;
            return n;
        }
        return sequenceBustByTen(h.player) ? 1 : 0;
    }

    private static boolean sequenceBustByTen(List<String> cards) {
        if (cards == null || cards.size() < 3) return false;
        if (!isTenValueCard(cards.get(cards.size()-1))) return false;
        List<String> before = cards.subList(0, cards.size()-1);
        return cardTotal(before) <= 21 && cardTotal(cards) > 21;
    }

    private static boolean isTenValueCard(String card) {
        String r = openingRank(card);
        return r.equals("10") || r.equals("J") || r.equals("Q") || r.equals("K");
    }

    private static class CuriosityNews {
        final String headline,detail,source; final Color colour; final int displayMs;
        CuriosityNews(String headline,String detail,String source,Color colour){this(headline,detail,source,colour,10000);}
        CuriosityNews(String headline,String detail,String source,Color colour,int displayMs){this.headline=headline;this.detail=detail;this.source=source;this.colour=colour;this.displayMs=displayMs;}
    }

    private static class FascinatingStats {
        int dealerTenTen = 0;
        int totalPlayerTenBusts = 0;
        int sessionsWithTenBust = 0;
        int positiveWithTenBust = 0;
        java.util.List<TenBustPoint> points = new ArrayList<>();
        java.util.List<TenBustPoint> casualPoints = new ArrayList<>();
        java.util.List<CuriosityNews> curiosityNews = new ArrayList<>();
        java.util.List<StatWatchView> statWatchPoints = new ArrayList<>();
        StatWatchView latestStatWatch = null;
    }

    private static class StatWatchView {
        String label="", status=""; int exitHand=0,chosenHands=0,stayHands=0,preambleHands=0; double chosenBank=Double.NaN,exitBank=Double.NaN,stayBank=Double.NaN;
        double frozenComparableBank=Double.NaN, meanInitialWager=Double.NaN, maxInitialWager=Double.NaN;
        String chosenTermination="",stayTermination="",exitReason=""; boolean staySourceComplete=false,preambleStart=false;
        java.util.List<Double> chosenPath=new ArrayList<>(),stayPath=new ArrayList<>(); java.util.List<String> influences=new ArrayList<>();
    }

    private static StatWatchView parseStatWatchBlock(String text,String sid,String label){
        if(text==null||sid==null)return null;String start="================ STAT-WATCHING CASUAL | SESSION "+sid+" ================";int a=text.indexOf(start);if(a<0)return null;int b=text.indexOf("================ END STAT-WATCHING CASUAL | SESSION "+sid+" ================",a);if(b<0)b=text.length();String block=text.substring(a,b);StatWatchView v=new StatWatchView();v.label=label;v.preambleStart=block.contains("REPLAY BASIS: PREAMBLE_START");
        Matcher pre=Pattern.compile("PREAMBLE CONTEXT:.*?preamble hands (\\d+)").matcher(block);if(pre.find())v.preambleHands=Integer.parseInt(pre.group(1));
        Matcher m=Pattern.compile("Chosen behavioural path: hands (\\d+) \\| bankroll £([0-9.]+).*?termination ([A-Z_]+)(?: \\| chosen exit Hand (\\d+) at £([0-9.]+))?").matcher(block);if(m.find()){v.chosenHands=Integer.parseInt(m.group(1));v.chosenBank=Double.parseDouble(m.group(2));v.chosenTermination=m.group(3);if(m.group(4)!=null){v.exitHand=Integer.parseInt(m.group(4));v.exitBank=Double.parseDouble(m.group(5));}}
        m=Pattern.compile("Stay-at-table counterfactual: hands (\\d+) \\| bankroll £([0-9.]+).*?termination ([A-Z_]+) \\| (SOURCE-COMPLETE|NOT SOURCE-COMPLETE)").matcher(block);if(m.find()){v.stayHands=Integer.parseInt(m.group(1));v.stayBank=Double.parseDouble(m.group(2));v.stayTermination=m.group(3);v.staySourceComplete=m.group(4).equals("SOURCE-COMPLETE");}
        Matcher pm=Pattern.compile("(?m)^PATH_CHOSEN: (.+)$").matcher(block);if(pm.find())v.chosenPath=parseBankPath(pm.group(1));pm=Pattern.compile("(?m)^PATH_STAY: (.+)$").matcher(block);if(pm.find())v.stayPath=parseBankPath(pm.group(1));
        Matcher im=Pattern.compile("(?m)^INFLUENCE \\| H?(\\d+|NONE) \\| ([^|]+) \\| (.+)$").matcher(block);while(im.find()){if(!im.group(1).equals("NONE")){String kind=im.group(2).trim(),detail=im.group(3).trim();v.influences.add("Hand "+im.group(1)+" — "+kind+": "+detail);if(kind.equals("EXIT"))v.exitReason=detail;}}
        return v;
    }
    private static java.util.List<Double> parseBankPath(String raw){java.util.List<Double>x=new ArrayList<>();for(String q:raw.trim().split(";")){int eq=q.indexOf('=');if(eq<0)continue;try{x.add(Double.parseDouble(q.substring(eq+1).trim()));}catch(Exception ignored){}}return x;}
    private static String html(String x){return x==null?"":x.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;");}
    private static String storyHtml(String x){
        String safe=html(x);
        if(safe.startsWith("✓ ")) safe="<span style='color:#188038;font-weight:bold'>✓</span> "+safe.substring(2);
        else if(safe.startsWith("✗ ")) safe="<span style='color:#c62828;font-weight:bold'>✗</span> "+safe.substring(2);
        else if(safe.startsWith("— ")) safe="<span style='color:#1769aa;font-weight:bold'>—</span> "+safe.substring(2);
        safe=safe.replace("✓ =","<span style='color:#188038;font-weight:bold'>✓</span> =")
                 .replace("✗ =","<span style='color:#c62828;font-weight:bold'>✗</span> =")
                 .replace("— =","<span style='color:#1769aa;font-weight:bold'>—</span> =");
        return safe.replaceAll("\\bHand (\\d+)\\b","<b>Hand $1</b>");
    }

    private static class StatWatchBarPanel extends JPanel {
        final java.util.List<StatWatchView> pts;
        final java.util.List<StatWatchView> cumulativePts;
        StatWatchBarPanel(java.util.List<StatWatchView> pts){this(pts,pts);}
        StatWatchBarPanel(java.util.List<StatWatchView> pts,java.util.List<StatWatchView> cumulativePts){this.pts=pts;this.cumulativePts=cumulativePts;setOpaque(true);setBackground(Color.WHITE);setBorder(new LineBorder(new Color(226,231,235)));setToolTipText("Solid bar = chosen final bankroll when he actually stopped/ended; outlined bar = final bankroll if only his walk-away decision is ignored. PRE = replay began at observed preamble start. Running profit is cumulative through the last session on this chart.");}
        @Override protected void paintComponent(Graphics g){
            super.paintComponent(g); Graphics2D g2=(Graphics2D)g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth(),h=getHeight(),left=202,right=10,top=104,bottom=84,pw=Math.max(40,w-left-right),ph=Math.max(60,h-top-bottom);

            double chosenRunningProfit=0.0, stayedRunningProfit=0.0, frozenRunningProfit=0.0;
            boolean stayedTotalComplete=true, frozenTotalComplete=true; int supportedSessions=0;
            for(StatWatchView v:cumulativePts){
                double chosen=v.exitHand>0?v.exitBank:v.chosenBank;
                if(!Double.isNaN(chosen)){chosenRunningProfit+=chosen-100.0;supportedSessions++;}
                if(!Double.isNaN(v.stayBank))stayedRunningProfit+=v.stayBank-100.0; else stayedTotalComplete=false;
                if(!Double.isNaN(v.frozenComparableBank))frozenRunningProfit+=v.frozenComparableBank-100.0; else frozenTotalComplete=false;
            }
            double max=100; for(StatWatchView v:pts){if(!Double.isNaN(v.chosenBank))max=Math.max(max,v.chosenBank);if(!Double.isNaN(v.stayBank))max=Math.max(max,v.stayBank);} max=Math.ceil((max+25)/50.0)*50;

            // Running-profit summary in its own white-space column, deliberately outside the plot.
            int sx=18, sy=116;
            g2.setColor(NAVY); g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.drawString("RUNNING PROFIT",sx,sy);
            g2.setFont(new Font("SansSerif",Font.BOLD,11)); g2.setColor(new Color(51,66,82));
            g2.drawString("Chosen exits",sx,sy+26);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(64,91,122));
            g2.drawString(moneySigned(chosenRunningProfit),sx,sy+45);
            g2.setFont(new Font("SansSerif",Font.BOLD,11)); g2.setColor(new Color(51,66,82));
            g2.drawString("If always stayed",sx,sy+70);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(100,112,124));
            g2.drawString(stayedTotalComplete?moneySigned(stayedRunningProfit):"N/A",sx,sy+89);
            g2.setFont(new Font("SansSerif",Font.BOLD,11)); g2.setColor(new Color(51,66,82));
            g2.drawString("Walk-away effect",sx,sy+114);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(155,72,67));
            g2.drawString(stayedTotalComplete?moneySigned(chosenRunningProfit-stayedRunningProfit):"N/A",sx,sy+133);
            g2.setFont(new Font("SansSerif",Font.PLAIN,9)); g2.setColor(new Color(105,112,119));
            g2.drawString("Across "+supportedSessions+" playable session"+(supportedSessions==1?"":"s"),sx,sy+154);
            g2.setColor(new Color(225,230,234)); g2.drawLine(sx,sy+166,left-28,sy+166);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(NAVY);
            g2.drawString("RUNNING PROFIT — FROZEN",sx,sy+187);
            g2.setFont(new Font("SansSerif",Font.PLAIN,9)); g2.setColor(new Color(86,96,105));
            g2.drawString("Same "+supportedSessions+" sessions with Frozen policy",sx,sy+202);
            g2.drawString("(30-hand horizon)",sx,sy+215);
            g2.setFont(new Font("SansSerif",Font.BOLD,14)); g2.setColor(new Color(40,82,109));
            g2.drawString(frozenTotalComplete?moneySigned(frozenRunningProfit):"N/A",sx,sy+237);

            // Grid, ticks and a dedicated y-axis title beside the plot rather than over the running-profit block.
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            for(int k=0;k<=4;k++){
                double val=max*k/4.0; int y=top+ph-(int)Math.round(val/max*ph);
                g2.setColor(new Color(232,236,239)); g2.drawLine(left,y,left+pw,y);
                g2.setColor(new Color(92,102,110)); String tick="£"+String.format(Locale.ROOT,"%.0f",val);
                g2.drawString(tick,left-14-g2.getFontMetrics().stringWidth(tick),y+3);
            }
            AffineTransform axisTx=g2.getTransform(); g2.rotate(-Math.PI/2);
            g2.setFont(new Font("SansSerif",Font.BOLD,10)); g2.setColor(new Color(62,72,81)); String yAxis="Final bankroll (£)";
            g2.drawString(yAxis,-(top+ph/2+g2.getFontMetrics().stringWidth(yAxis)/2),left-54); g2.setTransform(axisTx);

            int n=Math.max(1,pts.size()),slot=Math.max(42,pw/n),bar=Math.max(9,Math.min(16,(slot-16)/2));
            for(int i=0;i<pts.size();i++){
                StatWatchView v=pts.get(i); int cx=left+i*slot+slot/2, base=top+ph;
                double chosen=v.exitHand>0?v.exitBank:v.chosenBank; int y1=top+ph-(int)Math.round(chosen/max*ph);
                int chosenX=cx-bar-3, stayX=cx+3;
                g2.setColor(new Color(64,91,122)); g2.fillRoundRect(chosenX,y1,bar,Math.max(1,base-y1),5,5);
                int y2=Integer.MAX_VALUE;
                if(!Double.isNaN(v.stayBank)){
                    y2=top+ph-(int)Math.round(v.stayBank/max*ph); g2.setColor(new Color(112,124,136)); g2.setStroke(new BasicStroke(1.5f));
                    g2.drawRoundRect(stayX,y2,bar,Math.max(1,base-y2),5,5);
                }

                // v15.10.35: reserve a dedicated 104px evidence-label headroom above the plot so even the tallest STAY label remains fully inside the chart.
                                int chosenCx=chosenX+bar/2, stayCx=stayX+bar/2;
                String chosenHead=isForcedBankrollExit(v)?"BANK H"+v.exitHand:(v.exitHand>0?"WALK H"+v.exitHand:(v.chosenTermination.equals("SOURCE_EXHAUSTED")?"SRC H"+v.chosenHands:"END H"+v.chosenHands));
                String chosenVal="£"+String.format(Locale.ROOT,"%.2f",chosen);
                // v15.10.35: every evidence label remains anchored to the centre of the bar it describes.
                // The label grows vertically upward from the bar top, so it cannot drift toward a neighbour.
                String chosenLabel=chosenHead+"  "+chosenVal+(v.exitHand>0?"  "+shortExitCause(v.exitReason):"");
                drawVerticalLabelAboveBar(g2,chosenLabel,chosenCx,y1-5,new Font("SansSerif",Font.BOLD,9),new Color(45,57,68));

                if(!Double.isNaN(v.stayBank)){
                    String stayHead=(v.stayTermination.equals("SOURCE_EXHAUSTED")?"STAY SRC H"+v.stayHands:"STAY H"+v.stayHands);
                    String stayVal="£"+String.format(Locale.ROOT,"%.2f",v.stayBank);
                    drawVerticalLabelAboveBar(g2,stayHead+"  "+stayVal,stayCx,y2-5,new Font("SansSerif",Font.PLAIN,9),new Color(100,112,124));
                }

                g2.setFont(new Font("SansSerif",Font.BOLD,9)); g2.setColor(new Color(62,72,81)); g2.drawString(v.label,cx-g2.getFontMetrics().stringWidth(v.label)/2,base+18);
                if(v.preambleStart){
                    g2.setFont(new Font("SansSerif",Font.BOLD,8)); g2.setColor(new Color(76,105,131)); g2.drawString("PRE",cx-g2.getFontMetrics().stringWidth("PRE")/2,base+31);
                    // Keep preamble context attached to the session label, never floating in the title/bar-label area.
                    if(v.preambleHands>0){
                        // v15.10.36: deliberately tiny preamble metadata so all five sessions on a page can carry it without crowding.
                        g2.setFont(new Font("SansSerif",Font.PLAIN,6)); g2.setColor(new Color(137,94,18));
                        String pre=v.preambleHands+" pre-hands";
                        g2.drawString(pre,cx-g2.getFontMetrics().stringWidth(pre)/2,base+43);
                    }
                }
            }

            g2.setFont(new Font("SansSerif",Font.BOLD,9)); g2.setColor(new Color(62,72,81)); String xAxis="Session  •  PRE = observed preamble-start replay";
            g2.drawString(xAxis,left+Math.max(0,(pw-g2.getFontMetrics().stringWidth(xAxis))/2),h-31);
            g2.setFont(new Font("SansSerif",Font.PLAIN,8)); g2.setColor(new Color(92,102,110));
            String note="Bar labels = final bankroll; STAY keeps the same personality and suppresses only the walk-away decision.";
            g2.drawString(note,left+Math.max(0,(pw-g2.getFontMetrics().stringWidth(note))/2),h-13);
            g2.dispose();
        }
        static void drawCentered(Graphics2D g2,String s,int cx,int y,Font f,Color c){g2.setFont(f);g2.setColor(c);g2.drawString(s,cx-g2.getFontMetrics().stringWidth(s)/2,y);}
        static void drawVerticalLabel(Graphics2D g2,String s,int x,int y,Font f,Color c){
            AffineTransform old=g2.getTransform(); g2.rotate(-Math.PI/2,x,y); g2.setFont(f); g2.setColor(c); g2.drawString(s,x,y); g2.setTransform(old);
        }
        static void drawVerticalLabelAboveBar(Graphics2D g2,String s,int barCenterX,int barTopY,Font f,Color c){
            AffineTransform old=g2.getTransform();
            g2.setFont(f); g2.setColor(c);
            FontMetrics fm=g2.getFontMetrics();
            int safeY=Math.max(64,barTopY);
            // Translate to the exact horizontal centre of the bar, then rotate the label upward.
            // A small ascent correction keeps the glyph body centred over the bar rather than to one side.
            g2.translate(barCenterX + fm.getAscent()/2 - 1, safeY);
            g2.rotate(-Math.PI/2);
            g2.drawString(s,0,0);
            g2.setTransform(old);
        }
        static String moneySigned(double v){return (v>=0?"+":"-")+"£"+String.format(Locale.ROOT,"%.2f",Math.abs(v));}
        static String shortExitCause(String s){if(s==null||s.isBlank())return "trend trigger";String q=s.toLowerCase(Locale.ROOT);if(q.contains("drawdown"))return "drawdown/trend";if(q.contains("10-value"))return "visible trend";if(q.contains("loss"))return "loss/trend";return "trend trigger";}
    }

    private static class StatWatchPanel extends JPanel {
        final StatWatchView v; StatWatchPanel(StatWatchView v){this.v=v;setOpaque(true);setBackground(Color.WHITE);setBorder(new LineBorder(new Color(226,231,235)));setToolTipText("Solid path = behaviour actually chosen; dashed continuation = what happened if his exit was ignored.");}
        @Override protected void paintComponent(Graphics g){super.paintComponent(g);Graphics2D g2=(Graphics2D)g.create();g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int w=getWidth(),h=getHeight();int left=48,right=18,top=58,bottom=72;int pw=Math.max(20,w-left-right),ph=Math.max(30,h-top-bottom);
            java.util.List<Double> p=!v.stayPath.isEmpty()?v.stayPath:v.chosenPath;if(p.isEmpty()){g2.setColor(new Color(100,108,116));g2.drawString("No source-supported bankroll path",left,top+20);g2.dispose();return;}double min=Double.POSITIVE_INFINITY,max=Double.NEGATIVE_INFINITY;for(double z:p){min=Math.min(min,z);max=Math.max(max,z);}for(double z:v.chosenPath){min=Math.min(min,z);max=Math.max(max,z);}min=Math.floor((min-10)/10.0)*10;max=Math.ceil((max+10)/10.0)*10;if(max-min<40)max=min+40;
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));for(int k=0;k<=4;k++){double val=min+(max-min)*k/4.0;int y=top+ph-(int)Math.round((val-min)/(max-min)*ph);g2.setColor(new Color(232,236,239));g2.drawLine(left,y,left+pw,y);g2.setColor(new Color(92,102,110));g2.drawString("£"+String.format(Locale.ROOT,"%.0f",val),5,y+3);}int n=Math.max(1,p.size()-1);for(int k=0;k<=3;k++){int hand=(int)Math.round(n*k/3.0);int x=left+(int)Math.round((double)hand/n*pw);g2.setColor(new Color(92,102,110));g2.drawString("H"+hand,x-8,top+ph+16);}
            // stay-at-table path first; after a chosen exit this is the counterfactual continuation.
            if(p.size()>1){Stroke old=g2.getStroke();for(int i=1;i<p.size();i++){boolean post=v.exitHand>0&&i>v.exitHand;g2.setStroke(post?new BasicStroke(2f,BasicStroke.CAP_ROUND,BasicStroke.JOIN_ROUND,0,new float[]{6,5},0):new BasicStroke(2.2f));g2.setColor(post?new Color(125,135,145):NAVY);g2.drawLine(px(i-1,n,left,pw),py(p.get(i-1),min,max,top,ph),px(i,n,left,pw),py(p.get(i),min,max,top,ph));}g2.setStroke(old);}
            // press markers and exit marker
            for(String inf:v.influences){Matcher mm=Pattern.compile("Hand (\\d+) — WAGER: (\\d)x").matcher(inf);if(mm.find()){int hh=Integer.parseInt(mm.group(1));if(hh>=0&&hh<p.size()){int x=px(hh,n,left,pw),y=py(p.get(hh),min,max,top,ph);g2.setColor(new Color(171,117,31));g2.fillOval(x-5,y-5,10,10);g2.setFont(new Font("SansSerif",Font.BOLD,8));g2.drawString(mm.group(2)+"x",x+6,y-5);}}}
            if(v.exitHand>0&&v.exitHand<p.size()){int x=px(v.exitHand,n,left,pw),y=py(v.exitBank,min,max,top,ph);g2.setColor(new Color(177,67,61));g2.fillOval(x-6,y-6,12,12);g2.setFont(new Font("SansSerif",Font.BOLD,9));g2.drawString("EXIT H"+v.exitHand,x+8,y+3);}
            g2.setColor(NAVY);g2.setFont(new Font("SansSerif",Font.BOLD,11));String a=v.exitHand>0?"Chosen exit: H"+v.exitHand+"  £"+String.format(Locale.ROOT,"%.2f",v.exitBank):"Chosen path: "+v.chosenHands+" hands  £"+String.format(Locale.ROOT,"%.2f",v.chosenBank);g2.drawString(v.label+"  •  "+a,left,20);g2.setFont(new Font("SansSerif",Font.PLAIN,10));String b="If he stayed: "+v.stayHands+" hands  £"+String.format(Locale.ROOT,"%.2f",v.stayBank)+"  •  "+v.stayTermination+(v.staySourceComplete?" / source-complete":" / source-bounded");g2.drawString(b,left,37);g2.setColor(new Color(92,102,110));g2.drawString("Gold = evidence-triggered wager press • red = walk-away • dashed = ignored-exit continuation",left,h-18);g2.dispose();}
        static int px(int i,int n,int left,int pw){return left+(int)Math.round((double)i/Math.max(1,n)*pw);}static int py(double v,double min,double max,int top,int ph){return top+ph-(int)Math.round((v-min)/(max-min)*ph);}
    }

    private static class TenBustPoint {
        final String label;
        final double finalBankroll;
        final int busts;
        final boolean positive;
        final boolean available;          // exact 10-card-bust count available
        final boolean closingAvailable;   // closing bankroll independently supported
        final String statusLabel;
        final String statusDetail;
        final boolean hadPreamble;
        final int preambleHands;
        private TenBustPoint(String label, double finalBankroll, int busts, boolean available,
                             boolean closingAvailable, String statusLabel, String statusDetail, boolean hadPreamble, int preambleHands) {
            this.label=label; this.finalBankroll=finalBankroll; this.busts=busts;
            this.available=available; this.closingAvailable=closingAvailable;
            this.positive=closingAvailable && finalBankroll > 100.000001;
            this.statusLabel=statusLabel; this.statusDetail=statusDetail; this.hadPreamble=hadPreamble; this.preambleHands=preambleHands;
        }
        static TenBustPoint measured(String label,double finalBankroll,int busts,boolean hadPreamble,int preambleHands) {
            return new TenBustPoint(label,finalBankroll,busts,true,true,"","",hadPreamble,preambleHands);
        }
        static TenBustPoint historicalTraceMissing(String label,double finalBankroll) {
            return new TenBustPoint(label,finalBankroll,0,false,true,"TRACE N/A",
                    "Historical Casual replay exists; exact hand-card trace required for this count",false,0);
        }
        static TenBustPoint unavailable(String label,String status,boolean hadPreamble,int preambleHands) {
            return new TenBustPoint(label,0,0,false,false,status,
                    "No source-verified complete Casual reconstruction available",hadPreamble,preambleHands);
        }
    }

    private static class TenBustBarPanel extends JPanel {
        private final java.util.List<TenBustPoint> points;
        TenBustBarPanel(java.util.List<TenBustPoint> points) {
            this.points = points;
            setOpaque(true);
            setBackground(Color.WHITE);
            setBorder(new LineBorder(new Color(226, 231, 235)));
            setToolTipText("");
        }
        @Override public String getToolTipText(MouseEvent e) {
            if (points.isEmpty()) return null;
            int left=42,right=14,top=30,bottom=52;
            int pw=Math.max(10,getWidth()-left-right);
            double slot=(double)pw/points.size();
            for(int i=0;i<points.size();i++){
                int cx=left+(int)Math.round((i+0.5)*slot);
                if(Math.abs(e.getX()-cx)<=Math.max(12,slot*0.38)){
                    TenBustPoint p=points.get(i);
                    if (!p.available) return p.label + " • " + p.statusDetail + (p.closingAvailable ? " • session P/L " + signedMoney(p.finalBankroll - 100.0) : "");
                    return p.label + " • 10-card busts " + p.busts + " • session P/L " + signedMoney(p.finalBankroll - 100.0);
                }
            }
            return null;
        }
        @Override protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2=(Graphics2D)g.create();
            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth(), h=getHeight();
            int left=42,right=14,bottom=52;
            boolean anyPreamble=false; for(TenBustPoint q:points) if(q.hadPreamble){anyPreamble=true;break;}
            int top=anyPreamble?112:30;
            int pw=Math.max(10,w-left-right), ph=Math.max(10,h-top-bottom);
            int yMax=4;
            for(TenBustPoint p:points) if(p.available) yMax=Math.max(yMax,p.busts);

            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            for(int y=0;y<=yMax;y++){
                int py=top+ph-(int)Math.round((double)y/yMax*ph);
                g2.setColor(new Color(228,233,237)); g2.drawLine(left,py,left+pw,py);
                g2.setColor(new Color(90,100,108)); g2.drawString(String.valueOf(y),left-17,py+3);
            }

            if(!points.isEmpty()){
                double slot=(double)pw/points.size();
                int barW=(int)Math.max(18,Math.min(44,slot*0.55));
                for(int i=0;i<points.size();i++){
                    TenBustPoint p=points.get(i);
                    int cx=left+(int)Math.round((i+0.5)*slot);
                    if(p.hadPreamble){
                        g2.setColor(new Color(137,94,18));
                        g2.setFont(new Font("SansSerif",Font.BOLD,8));
                        String pre=p.preambleHands>0?"("+p.preambleHands+" HANDS)":"(PREAMBLE)";
                        AffineTransform oldTx=g2.getTransform();
                        g2.rotate(-Math.PI/2,cx+3,top-6);
                        g2.drawString(pre,cx+3,top-6);
                        g2.setTransform(oldTx);
                    }
                    int bh=(int)Math.round((double)p.busts/yMax*ph);
                    int by=top+ph-bh;
                    if (p.available) {
                        g2.setColor(p.positive ? new Color(34,125,91) : new Color(190,74,64));
                        g2.fillRoundRect(cx-barW/2,by,barW,Math.max(2,bh),6,6);
                    }
                    g2.setColor(new Color(48,58,66));
                    g2.setFont(new Font("SansSerif",Font.BOLD,p.available ? 9 : 8));
                    String v=p.available ? String.valueOf(p.busts) : p.statusLabel;
                    FontMetrics fm=g2.getFontMetrics();
                    g2.drawString(v,cx-fm.stringWidth(v)/2,p.available ? Math.max(top+10,by-4) : top+ph-8);
                    g2.setFont(new Font("SansSerif",Font.BOLD,9));
                    FontMetrics sf=g2.getFontMetrics();
                    g2.drawString(p.label,cx-sf.stringWidth(p.label)/2,top+ph+15);
                    String closing = p.closingAvailable ? signedMoney(p.finalBankroll - 100.0) : "N/A";
                    g2.setFont(new Font("SansSerif",Font.PLAIN,9));
                    FontMetrics cf=g2.getFontMetrics();
                    g2.drawString(closing,cx-cf.stringWidth(closing)/2,top+ph+29);
                }
            }
            g2.setColor(new Color(90,100,108));
            g2.setFont(new Font("SansSerif",Font.PLAIN,9));
            g2.drawString("Session • profit / loss", left + Math.max(0,pw/2-43), h-6);
            g2.rotate(-Math.PI/2);
            g2.drawString("10-card busts", -(top+ph/2+28), 13);
            g2.rotate(Math.PI/2);
            g2.dispose();
        }
    }


    private static String signedMoney(double value) {
        if (Math.abs(value) < 0.000001) return "£0";
        return (value > 0 ? "+£" : "−£") + money(Math.abs(value));
    }


    private JPanel openingMetricCard(String heading, String value) {
        JPanel p = new JPanel();
        p.setBackground(new Color(238, 244, 249));
        p.setBorder(new CompoundBorder(new LineBorder(new Color(220, 228, 235)), new EmptyBorder(8, 12, 8, 12)));
        p.setLayout(new BoxLayout(p, BoxLayout.Y_AXIS));
        JLabel h = new JLabel(heading, SwingConstants.CENTER);
        h.setFont(new Font("SansSerif", Font.BOLD, 10));
        h.setForeground(new Color(55, 72, 88));
        h.setAlignmentX(Component.CENTER_ALIGNMENT);
        JLabel v = new JLabel(value, SwingConstants.CENTER);
        v.setFont(new Font("SansSerif", Font.BOLD, 27));
        v.setForeground(NAVY);
        v.setAlignmentX(Component.CENTER_ALIGNMENT);
        p.add(h); p.add(Box.createVerticalStrut(2)); p.add(v);
        p.setPreferredSize(new Dimension(170, 62));
        return p;
    }

    private static int totalHandsDealt(java.util.List<Session> all) {
        int total = 0;
        for (Session s : all) total += s.hands.size();
        return total;
    }

    private static double averageDecisionsPerSession(java.util.List<Session> all) {
        if (all.isEmpty()) return 0.0;
        int decisions = 0;
        for (Session s : all) {
            for (Hand h : s.hands) {
                for (String a : h.actions) {
                    if (a.equals("HIT") || a.equals("STAND") || a.equals("DOUBLE") || a.equals("SPLIT")) decisions++;
                }
            }
        }
        return (double) decisions / all.size();
    }

    private JPanel openingHeaderRow() {
        JPanel r = new JPanel(new GridBagLayout());
        r.setBackground(new Color(232, 237, 242));
        r.setBorder(new MatteBorder(0, 0, 1, 0, new Color(218, 224, 229)));
        addOpeningCell(r, "<html>PLAYER<br>TOTAL</html>", 0, 0.32, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, "<html>DEALER<br>&nbsp;</html>", 1, 0.18, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "<html>OCCUR-<br>RENCE</html>", 2, 0.24, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "<html>AVG<br>WAGER</html>", 3, 0.26, SwingConstants.RIGHT, Font.BOLD);
        r.setMaximumSize(new Dimension(Integer.MAX_VALUE, 46));
        return r;
    }

    private JPanel openingRow(int rank, OpeningFrequency o) {
        JPanel r = new JPanel(new GridBagLayout());
        r.setBackground(Color.WHITE);
        r.setBorder(new MatteBorder(0, 0, 1, 0, new Color(226, 231, 235)));
        addOpeningCell(r, o.playerState, 0, 0.32, SwingConstants.LEFT, Font.BOLD);
        addOpeningCell(r, o.dealer, 1, 0.18, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, String.valueOf(o.count), 2, 0.24, SwingConstants.RIGHT, Font.BOLD);
        addOpeningCell(r, "£" + money(o.averageWager()), 3, 0.26, SwingConstants.RIGHT, Font.BOLD);
        r.setMaximumSize(new Dimension(Integer.MAX_VALUE, 38));
        return r;
    }

    private void addOpeningCell(JPanel row, String text, int x, double weight, int align, int style) {
        GridBagConstraints c = new GridBagConstraints();
        c.gridx = x; c.gridy = 0; c.weightx = weight; c.fill = GridBagConstraints.BOTH;
        c.insets = new Insets(0, 0, 0, 0);
        JLabel l = new JLabel(text, align);
        l.setFont(new Font("SansSerif", style, 11));
        l.setForeground(new Color(38, 48, 58));
        l.setBorder(new CompoundBorder(
                x == 0 ? BorderFactory.createEmptyBorder() : new MatteBorder(0, 1, 0, 0, new Color(235, 239, 242)),
                new EmptyBorder(5, 7, 5, 7)));
        row.add(l, c);
    }

    private static java.util.List<OpeningFrequency> openingFrequencies(java.util.List<Session> all) {
        Map<String, OpeningFrequency> map = new LinkedHashMap<>();
        for (Session s : all) {
            for (Hand h : s.hands) {
                if (h.player.size() < 2) continue;
                String playerState = openingPlayerState(h.player.get(0), h.player.get(1));
                String dealer = openingDealerValue(h.dealerUp);
                if (playerState.equals("?") || dealer.equals("?")) continue;
                String k = playerState + "|" + dealer;
                OpeningFrequency f = map.get(k);
                if (f == null) { f = new OpeningFrequency(playerState, dealer); map.put(k, f); }
                f.count++;
                f.wagerTotal += parseMoney(h.wager);
            }
        }
        java.util.List<OpeningFrequency> r = new ArrayList<>();
        for (OpeningFrequency f : map.values()) if (f.count >= 2) r.add(f);
        r.sort((x, y) -> {
            int c = Integer.compare(y.count, x.count);
            if (c != 0) return c;
            c = Double.compare(y.averageWager(), x.averageWager());
            if (c != 0) return c;
            return x.signature().compareTo(y.signature());
        });
        if (r.size() > 20) return new ArrayList<>(r.subList(0, 20));
        return r;
    }

    // Memory Lane deliberately mirrors the early Primary/Holdout player-facing view:
    // opening TOTAL versus dealer upcard. Composition, hard/soft and pair labels are not shown.
    private static String openingPlayerState(String c1, String c2) {
        String a = openingRank(c1), b = openingRank(c2);
        if (a.equals("?") || b.equals("?")) return "?";

        // Memory Lane convention: preserve two aces as the visible opening value 2,
        // rather than folding A+A into the ordinary 12 bucket. This lets "2 vs X"
        // uniquely identify a pair of aces while ordinary 12 totals remain separate.
        if (a.equals("A") && b.equals("A")) return "2";

        int v1 = openingCardValue(a), v2 = openingCardValue(b);
        if (v1 < 0 || v2 < 0) return "?";
        int total = v1 + v2;
        if ((a.equals("A") || b.equals("A")) && total > 21) total -= 10;
        return String.valueOf(total);
    }

    private static String openingDealerValue(String card) {
        String r = openingRank(card);
        if (r.equals("?")) return "?";
        if (r.equals("J") || r.equals("Q") || r.equals("K")) return "10";
        return r;
    }

    private static int openingCardValue(String r) {
        if (r.equals("A")) return 11;
        if (r.equals("10") || r.equals("J") || r.equals("Q") || r.equals("K")) return 10;
        try { return Integer.parseInt(r); } catch (Exception e) { return -1; }
    }

    private static String openingRank(String card) {
        if (card == null) return "?";
        String r = card.replace("♥", "").replace("♦", "").replace("♣", "").replace("♠", "").trim().toUpperCase(Locale.ROOT);
        if (r.startsWith("10")) return "10";
        if (r.startsWith("J")) return "J";
        if (r.startsWith("Q")) return "Q";
        if (r.startsWith("K")) return "K";
        if (r.startsWith("A")) return "A";
        Matcher m = Pattern.compile("([2-9])").matcher(r);
        return m.find() ? m.group(1) : "?";
    }

    private static class OpeningFrequency {
        final String playerState, dealer;
        int count = 0;
        double wagerTotal = 0;
        OpeningFrequency(String playerState, String dealer) { this.playerState = playerState; this.dealer = dealer; }
        double averageWager() { return count == 0 ? 0 : wagerTotal / count; }
        String signature() { return playerState + "|" + dealer; }
    }

    private void selectSession(Session s) {
        currentSession=s; updateCompareAvailability();
        timer.stop(); currentSession=s; handIndex=-1; stage=0;
        sessionLabel.setText(sessionDisplayTitle(s) + " (" + sessionArchitectureLabel(s) + ")");
        clearTable();
        handLabel.setText("HAND -- / " + s.hands.size());
        bankrollLabel.setText(s.summary.isBlank()?"Historical replay":s.summary);
        chipDisplay.setValues(0, 0);
        dealerTotalLabel.setText("TOTAL --"); playerTotalLabel.setText("TOTAL --");
        wagerLabel.setText("Press PLAY");
        actionLabel.setText("Ready — playback uses the recorded historical hand evidence."); journeyStrip.showHistory(null, -1); shoePanel.reset(); previousPanel.clear();
    }

    private void clearTable() {
        dealerCards.removeAll(); playerCards.removeAll(); splitCards.removeAll(); splitCards.setVisible(false);
        playerCards.setVisible(true); playerOutcomeLabel.setText(" ");
        dealerTotalLabel.setText("TOTAL --"); playerTotalLabel.setText("TOTAL --");
        resultLabel.setText(" "); dealerCards.revalidate(); playerCards.revalidate(); repaint();
    }

    private void nextHand() {
        if(currentSession==null) return;
        handIndex++;
        if(handIndex>=currentSession.hands.size()) {
            timer.stop(); handIndex=currentSession.hands.size()-1;
            actionLabel.setText("SESSION REPLAY COMPLETE");
            resultLabel.setText(currentSession.summary.isBlank()?"Complete":currentSession.summary);
            return;
        }
        stage=0; playerRevealIndex=0; dealerRevealIndex=0; splitARevealIndex=0; splitBRevealIndex=0; openingStreamRevealCount=0; clearTable();
        Hand h=currentSession.hands.get(handIndex);
        handLabel.setText(String.format("HAND %02d / %02d", h.number,currentSession.hands.size()));
        bankrollLabel.setText("Start £"+money(h.researchStart())+"  •  IN PLAY £"+money(h.researchStart()));
        wagerLabel.setText("Wager £"+h.wager+(h.committed.equals(h.wager)?"":"  •  committed £"+h.committed));
        chipDisplay.setValues(Math.max(0, h.researchStart() - parseMoney(h.wager)), parseMoney(h.wager));
        journeyStrip.showHistory(currentSession, handIndex);
        previousPanel.showMatches(sessions,currentSession,handIndex,h,false);
        shoePanel.setPolicyStatus(h);
        shoePanel.setStreak(currentSession, handIndex);
        if(h.shuffleBefore){shoePanel.showShuffle(h.shufflePreviousCards,h.number);actionLabel.setText("VISIBLE SHUFFLE RECORDED — shuffling before Hand "+h.number);stage=-8;} else {shoePanel.startHand(h.number);actionLabel.setText("Dealing…");}
        tick();
    }

    private void tick() {
        if(currentSession==null || handIndex<0) return;
        Hand h=currentSession.hands.get(handIndex);
        if(stage<0){shoePanel.advanceShuffle(); if(stage==-1){shoePanel.finishShuffle();actionLabel.setText("Shuffle complete — dealing from recorded shoe…");stage=0;} else {stage++;} return;}
        if(stage==0) { // player first card
            if(!h.player.isEmpty()) addCard(playerCards,h.player.get(0),false); playerRevealIndex=1; openingStreamRevealCount=1; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=1; return;
        }
        if(stage==1) { // dealer upcard + facedown hole-card placeholder
            addCard(dealerCards,h.dealerUp,false);
            if(!h.dealerHidden.isEmpty()) addCard(dealerCards,"HOLE",true);
            dealerRevealIndex=0; openingStreamRevealCount=2; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=2; return;
        }
        if(stage==2) { // player second card
            if(playerRevealIndex<h.player.size()) { addCard(playerCards,h.player.get(playerRevealIndex++),false); openingStreamRevealCount=3; refreshTotals(h); previousPanel.showMatches(sessions,currentSession,handIndex,h,false); stage=25; }
            else stage=4; return;
        }
        if(stage==25) { // complete the four-card opening stream row in physical capture order
            openingStreamRevealCount=4;
            previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
            stage=3; return;
        }
        if(stage==3) { // remaining player draws
            if(playerRevealIndex<h.player.size()) {
                String revealAction = actionForReveal(h, playerRevealIndex);
                actionLabel.setText(revealAction);
                if(revealAction.toUpperCase().contains("DOUBLE")) {
                    double committed = parseMoney(h.committed);
                    chipDisplay.setValues(Math.max(0, h.researchStart() - committed), committed);
                }
                addCard(playerCards,h.player.get(playerRevealIndex++),false); refreshTotals(h); return;
            }
            stage=4; return;
        }
        if(stage==4) {
            if(h.split) {
                // First show the original pair physically separating into two hands.
                splitARevealIndex = Math.min(1, h.splitA.size());
                splitBRevealIndex = Math.min(1, h.splitB.size());
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                double committed = parseMoney(h.committed);
                chipDisplay.setValues(Math.max(0, h.researchStart() - committed), committed);
                actionLabel.setText("PLAYER SPLITS — original pair separates into SPLIT A and SPLIT B");
                stage=40;
            } else {
                actionLabel.setText(h.actions.isEmpty()?"Recorded player action complete":String.join("  →  ",h.actions));
                stage=5;
            }
            return;
        }
        if(stage==40) {
            // Deal/reveal the recorded child cards one at a time so the split is visually played out.
            if(splitARevealIndex < h.splitA.size()) {
                String c = h.splitA.get(splitARevealIndex++);
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                actionLabel.setText("SPLIT A receives " + c);
                return;
            }
            if(splitBRevealIndex < h.splitB.size()) {
                String c = h.splitB.get(splitBRevealIndex++);
                showSplit(h, splitARevealIndex, splitBRevealIndex);
                actionLabel.setText("SPLIT B receives " + c);
                return;
            }
            actionLabel.setText("Split hands complete — dealer resolves");
            stage=5;
            return;
        }
        if(stage==5) { // reveal dealer hole card, then dealer draws
            if(dealerRevealIndex<h.dealerHidden.size()) {
                String c=h.dealerHidden.get(dealerRevealIndex);
                if(dealerRevealIndex==0 && dealerCards.getComponentCount()>=2) {
                    dealerCards.remove(1);
                    dealerCards.add(new CardPanel(c,false),1);
                    dealerCards.revalidate(); dealerCards.repaint();
                    dealerRevealIndex++; refreshTotals(h);
                    previousPanel.showMatches(sessions,currentSession,handIndex,h,true);
                    actionLabel.setText("Dealer reveals hole card");
                    // Once the player has busted, the dealer does not take any further draws.
                    // Historical replay may retain additional dealer-card text from source summaries,
                    // but those cards must not be animated as dealer hits after a player bust.
                    if (h.playerBust) stage=6;
                    return;
                }
                if (h.playerBust) { stage=6; return; }
                dealerRevealIndex++;
                addCard(dealerCards,c,false); refreshTotals(h);
                actionLabel.setText("Dealer draws"); return;
            }
            stage=6; return;
        }
        if(stage==6) {
            if(h.evidenceGap) actionLabel.setText("⚠ Evidence gap retained — unknown/ambiguous card not invented");
            if(!h.split) playerOutcomeLabel.setText(handOutcomeText(h));
            resultLabel.setText(resultText(h.result));
            bankrollLabel.setText("£"+money(h.researchStart())+"  →  £"+money(h.researchEnd()));
            chipDisplay.setValues(h.researchEnd(), 0);
            stage=7; return;
        }
        if(stage==7) nextHand();
    }

    private static String directionMarker(Session s, Hand h) {
        String arch = s==null || s.architecture==null ? "" : s.architecture.toUpperCase(Locale.ROOT);
        if (arch.contains("PERSONAL")) return "P";
        if (arch.contains("FROZEN") && !arch.contains("HYBRID")) return "F";
        if (arch.contains("HYBRID")) {
            // A Hybrid hand is marked P if either its wager or any supported card decision
            // departed from Frozen. Otherwise it is marked F.
            if (Math.abs(parseMoney(h.wager)-parseMoney(h.refWager)) >= 0.001) return "P";
            if (Boolean.FALSE.equals(h.frozenFollowed)) return "P";
            return "F";
        }
        return h.frozenFollowed==null ? "?" : (h.frozenFollowed ? "F" : "P");
    }

    private static String recentPlayerExperience(Session s, int currentIndex) {
        if (s==null || currentIndex<=0) return "RECENT PLAYER EXPERIENCE  •  no previous hands";
        int from=Math.max(0,currentIndex-5);
        StringBuilder b=new StringBuilder("RECENT PLAYER EXPERIENCE  •  ");
        for(int i=from;i<currentIndex;i++){
            Hand h=s.hands.get(i);
            if(i>from)b.append("   |   ");
            b.append("H").append(h.number).append(" ")
             .append(friendlyOutcome(h.result)).append(" (£").append(money(parseMoney(h.wager))).append(") (")
             .append(directionMarker(s,h)).append(")");
        }
        return b.toString();
    }

    private static String playerCardsTakenLabel(Hand h) {
        if (h==null || h.split || !"WIN".equals(friendlyOutcome(h.result))) return "—";
        int n = h.player==null ? 0 : h.player.size();
        return n==3 ? "3" : (n>=4 ? "4+" : "—");
    }

    private static class RecentExperiencePanel extends JPanel {
        RecentExperiencePanel(){
            setOpaque(true); setBackground(new Color(238,245,240));
            setBorder(new CompoundBorder(new LineBorder(new Color(150,185,165)),new EmptyBorder(3,7,3,7)));
            setLayout(new FlowLayout(FlowLayout.CENTER,14,1));
            showHistory(null,-1);
        }
        void showHistory(Session s,int currentIndex){
            removeAll();
            JLabel head=new JLabel("<html><b>RECENT PLAYER EXPERIENCE</b><br><br><br><span style='font-size:8px'>NUMBER PLAYER CARDS ON WIN</span></html>"); head.setFont(new Font("SansSerif",Font.BOLD,10)); head.setForeground(NAVY); add(head);
            if(s==null||currentIndex<=0){JLabel n=new JLabel("no previous hands");n.setFont(new Font("SansSerif",Font.PLAIN,10));add(n);revalidate();repaint();return;}
            int from=Math.max(0,currentIndex-5);
            for(int i=from;i<currentIndex;i++){
                Hand h=s.hands.get(i); String out=friendlyOutcome(h.result);
                String shortOut=out.equals("WIN")?"W":out.equals("LOSS")?"L":"P";
                JPanel cell=new JPanel();cell.setOpaque(false);cell.setLayout(new BoxLayout(cell,BoxLayout.Y_AXIS));
                JLabel top=new JLabel(shortOut,SwingConstants.CENTER);
                top.setForeground(shortOut.equals("W")?new Color(25,125,75):shortOut.equals("L")?new Color(190,55,50):new Color(85,95,105));
                top.setFont(new Font("SansSerif",Font.BOLD,17));top.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel wager=new JLabel("£"+money(parseMoney(h.wager)),SwingConstants.CENTER);
                wager.setForeground(new Color(55,68,78));wager.setFont(new Font("SansSerif",Font.BOLD,10));wager.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel dir=new JLabel("("+directionMarker(s,h)+")",SwingConstants.CENTER);
                dir.setForeground(new Color(115,125,132));dir.setFont(new Font("SansSerif",Font.PLAIN,8));dir.setAlignmentX(Component.CENTER_ALIGNMENT);
                JLabel cardsTaken=new JLabel(playerCardsTakenLabel(h),SwingConstants.CENTER);
                cardsTaken.setForeground(new Color(30,105,170));cardsTaken.setFont(new Font("SansSerif",Font.BOLD,10));cardsTaken.setAlignmentX(Component.CENTER_ALIGNMENT);
                cardsTaken.setToolTipText("Number of player cards on a winning hand: 3, 4+, or — otherwise");
                cell.add(top);cell.add(wager);cell.add(dir);cell.add(cardsTaken);add(cell);
            }
            revalidate();repaint();
        }
    }

    private String actionForReveal(Hand h,int index) {
        int ai=Math.max(0,index-2);
        if(ai<h.actions.size()) return "PLAYER: "+h.actions.get(ai);
        return "PLAYER DRAW";
    }

    private void showSplit(Hand h, int aCount, int bCount) {
        playerCards.setVisible(false); playerOutcomeLabel.setText(" "); splitCards.removeAll();
        List<String> a = h.splitA.subList(0, Math.min(aCount, h.splitA.size()));
        List<String> b = h.splitB.subList(0, Math.min(bCount, h.splitB.size()));
        splitCards.add(splitPanel("SPLIT A • TOTAL " + totalText(a), a, h.splitAResult));
        splitCards.add(splitPanel("SPLIT B • TOTAL " + totalText(b), b, h.splitBResult));
        playerTotalLabel.setText("SPLIT HANDS");
        splitCards.setVisible(true); splitCards.revalidate(); splitCards.repaint();
    }

    private JPanel splitPanel(String title,List<String> cards,String outcome) {
        JPanel box=new JPanel(new BorderLayout()); box.setOpaque(false);
        JLabel t=new JLabel(title,SwingConstants.CENTER); t.setForeground(Color.WHITE); t.setFont(new Font("SansSerif",Font.BOLD,15)); box.add(t,BorderLayout.NORTH);
        JPanel cp=new JPanel(new FlowLayout(FlowLayout.CENTER,7,5)); cp.setOpaque(false); for(String c:cards) cp.add(new CardPanel(c,false)); box.add(cp,BorderLayout.CENTER);
        JLabel out=new JLabel(friendlyOutcome(outcome),SwingConstants.CENTER); out.setForeground(new Color(255,246,196)); out.setFont(new Font("SansSerif",Font.BOLD,21)); box.add(out,BorderLayout.SOUTH);
        return box;
    }

    private String handOutcomeText(Hand h) {
        if(h.playerBust) return "BUST — LOSS";
        if(h.natural && h.result.toUpperCase().startsWith("W")) return "BLACKJACK — WIN";
        if(h.dealerBust && h.result.toUpperCase().startsWith("W")) return "DEALER BUST — WIN";
        return friendlyOutcome(h.result);
    }

    private static String friendlyOutcome(String r) {
        if(r==null) return "";
        String u=r.trim().toUpperCase();
        if(u.equals("W") || u.startsWith("WIN")) return "WIN";
        if(u.equals("L") || u.startsWith("LOSS")) return "LOSS";
        if(u.equals("P") || u.startsWith("PUSH")) return "PUSH";
        if(u.contains("BUST")) return "BUST";
        return r.trim();
    }

    private void addCard(JPanel panel,String card,boolean hidden) { panel.add(new CardPanel(card,hidden)); panel.revalidate(); panel.repaint(); }
    private String resultText(String r) {
        if(r.startsWith("W")) return "✓  WIN   •   " + r;
        if(r.startsWith("L")) return "✕  LOSS   •   " + r;
        if(r.startsWith("P")) return "—  PUSH   •   " + r;
        return r;
    }

    private void refreshTotals(Hand h) {
        List<String> pv = h.split ? Collections.emptyList() : h.player.subList(0, Math.min(playerRevealIndex, h.player.size()));
        if(!h.split) playerTotalLabel.setText("TOTAL " + totalText(pv));
        List<String> dv = new ArrayList<>();
        if(h.dealerUp!=null && !h.dealerUp.equals("?")) dv.add(h.dealerUp);
        for(int i=0;i<Math.min(dealerRevealIndex,h.dealerHidden.size());i++) dv.add(h.dealerHidden.get(i));
        String dt = totalText(dv);
        if(dealerRevealIndex==0 && !h.dealerHidden.isEmpty()) dt += " + ?";
        dealerTotalLabel.setText("TOTAL " + dt);
    }

    private static String totalText(List<String> cards) {
        if(cards==null || cards.isEmpty()) return "--";
        int total=0, aces=0; boolean unknown=false;
        for(String c:cards){
            if(c==null || c.contains("?") || c.toUpperCase().contains("UNKNOWN")){unknown=true;continue;}
            String r=c.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim().toUpperCase();
            if(r.startsWith("A")){total+=11;aces++;}
            else if(r.startsWith("K")||r.startsWith("Q")||r.startsWith("J")||r.startsWith("10")) total+=10;
            else { Matcher m=Pattern.compile("([2-9])").matcher(r); if(m.find()) total+=Integer.parseInt(m.group(1)); else unknown=true; }
        }
        while(total>21 && aces>0){total-=10;aces--;}
        return unknown ? (total>0?total+" + ?":"?") : String.valueOf(total);
    }

    private static double parseMoney(String s){
        if(s==null) return 0; try{return Double.parseDouble(s.replace("£","").replace(",","").trim());}catch(Exception e){return 0;}
    }
    private static String money(double v){
        if(Math.abs(v-Math.rint(v))<0.0001) return String.format("%.0f",v);
        return String.format("%.2f",v);
    }

    private static class ChipDisplayPanel extends JComponent {
        double bankroll=0, bet=0;
        void setValues(double bankroll,double bet){this.bankroll=Math.max(0,bankroll);this.bet=Math.max(0,bet);repaint();}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            // Responsive chip-group positions keep the bankroll stack fully inside narrower table widths.
            int leftCentre = (int)(getWidth() * 0.20);
            int rightCentre = (int)(getWidth() * 0.72);
            drawGroup(g, leftCentre, 24, "BANKROLL", bankroll);
            drawGroup(g, rightCentre, 24, "ON TABLE", bet);
            g.dispose();
        }
        private void drawGroup(Graphics2D g,int cx,int cy,String label,double amount){
            g.setFont(new Font("SansSerif",Font.BOLD,12)); g.setColor(new Color(238,246,240));
            String text=label+"  £"+money(amount); FontMetrics fm=g.getFontMetrics(); g.drawString(text,cx-fm.stringWidth(text)/2,14);
            double[] den={25,10,5,2.5}; Color[] col={new Color(40,45,50),new Color(46,110,190),new Color(205,55,55),new Color(235,235,235)};
            double rem=amount; int x=cx-108;
            for(int i=0;i<den.length;i++){
                int count=(int)Math.floor((rem+0.0001)/den[i]); rem-=count*den[i];
                if(count==0){x+=68;continue;}
                int shown=Math.min(count,5); int baseY=cy+49;
                for(int j=0;j<shown;j++) drawChip(g,x,baseY-j*8,52,col[i],den[i]);
                if(count>4){g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,11));g.drawString("×"+count,x+31,baseY-39);}
                x+=68;
            }
        }
        private void drawChip(Graphics2D g,int x,int y,int d,Color c,double value){
            g.setColor(c); g.fillOval(x,y-d,d,d); g.setColor(new Color(250,250,250));g.setStroke(new BasicStroke(2));g.drawOval(x,y-d,d,d);
            g.setStroke(new BasicStroke(1)); for(int a=0;a<360;a+=60){double r=Math.toRadians(a);int x1=(int)(x+d/2+Math.cos(r)*(d/2-4));int y1=(int)(y-d/2+Math.sin(r)*(d/2-4));int x2=(int)(x+d/2+Math.cos(r)*(d/2));int y2=(int)(y-d/2+Math.sin(r)*(d/2));g.drawLine(x1,y1,x2,y2);}
            g.setColor(c.getRed()+c.getGreen()+c.getBlue()>560?new Color(25,25,25):Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,12));String t=value==2.5?"2.5":String.valueOf((int)value);FontMetrics fm=g.getFontMetrics();g.drawString(t,x+d/2-fm.stringWidth(t)/2,y-d/2+3);
        }
    }

    private static class ShoePanel extends JPanel {
        boolean shuffling=false; int hand=0, previousCards=0, shuffleFrame=0;
        String wagerPolicy="FROZEN POLICY — WAGER";
        String handPolicy="FROZEN POLICY — HAND";
        String streakStatus="CURRENT STREAK — NONE";
        ShoePanel(){setOpaque(false);setBorder(new EmptyBorder(18,8,8,8));}
        void reset(){shuffling=false;hand=0;previousCards=0;shuffleFrame=0;wagerPolicy="FROZEN POLICY — WAGER";handPolicy="FROZEN POLICY — HAND";streakStatus="CURRENT STREAK — NONE";repaint();}
        void setPolicyStatus(Hand h){
            double ref=parseMoney(h.refWager), actual=parseMoney(h.wager);
            boolean wagerKnown=!h.refWager.equals("?")&&!h.wager.equals("?");
            wagerPolicy=(wagerKnown && Math.abs(ref-actual)>0.001)?"OVERRIDDEN POLICY — WAGER":"FROZEN POLICY — WAGER";
            handPolicy=Boolean.FALSE.equals(h.frozenFollowed)?"OVERRIDDEN POLICY — HAND":"FROZEN POLICY — HAND";
            repaint();
        }
        void setStreak(Session s,int currentIndex){
            int count=0; String kind="";
            for(int i=currentIndex-1;i>=0;i--){
                String r=friendlyOutcome(s.hands.get(i).result);
                if(!r.equals("WIN")&&!r.equals("LOSS")) break; // a push breaks the W/L streak
                if(kind.isEmpty()) kind=r;
                if(!r.equals(kind)) break;
                count++;
            }
            streakStatus=count==0?"CURRENT STREAK — NONE":"CURRENT STREAK — "+count+" "+(kind.equals("WIN")?(count==1?"WIN":"WINS"):(count==1?"LOSS":"LOSSES"));
            repaint();
        }
        void startHand(int h){hand=h;shuffling=false;shuffleFrame=0;repaint();}
        void showShuffle(int p,int h){previousCards=p;hand=h;shuffling=true;shuffleFrame=0;repaint();}
        void advanceShuffle(){if(shuffling){shuffleFrame++;repaint();}}
        void finishShuffle(){shuffling=false;shuffleFrame=0;repaint();}
        protected void paintComponent(Graphics q){super.paintComponent(q);Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);int w=getWidth();
            g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,17));center(g,"RECORDED SHOE",w/2,24);g.setColor(new Color(205,230,220));g.setFont(new Font("SansSerif",0,12));center(g,"Historical replay",w/2,44);
            int x=22,y=90,sw=Math.max(145,w-44);g.setColor(new Color(25,31,36));g.fillRoundRect(x,y,sw,255,22,22);g.setColor(new Color(80,90,95));g.setStroke(new BasicStroke(3));g.drawRoundRect(x,y,sw,255,22,22);g.setColor(new Color(215,225,228));g.fillRoundRect(x+20,y+25,sw-40,165,15,15);
            for(int i=0;i<14;i++){int yy=y+42+i*7;g.setColor(new Color(24,62,105));g.fillRoundRect(x+40,yy,sw-80,68,8,8);g.setColor(Color.WHITE);g.drawRoundRect(x+40,yy,sw-80,68,8,8);}g.setColor(TABLE);g.fillRoundRect(x+18,y+205,sw-36,34,10,10);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,12));center(g,"6-DECK SOURCE",w/2,y+227);
            if(shuffling){g.setColor(GOLD);g.setFont(new Font("SansSerif",Font.BOLD,18));center(g,"SHUFFLE OBSERVED",w/2,y+300);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",0,12));center(g,"before Hand "+hand,w/2,y+321);if(previousCards>0)center(g,previousCards+" cards in prior segment",w/2,y+340);int center=w/2; int spread=Math.min(62, 10 + shuffleFrame*9);
                for(int i=0;i<8;i++){
                    boolean left=(i%2==0); int lane=i/2;
                    int cx=center-27 + (left?-spread:spread) + (left?lane*8:-lane*8);
                    int wave=(int)Math.round(Math.sin((shuffleFrame+i)*0.9)*10);
                    int cy=y+365 + wave + lane*4;
                    g.setColor(new Color(24,62,105));g.fillRoundRect(cx,cy,54,76,7,7);
                    g.setColor(Color.WHITE);g.drawRoundRect(cx,cy,54,76,7,7);
                    for(int d=0;d<3;d++) g.drawOval(cx+10+d*12,cy+12,4,4);
                }
                g.setColor(new Color(205,230,220));g.setFont(new Font("SansSerif",Font.BOLD,11));center(g,shuffleFrame<4?"PACKETS SEPARATING":"PACKETS INTERLEAVING",w/2,y+462);}
            else {g.setColor(new Color(220,235,228));g.setFont(new Font("SansSerif",Font.BOLD,14));center(g,hand>0?"DEALING • HAND "+hand:"WAITING",w/2,y+300);g.setFont(new Font("SansSerif",0,10));g.setColor(new Color(190,215,205));center(g,"Only recorded shuffles are shown",w/2,y+324);}
            int sy=y+390;
            g.setFont(new Font("SansSerif",Font.BOLD,11));
            g.setColor(wagerPolicy.startsWith("OVERRIDDEN")?new Color(238,198,112):new Color(220,235,228));center(g,wagerPolicy,w/2,sy);
            g.setColor(handPolicy.startsWith("OVERRIDDEN")?new Color(238,198,112):new Color(220,235,228));center(g,handPolicy,w/2,sy+24);
            g.setColor(new Color(220,235,228));center(g,streakStatus,w/2,sy+48);
            g.dispose();}
        void center(Graphics2D g,String s,int x,int y){FontMetrics f=g.getFontMetrics();g.drawString(s,x-f.stringWidth(s)/2,y);}
    }
    private class PreviousPanel extends JPanel {
        PreviousPanel(){setLayout(new BoxLayout(this,BoxLayout.Y_AXIS));setBackground(new Color(241,245,247));setBorder(new CompoundBorder(new MatteBorder(0,1,0,0,new Color(205,214,218)),new EmptyBorder(14,12,12,12)));clear();}
        JLabel lab(String t,int z,int st,Color c){JLabel l=new JLabel(t);l.setFont(new Font("SansSerif",st,z));l.setForeground(c);l.setAlignmentX(Component.LEFT_ALIGNMENT);return l;}
        void clear(){removeAll();add(lab("PREVIOUS OBSERVATIONS",16,Font.BOLD,NAVY));add(Box.createVerticalStrut(8));add(lab("Comparable starting hands will appear here.",11,0,new Color(80,90,98)));revalidate();repaint();}
        void showMatches(List<Session> all,Session cur,int hi,Hand now,boolean showCurrent){removeAll();
            JPanel headRow=new JPanel(new BorderLayout()); headRow.setOpaque(false); headRow.setMaximumSize(new Dimension(Integer.MAX_VALUE,58));
            JPanel key=new JPanel(); key.setOpaque(false); key.setLayout(new BoxLayout(key,BoxLayout.Y_AXIS));
            key.add(lab("↑  HIGH",9,Font.BOLD,new Color(25,125,75)));
            key.add(lab("—  NEUTRAL",9,Font.BOLD,new Color(70,95,120)));
            key.add(lab("↓  LOW",9,Font.BOLD,new Color(190,55,50)));
            JPanel heading=new JPanel(); heading.setOpaque(false); heading.setLayout(new BoxLayout(heading,BoxLayout.Y_AXIS));
            JLabel h1=lab("SAME STARTING STATE",16,Font.BOLD,NAVY); h1.setAlignmentX(Component.CENTER_ALIGNMENT);
            JLabel h2=lab("CURRENT + PREVIOUS OBSERVATIONS",11,Font.BOLD,new Color(80,90,98)); h2.setAlignmentX(Component.CENTER_ALIGNMENT);
            heading.add(h1); heading.add(h2);
            JPanel spacer=new JPanel(); spacer.setOpaque(false); spacer.setPreferredSize(new Dimension(82,1));
            headRow.add(key,BorderLayout.WEST); headRow.add(heading,BorderLayout.CENTER); headRow.add(spacer,BorderLayout.EAST);
            add(headRow); add(Box.createVerticalStrut(4));
            // v15.10.9: the current hand is visible during the opening deal so the compact
            // four-card physical stream can appear one card at a time. Historical matching
            // remains suppressed until both player cards and the dealer upcard are visible.
            add(currentRow(cur,hi,now,showCurrent));add(Box.createVerticalStrut(3));
            if(!showCurrent){
                add(lab("Previous observations unlock when the starting state is complete.",10,Font.BOLD,new Color(85,95,102)));
                revalidate();repaint();return;
            }
            List<Occurrence> ms=new ArrayList<>();int ci=all.indexOf(cur);for(int si=0;si<=ci;si++){Session s=all.get(si);for(int j=0;j<s.hands.size();j++){if(si==ci&&j>=hi)break;Hand h=s.hands.get(j);if(sameState(now,h))ms.add(new Occurrence(displaySessionLabel(s),s,j,h));}}
            if(ms.isEmpty()){add(lab("No previous occurrence recorded.",10,Font.BOLD,new Color(85,95,102)));}else for(int i=ms.size()-1;i>=Math.max(0,ms.size()-3);i--){add(row(ms.get(i)));add(Box.createVerticalStrut(3));}
            add(Box.createVerticalStrut(3));add(new JSeparator());add(Box.createVerticalStrut(3));int w=0,l=0,p=0;for(Occurrence o:ms){String r=friendlyOutcome(o.h.result);if(r.equals("WIN"))w++;else if(r.equals("LOSS"))l++;else if(r.equals("PUSH"))p++;}int n=w+l+p;add(lab("ALL PREVIOUS OCCURRENCES  •  TOTAL "+n,10,Font.BOLD,NAVY));if(n>0){add(lab(String.format("Won %d  •  %.1f%%",w,100.0*w/n),10,Font.BOLD,new Color(32,115,72)));add(lab(String.format("Lost %d  •  %.1f%%",l,100.0*l/n),10,Font.BOLD,new Color(155,55,55)));add(lab(String.format("Push %d  •  %.1f%%",p,100.0*p/n),10,Font.BOLD,new Color(85,95,105)));}revalidate();repaint();}
        JPanel currentRow(Session s,int handIndex,Hand h,boolean completeState){JPanel b=new JPanel(new BorderLayout(3,1));b.setBackground(new Color(248,252,253));b.setBorder(new CompoundBorder(new LineBorder(new Color(120,155,170)),new EmptyBorder(3,5,3,5)));
            JPanel top=new JPanel(new BorderLayout());top.setOpaque(false);
            top.add(lab("CURRENT • "+displaySessionLabel(s)+" • HAND "+h.number,9,Font.BOLD,NAVY),BorderLayout.WEST);
            JLabel currentStake=lab("£"+h.wager,15,Font.BOLD,NAVY);
            currentStake.setHorizontalAlignment(SwingConstants.CENTER);
            currentStake.setBorder(new EmptyBorder(0,18,0,0));
            top.add(currentStake,BorderLayout.CENTER);
            JPanel topSpacer=new JPanel();topSpacer.setOpaque(false);topSpacer.setPreferredSize(new Dimension(120,1));top.add(topSpacer,BorderLayout.EAST);
            b.add(top,BorderLayout.NORTH);
            JPanel middle=new JPanel(new BorderLayout(6,0));middle.setOpaque(false);
            JPanel c=new JPanel(new FlowLayout(FlowLayout.LEFT,3,0));c.setOpaque(false);
            int pc=Math.min(playerRevealIndex,Math.min(2,h.player.size()));for(int i=0;i<pc;i++)c.add(new MiniCard(h.player.get(i)));
            boolean dealerUpVisible = stage>=2 || completeState || dealerRevealIndex>0;
            if(dealerUpVisible){c.add(new JLabel(" vs "));c.add(new MiniCard(h.dealerUp));}
            middle.add(c,BorderLayout.WEST);
            JPanel streamWrap=new JPanel(new FlowLayout(FlowLayout.LEFT,0,0));streamWrap.setOpaque(false);streamWrap.setPreferredSize(new Dimension(120,42));
            JPanel stream=openingStreamRow(h);stream.setOpaque(false);streamWrap.add(stream);middle.add(streamWrap,BorderLayout.EAST);
            b.add(middle,BorderLayout.CENTER);
            JPanel pcards=new JPanel(new FlowLayout(FlowLayout.LEFT,2,0));pcards.setOpaque(false);List<String> prior=priorCards(s,handIndex,5);pcards.add(lab("5 CARDS BEFORE",7,Font.BOLD,new Color(90,98,105)));if(prior.isEmpty())pcards.add(lab("none recorded",8,Font.PLAIN,new Color(110,115,120)));else for(String x:prior)pcards.add(new TinyCard(x));b.add(pcards,BorderLayout.SOUTH);b.setMaximumSize(new Dimension(Integer.MAX_VALUE,126));return b;}
        JPanel openingStreamRow(Hand h){JPanel p=new JPanel(new FlowLayout(FlowLayout.RIGHT,3,0));p.setOpaque(false);List<String> opening=new ArrayList<>();if(!h.player.isEmpty())opening.add(h.player.get(0));if(!h.dealerHidden.isEmpty())opening.add(h.dealerHidden.get(0));if(h.player.size()>1)opening.add(h.player.get(1));if(h.dealerUp!=null&&!h.dealerUp.isBlank()&&!h.dealerUp.equals("?"))opening.add(h.dealerUp);int n=Math.min(openingStreamRevealCount,opening.size());for(int i=0;i<n;i++){boolean hidden=(i==1 && dealerRevealIndex==0);p.add(new StreamCard(opening.get(i),hidden));}return p;}
        JPanel row(Occurrence o){JPanel b=new JPanel(new BorderLayout(3,1));b.setBackground(Color.WHITE);b.setBorder(new CompoundBorder(new LineBorder(new Color(210,218,222)),new EmptyBorder(3,5,3,5)));
            JPanel rowHead=new JPanel(new BorderLayout(8,0));rowHead.setOpaque(false);
            rowHead.add(lab(o.sessionLabel+" • HAND "+o.h.number,10,Font.BOLD,NAVY),BorderLayout.WEST);
            JPanel holeHead=new JPanel(new FlowLayout(FlowLayout.LEFT,29,0));holeHead.setOpaque(false);holeHead.setPreferredSize(new Dimension(120,12));
            holeHead.add(lab("HOLE CARD",8,Font.BOLD,NAVY));rowHead.add(holeHead,BorderLayout.EAST);
            b.add(rowHead,BorderLayout.NORTH);
            JPanel middle=new JPanel(new BorderLayout(8,0));middle.setOpaque(false);
            JPanel c=new JPanel(new FlowLayout(FlowLayout.LEFT,3,0));c.setOpaque(false);for(String x:startCards(o.h))c.add(new MiniCard(x));c.add(new JLabel(" vs "));c.add(new MiniCard(o.h.dealerUp));middle.add(c,BorderLayout.WEST);
            JPanel resultBox=new JPanel();resultBox.setOpaque(false);resultBox.setLayout(new BoxLayout(resultBox,BoxLayout.Y_AXIS));
            String a=o.h.actions.isEmpty()?"—":o.h.actions.get(0);String outcome=friendlyOutcome(o.h.result);String outcomeColor=outcome.equals("WIN")?"#207348":outcome.equals("LOSS")?"#b93737":"#596773";
            JLabel result=new JLabel("<html><b>£"+o.h.wager+" • "+a+" • <font color='"+outcomeColor+"'>"+outcome+"</font></b></html>");result.setFont(new Font("SansSerif",Font.BOLD,13));result.setForeground(NAVY);result.setAlignmentX(Component.LEFT_ALIGNMENT);resultBox.add(result);
            boolean wagerKnown=!o.h.refWager.equals("?")&&!o.h.wager.equals("?");
            boolean wagerFollowed=wagerKnown && Math.abs(parseMoney(o.h.refWager)-parseMoney(o.h.wager))<0.001;
            boolean cardFollowed=Boolean.TRUE.equals(o.h.frozenFollowed);
            String st; Color sc;
            if(cardFollowed && wagerFollowed){st="FROZEN FOLLOWED • CARD + WAGER";sc=new Color(32,115,72);}
            else if(cardFollowed){st="FROZEN FOLLOWED • CARD ONLY";sc=new Color(32,115,72);}
            else if(wagerFollowed){st="FROZEN FOLLOWED • WAGER ONLY";sc=new Color(32,115,72);}
            else if(o.h.frozenFollowed==null && !wagerKnown){st="FROZEN FOLLOWED • NOT DETERMINABLE";sc=Color.GRAY;}
            else {st="FROZEN NOT FOLLOWED";sc=new Color(175,105,30);}
            JLabel stl=lab(st,9,Font.BOLD,sc);stl.setAlignmentX(Component.LEFT_ALIGNMENT);resultBox.add(stl);middle.add(resultBox,BorderLayout.CENTER);
            JPanel hole=new JPanel(new FlowLayout(FlowLayout.LEFT,29,0));hole.setOpaque(false);hole.setPreferredSize(new Dimension(120,52));
            if(!o.h.dealerHidden.isEmpty())hole.add(new MiniCard(o.h.dealerHidden.get(0)));else hole.add(lab("not recorded",7,Font.PLAIN,new Color(110,115,120)));
            middle.add(hole,BorderLayout.EAST);
            b.add(middle,BorderLayout.CENTER);
            List<String> prior=priorCards(o.session,o.handIndex,5);JPanel pc=new JPanel(new FlowLayout(FlowLayout.LEFT,2,0));pc.setOpaque(false);pc.add(lab("5 CARDS BEFORE",7,Font.BOLD,new Color(90,98,105)));if(prior.isEmpty())pc.add(lab("none recorded",8,Font.PLAIN,new Color(110,115,120)));else for(String x:prior)pc.add(new TinyCard(x));b.add(pc,BorderLayout.SOUTH);b.setMaximumSize(new Dimension(Integer.MAX_VALUE,132));return b;}
    }
    private static class Occurrence{String sessionLabel;Session session;int handIndex;Hand h;Occurrence(String sessionLabel,Session session,int handIndex,Hand h){this.sessionLabel=sessionLabel;this.session=session;this.handIndex=handIndex;this.h=h;}}
    private static List<String> priorCards(Session s,int handIndex,int count){List<String> seq=new ArrayList<>();for(int i=0;i<handIndex;i++)seq.addAll(recordedCardSequence(s.hands.get(i)));int from=Math.max(0,seq.size()-count);return new ArrayList<>(seq.subList(from,seq.size()));}
    private static List<String> recordedCardSequence(Hand h){List<String> r=new ArrayList<>();if(h.player.size()>0)r.add(h.player.get(0));if(h.dealerHidden.size()>0)r.add(h.dealerHidden.get(0));if(h.player.size()>1)r.add(h.player.get(1));if(h.dealerUp!=null&&!h.dealerUp.trim().isEmpty()&&!h.dealerUp.equals("?"))r.add(h.dealerUp);if(h.split){if(h.splitA.size()>1)r.addAll(h.splitA.subList(1,h.splitA.size()));if(h.splitB.size()>1)r.addAll(h.splitB.subList(1,h.splitB.size()));}else if(h.player.size()>2)r.addAll(h.player.subList(2,h.player.size()));if(h.dealerHidden.size()>1)r.addAll(h.dealerHidden.subList(1,h.dealerHidden.size()));return r;}
    private static String publicationSessionLabel(String id){
        if(id==null)return "SESSION";
        if(id.contains("20260903_004704"))return "SESSION 1";
        if(id.contains("20260903_221223"))return "SESSION 2";
        if(id.contains("20260905_174453"))return "SESSION 3";
        if(id.contains("20260903_135610"))return "INTERIM SESSION";
        return "SESSION "+id;
    }
    private static String displaySessionLabel(Session s){
        if(s==null)return "SESSION";
        String legacy=publicationSessionLabel(s.id);
        if(s.id!=null && (s.id.contains("20260903_004704") || s.id.contains("20260903_221223") || s.id.contains("20260905_174453"))) return legacy;
        if("INTERIM SESSION".equals(legacy)) return legacy;
        if(s.publicationNumber>0) return "SESSION "+s.publicationNumber;
        return legacy;
    }
    private static String sessionDisplayTitle(Session s){
        String base=displaySessionLabel(s);
        if(s!=null && s.sessionName!=null && !s.sessionName.isBlank()) return base+" - "+s.sessionName;
        return base;
    }
    private static String sessionArchitectureLabel(Session s){
        if(s!=null && s.architecture!=null && !s.architecture.isBlank()) return s.architecture.toUpperCase();
        return "FROZEN";
    }
    private static List<String> startCards(Hand h){return h.player.subList(0,Math.min(2,h.player.size()));}
    private static boolean sameState(Hand a,Hand b){List<String>x=new ArrayList<>(),y=new ArrayList<>();for(String c:startCards(a))x.add(key(c));for(String c:startCards(b))y.add(key(c));if(x.size()!=2||y.size()!=2)return false;Collections.sort(x);Collections.sort(y);return x.equals(y)&&key(a.dealerUp).equals(key(b.dealerUp));}
    private static String key(String c){if(c==null)return"?";String r=c.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim().toUpperCase();if(r.startsWith("10")||r.startsWith("J")||r.startsWith("Q")||r.startsWith("K"))return"10";if(r.startsWith("A"))return"A";Matcher m=Pattern.compile("([2-9])").matcher(r);return m.find()?m.group(1):"?";}
    private static String cardBucketMarker(String raw){
        if(raw==null)return ""; String r=openingRank(raw);
        if(r.matches("2|3|4|5|6"))return "↓";
        if(r.matches("7|8|9"))return "–";
        if(r.matches("10|J|Q|K|A"))return "↑";
        return "";
    }
    private static Color cardBucketColor(String marker){
        if("↓".equals(marker))return new Color(205,55,55);
        if("↑".equals(marker))return new Color(45,145,65);
        return new Color(70,95,125);
    }
    private static void drawBucketMarker(Graphics2D g,String raw,int x,int y,int fontSize){
        String m=cardBucketMarker(raw); if(m.isEmpty())return;
        g.setColor(cardBucketColor(m));g.setFont(new Font("SansSerif",Font.BOLD,fontSize));
        FontMetrics fm=g.getFontMetrics();g.drawString(m,x-fm.stringWidth(m),y);
    }

    private static class MiniCard extends JComponent{String r;MiniCard(String r){this.r=r;setPreferredSize(new Dimension(36,50));}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setColor(Color.WHITE);g.fillRoundRect(1,1,33,47,5,5);g.setColor(Color.GRAY);g.drawRoundRect(1,1,33,47,5,5);String s="";for(String z:new String[]{"♥","♦","♣","♠"})if(r.contains(z)){s=z;break;}String a=r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();g.setColor((s.equals("♥")||s.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,12));g.drawString(a,4,15);g.setFont(new Font("Serif",0,16));g.drawString(s.isEmpty()?"?":s,4,34);drawBucketMarker(g,r,31,13,12);g.dispose();}}
    private static class StreamCard extends JComponent{String r;boolean back;StreamCard(String r,boolean back){this.r=r;this.back=back;setPreferredSize(new Dimension(26,38));setMinimumSize(getPreferredSize());}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);if(back){g.setColor(new Color(24,62,105));g.fillRoundRect(1,1,23,35,5,5);g.setColor(Color.WHITE);g.drawRoundRect(1,1,23,35,5,5);for(int y=7;y<31;y+=6)for(int x=5;x<21;x+=6)g.drawOval(x,y,2,2);g.dispose();return;}g.setColor(Color.WHITE);g.fillRoundRect(1,1,23,35,5,5);g.setColor(new Color(120,125,130));g.drawRoundRect(1,1,23,35,5,5);String suit="";for(String z:new String[]{"♥","♦","♣","♠"})if(r!=null&&r.contains(z)){suit=z;break;}String a=r==null?"?":r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();g.setColor((suit.equals("♥")||suit.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,9));g.drawString(a,3,12);g.setFont(new Font("Serif",Font.PLAIN,12));g.drawString(suit.isEmpty()?"?":suit,3,27);drawBucketMarker(g,r,22,11,9);g.dispose();}}
    private static class TinyCard extends JComponent{String r;TinyCard(String r){this.r=r;setPreferredSize(new Dimension(30,42));setMinimumSize(getPreferredSize());}protected void paintComponent(Graphics q){Graphics2D g=(Graphics2D)q.create();g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);g.setColor(Color.WHITE);g.fillRoundRect(1,1,27,39,5,5);g.setColor(new Color(120,125,130));g.drawRoundRect(1,1,27,39,5,5);String s="";for(String z:new String[]{"♥","♦","♣","♠"})if(r!=null&&r.contains(z)){s=z;break;}String a=r==null?"?":r.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();if(a.contains("/"))a=a.substring(0,a.indexOf('/'))+"?";if(a.contains("UNKNOWN")||a.equals("+"))a="?";g.setColor((s.equals("♥")||s.equals("♦"))?new Color(180,35,45):new Color(25,30,35));g.setFont(new Font("Serif",Font.BOLD,10));g.drawString(a,4,13);g.setFont(new Font("Serif",Font.PLAIN,13));g.drawString(s.isEmpty()?"?":s,4,29);drawBucketMarker(g,r,26,12,10);g.dispose();}}

    private static class CardPanel extends JComponent {
        final String raw; final boolean back;
        CardPanel(String raw,boolean back){this.raw=raw==null?"?":raw.trim();this.back=back;setPreferredSize(new Dimension(92,132));setMinimumSize(getPreferredSize());}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth()-2,h=getHeight()-2; Shape rr=new RoundRectangle2D.Double(1,1,w-1,h-1,13,13);
            if(back){
                g.setColor(new Color(245,245,245)); g.fill(rr);
                g.setColor(new Color(22,72,125)); g.setStroke(new BasicStroke(2f)); g.draw(rr);
                Shape inner=new RoundRectangle2D.Double(7,7,w-13,h-13,10,10);
                g.setColor(new Color(25,91,155)); g.fill(inner);
                g.setColor(new Color(220,235,248)); g.setStroke(new BasicStroke(1f));
                for(int y=14;y<h-10;y+=12) for(int x=14;x<w-10;x+=12) g.drawOval(x,y,4,4);
                g.dispose(); return;
            }
            g.setColor(Color.WHITE); g.fill(rr); g.setColor(new Color(30,35,40)); g.setStroke(new BasicStroke(1.5f)); g.draw(rr);
            String s=raw;
            boolean unknown=s.contains("UNKNOWN")||s.contains("?")||s.equals("+");
            if(unknown){g.setColor(new Color(90,90,90));g.setFont(new Font("SansSerif",Font.BOLD,34));drawCentered(g,"?",w/2,h/2+10);g.setFont(new Font("SansSerif",Font.PLAIN,10));drawCentered(g,"UNKNOWN",w/2,h-14);g.dispose();return;}
            String suit=""; for(String x:new String[]{"♥","♦","♣","♠"}) if(s.contains(x)){suit=x;break;}
            String rank=s.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();
            if(rank.contains("/")) rank=rank.substring(0,rank.indexOf('/'))+"?";
            Color c=(suit.equals("♥")||suit.equals("♦"))?new Color(190,35,45):new Color(25,30,35); g.setColor(c);
            g.setFont(new Font("Serif",Font.BOLD,26)); g.drawString(rank,10,31); g.setFont(new Font("Serif",Font.PLAIN,25)); g.drawString(suit.isEmpty()?"?":suit,10,58);
            g.setFont(new Font("Serif",Font.BOLD,48)); drawCentered(g,suit.isEmpty()?"?":suit,w/2,h/2+21); drawBucketMarker(g,raw,w-8,27,21); g.dispose();
        }
        private void drawCentered(Graphics2D g,String s,int x,int y){FontMetrics fm=g.getFontMetrics();g.drawString(s,x-fm.stringWidth(s)/2,y);}
    }


    private void updateCompareAvailability(){
        boolean complete=currentSession!=null && isSourceVerifiedCasual(currentSession.id);
        boolean partial=currentSession!=null && isSourceSupportedPartialCasual(currentSession.id);
        boolean ok=complete||partial;
        compareButton.setEnabled(ok);
        compareButton.setToolTipText(complete?"Open source-verified Frozen and Casual journeys side by side":
                partial?"Open source-supported Casual trace; final Casual outcome remains N/A because the captured source was exhausted":
                "Casual hand-by-hand comparison is unavailable without sufficient source-supported chronology");
    }

    private boolean isSourceVerifiedCasual(String sid){
        if(loadedOutputText==null||sid==null)return false;
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return false; int b=loadedOutputText.indexOf("================ SESSION ",a+20); if(b<0)b=loadedOutputText.length();
        String block=loadedOutputText.substring(a,b);
        return block.contains("Casual reconstructed:") && block.contains("RECONSTRUCTION STATUS: SOURCE-VERIFIED");
    }


    private boolean isSourceSupportedPartialCasual(String sid){
        if(loadedOutputText==null||sid==null)return false;
        // Session 6 has one specifically documented missing-card boundary at captured card #137/H26.
        // The prefix through #136 (end of observed H25) is retained as source-supported evidence;
        // nothing at or after the unsupported boundary is used to construct the Casual trace.
        if(sid.contains("20260907_003015")){
            try{return !buildCasualTrace(loadedOutputText,sid).isEmpty();}catch(Exception ex){return false;}
        }
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return false; int b=loadedOutputText.indexOf("================ SESSION ",a+20); if(b<0)b=loadedOutputText.length();
        String block=loadedOutputText.substring(a,b);
        boolean clean=Pattern.compile("(?i)Validation warnings/overrides encountered:\s*0").matcher(block).find();
        boolean failed=block.contains("Casual completion FAIL") && block.contains("Arithmetic PASS");
        if(!clean||!failed)return false;
        try{return !buildCasualTrace(loadedOutputText,sid).isEmpty();}catch(Exception ex){return false;}
    }
    private void openCasualComparison(){
        if(currentSession==null)return;
        boolean complete=isSourceVerifiedCasual(currentSession.id);
        boolean partial=isSourceSupportedPartialCasual(currentSession.id);
        if(!complete&&!partial)return;
        try{
            List<CompareHand> casual=buildCasualTrace(loadedOutputText,currentSession.id);
            if(casual.isEmpty())throw new IOException("No source-supported Casual hands reconstructed");
            new CompareWindow(currentSession,casual,Math.max(0,handIndex),complete).setVisible(true);
        }catch(Exception ex){JOptionPane.showMessageDialog(this,"Casual comparison could not be opened without inventing evidence:\n"+ex.getMessage(),"Comparison unavailable",JOptionPane.WARNING_MESSAGE);}
    }

    static class CompareHand{
        int number; double before,after,wager,committed; String result="?",dealerUp="?";
        List<String> player=new ArrayList<>(),dealer=new ArrayList<>(),actions=new ArrayList<>();
        List<Integer> playerSeq=new ArrayList<>(),dealerSeq=new ArrayList<>();
        List<List<String>> branches=new ArrayList<>(); List<List<Integer>> branchSeq=new ArrayList<>();
    }
    static class CCard{String rank,suit,role="";int value,seq;CCard(String r,String s,int v){rank=r;suit=s;value=v;}public String toString(){return rank+suit;}}
    enum CA{HIT,STAND,DOUBLE,SPLIT}
    static class CObserved{List<List<CCard>> segs=new ArrayList<>();List<Integer> starts=new ArrayList<>();Map<Integer,List<CCard>> byHand=new HashMap<>();}
    static class CShoe{CObserved o;int seg=-1,pos=0;CShoe(CObserved x){o=x;}void before(int h)throws EOFException{int w=-1;for(int i=0;i<o.starts.size();i++)if(o.starts.get(i)<=h)w=i;if(w<0)throw new EOFException("no observed segment");if(w!=seg){seg=w;pos=0;}}CCard draw()throws EOFException{if(seg<0||pos>=o.segs.get(seg).size())throw new EOFException("observed source exhausted");return o.segs.get(seg).get(pos++);}}
    static int ct(List<CCard> c){int t=0,a=0;for(CCard q:c){t+=q.value;if(q.value==11)a++;}while(t>21&&a-->0)t-=10;return t;}
    static boolean cs(List<CCard> c){int t=0,a=0;for(CCard q:c){t+=q.value;if(q.value==11)a++;}while(t>21&&a>0){t-=10;a--;}return a>0;}
    static boolean cb(int x,int a,int b){return x>=a&&x<=b;}
    static CA casualAct(List<CCard>c,int up,boolean first,boolean pair,boolean canExtra){int t=ct(c);if(t>=21)return CA.STAND;if(first&&pair&&canExtra&&(c.get(0).value==11||c.get(0).value==8))return CA.SPLIT;if(!cs(c)&&first&&canExtra&&(t==10||t==11)&&cb(up,2,9))return CA.DOUBLE;if(cs(c)){if(t>=19)return CA.STAND;if(t==18&&(up==2||up==7||up==8))return CA.STAND;return CA.HIT;}if(t>=17)return CA.STAND;if(t>=13)return cb(up,2,6)?CA.STAND:CA.HIT;if(t==12)return cb(up,4,6)?CA.STAND:CA.HIT;return CA.HIT;}
    static CCard cc(String raw){String x=raw.trim().toUpperCase(Locale.ROOT).replace(" ",""),su="";if(x.endsWith("♠")||x.endsWith("S")){su="♠";x=x.substring(0,x.length()-1);}else if(x.endsWith("♥")||x.endsWith("H")){su="♥";x=x.substring(0,x.length()-1);}else if(x.endsWith("♦")||x.endsWith("D")){su="♦";x=x.substring(0,x.length()-1);}else if(x.endsWith("♣")||x.endsWith("C")){su="♣";x=x.substring(0,x.length()-1);}int v;if(x.equals("A"))v=11;else if(x.matches("10|J|Q|K"))v=10;else try{v=Integer.parseInt(x);}catch(Exception e){return null;}return new CCard(x,su,v);}
    static CObserved observed(String text,String sid)throws IOException{int a=text.indexOf("================ SESSION "+sid+" ================");if(a<0)throw new IOException("session block not found");int b=text.indexOf("================ END SESSION "+sid,a);if(b<0)b=text.length();String block=text.substring(a,b);TreeSet<Integer> bounds=new TreeSet<>();bounds.add(1);Matcher sm=Pattern.compile("Shuffle observed before hand (\\d+)").matcher(block);while(sm.find())bounds.add(Integer.parseInt(sm.group(1)));CObserved o=new CObserved();for(int h:bounds){o.starts.add(h);o.segs.add(new ArrayList<>());}boolean in=false;for(String line:block.split("\\R")){if(line.trim().equals("CHRONOLOGICAL CARD DATASET (RECONSTRUCTED DEAL ORDER)")){in=true;continue;}if(in&&line.trim().equals("AUDIT LOG"))break;if(!in)continue;Matcher m=Pattern.compile("#(\\d+) \\| H(\\d+) \\|\\s*(.*?)\\s*\\| (\\S+)\\s*$").matcher(line.trim());if(m.find()){int seq=Integer.parseInt(m.group(1)),h=Integer.parseInt(m.group(2));
            // Session 6 evidence boundary: H26 settles as a win while the captured dealer cards total 15;
            // therefore a dealer draw is missing.  Stop the reconstructable stream at #136 (end H25).
            // This is a conservative truncation: no later card is shifted backward or treated as the missing draw.
            if(sid.contains("20260907_003015") && seq>=137) continue;
            CCard c=cc(m.group(4));if(c==null||c.suit.isEmpty())continue;c.seq=seq;c.role=m.group(3).trim();int z=0;for(int i=0;i<o.starts.size();i++)if(o.starts.get(i)<=h)z=i;o.segs.get(z).add(c);o.byHand.computeIfAbsent(h,k->new ArrayList<>()).add(c);}}if(o.segs.stream().mapToInt(List::size).sum()==0)throw new IOException("exact observed card stream not found");return o;}
    static void branch(CShoe sh,List<List<CCard>> out,List<CCard> cards,int up,double stake,double bank,double[] committed,List<String> acts,boolean allowSplit)throws EOFException{boolean first=true;while(true){if(ct(cards)>21){out.add(cards);return;}boolean pair=allowSplit&&first&&cards.size()==2&&cards.get(0).value==cards.get(1).value;boolean can=bank-committed[0]+.001>=stake;CA ac=casualAct(cards,up,first,pair,can);acts.add(ac.toString());if(ac==CA.STAND){out.add(cards);return;}if(ac==CA.DOUBLE&&can){committed[0]+=stake;cards.add(sh.draw());out.add(cards);return;}if(ac==CA.SPLIT&&pair&&can){committed[0]+=stake;CCard x0=cards.get(0),y0=cards.get(1);List<CCard>x=new ArrayList<>(List.of(x0,sh.draw())),y=new ArrayList<>(List.of(y0,sh.draw()));if(x0.value==11){out.add(x);out.add(y);return;}branch(sh,out,x,up,stake,bank,committed,acts,false);branch(sh,out,y,up,stake,bank,committed,acts,false);return;}cards.add(sh.draw());first=false;allowSplit=false;}}
    static List<String> cstr(List<CCard>x){List<String>r=new ArrayList<>();for(CCard c:x)r.add(c.toString());return r;}
    static List<Integer> cseq(List<CCard>x){List<Integer>r=new ArrayList<>();for(CCard c:x)r.add(c.seq);return r;}
    static List<CompareHand> buildCasualTrace(String text,String sid)throws Exception{CObserved o=observed(text,sid);CShoe sh=new CShoe(o);List<CompareHand> out=new ArrayList<>();double bank=100;try{for(int h=1;h<=30;h++){double base=15;if(bank+0.001<15)break;sh.before(h);CompareHand q=new CompareHand();q.number=h;q.before=bank;q.wager=base;CCard p1=sh.draw(),hole=sh.draw(),p2=sh.draw(),up=sh.draw();q.dealerUp=up.toString();List<CCard>player=new ArrayList<>(List.of(p1,p2)),dealer=new ArrayList<>(List.of(up,hole));double[]comm={base};List<List<CCard>> ph=new ArrayList<>();double net=0;boolean pn=ct(player)==21,dn=ct(dealer)==21;if(pn){ph.add(player);q.actions.add("NATURAL");while(ct(dealer)<17)dealer.add(sh.draw());net=dn?0:1.5*base;}else{branch(sh,ph,player,up.value,base,bank,comm,q.actions,true);if(dn){for(List<CCard>x:ph)net-= (ph.size()>1?base:(comm[0]>base&&q.actions.contains("DOUBLE")?base*2:base));}else{boolean live=false;for(List<CCard>x:ph)if(ct(x)<=21)live=true;if(live)while(ct(dealer)<17)dealer.add(sh.draw());int dt=ct(dealer);for(List<CCard>x:ph){int pt=ct(x);double st=(ph.size()==1&&comm[0]>base&&q.actions.contains("DOUBLE"))?base*2:base;if(pt>21)net-=st;else if(dt>21||pt>dt)net+=st;else if(pt<dt)net-=st;}}}q.committed=comm[0];q.after=bank+net;q.result=net>0?"W":net<0?"L":"P";q.dealer=cstr(dealer);q.dealerSeq=cseq(dealer);q.player=cstr(ph.isEmpty()?player:ph.get(0));q.playerSeq=cseq(ph.isEmpty()?player:ph.get(0));for(List<CCard>x:ph){q.branches.add(cstr(x));q.branchSeq.add(cseq(x));}out.add(q);bank=q.after;}}catch(EOFException e){/* Preserve only fully settled source-supported hands; incomplete hand is never added. */}return out;}

    private static class CompactCardPanel extends JComponent {
        final String raw; final boolean differing; final int sequenceNumber;
        CompactCardPanel(String raw){this(raw,false,0);}
        CompactCardPanel(String raw,boolean differing){this(raw,differing,0);}
        CompactCardPanel(String raw,boolean differing,int sequenceNumber){this.raw=raw==null?"?":raw.trim();this.differing=differing;this.sequenceNumber=sequenceNumber;Dimension d=new Dimension(66,92);setPreferredSize(d);setMinimumSize(d);setMaximumSize(d);}
        protected void paintComponent(Graphics g0){
            Graphics2D g=(Graphics2D)g0.create(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON);
            int w=getWidth()-2,h=getHeight()-2; Shape rr=new RoundRectangle2D.Double(1,1,w-1,h-1,10,10);
            g.setColor(differing?new Color(255,249,153):Color.WHITE); g.fill(rr); g.setColor(differing?new Color(225,185,0):new Color(30,35,40)); g.setStroke(new BasicStroke(differing?2.5f:1.4f)); g.draw(rr);
            String s=raw; boolean unknown=s.contains("UNKNOWN")||s.contains("?")||s.equals("+");
            if(unknown){g.setColor(new Color(90,90,90));g.setFont(new Font("SansSerif",Font.BOLD,28));drawCenteredCompact(g,"?",w/2,h/2+8);g.dispose();return;}
            String suit=""; for(String x:new String[]{"♥","♦","♣","♠"}) if(s.contains(x)){suit=x;break;}
            String rank=s.replace("♥","").replace("♦","").replace("♣","").replace("♠","").trim();
            if(rank.contains("/")) rank=rank.substring(0,rank.indexOf('/'))+"?";
            Color c=(suit.equals("♥")||suit.equals("♦"))?new Color(190,35,45):new Color(25,30,35); g.setColor(c);
            g.setFont(new Font("Serif",Font.BOLD,19)); g.drawString(rank,7,22);
            g.setFont(new Font("Serif",Font.PLAIN,18)); g.drawString(suit.isEmpty()?"?":suit,7,40);
            g.setFont(new Font("Serif",Font.BOLD,31)); drawCenteredCompact(g,suit.isEmpty()?"?":suit,w/2,h/2+15);
            drawBucketMarker(g,raw,sequenceNumber>0?w-25:w-7,20,16);
            if(sequenceNumber>0){int r=22,cx=w-12,cy=12;g.setColor(new Color(72,132,196));g.fillOval(cx-r/2,cy-r/2,r,r);g.setColor(Color.WHITE);g.setFont(new Font("SansSerif",Font.BOLD,sequenceNumber<100?9:8));String n=String.valueOf(sequenceNumber);FontMetrics fm=g.getFontMetrics();g.drawString(n,cx-fm.stringWidth(n)/2,cy+(fm.getAscent()-fm.getDescent())/2);}
            g.dispose();
        }
        private void drawCenteredCompact(Graphics2D g,String s,int x,int y){FontMetrics fm=g.getFontMetrics();g.drawString(s,x-fm.stringWidth(s)/2,y);}
    }

    private String comparisonEvidenceSummary(String sid){
        if(loadedOutputText==null||sid==null)return "COMPARABLE DECISIONS: N/A";
        int a=loadedOutputText.indexOf("================ SESSION "+sid+" ================");
        if(a<0)return "COMPARABLE DECISIONS: N/A";
        int b=loadedOutputText.indexOf("================ SESSION ",a+20); if(b<0)b=loadedOutputText.length();
        String block=loadedOutputText.substring(a,b);
        Matcher m=Pattern.compile("Directly Comparable Action Decisions:\\s*(\\d+)\\s*\\|\\s*Matched\\s*(\\d+)\\s*\\|\\s*Different\\s*(\\d+)\\s*\\|\\s*Alignment\\s*([0-9.]+%)",Pattern.CASE_INSENSITIVE).matcher(block);
        String last=null;
        while(m.find()) last="COMPARABLE DECISIONS  "+m.group(1)+"     ✓ MATCHED  "+m.group(2)+"     ✕ DIFFERED  "+m.group(3)+"     ALIGNMENT  "+m.group(4);
        return last==null?"COMPARABLE DECISIONS: N/A":last;
    }
    private String firstFrozenAction(Hand h){return h==null||h.actions.isEmpty()?"":h.actions.get(0).trim().toUpperCase(Locale.ROOT);}
    private String firstCasualAction(CompareHand h){return h==null||h.actions.isEmpty()?"":h.actions.get(0).trim().toUpperCase(Locale.ROOT);}
    private String rankOnly(String raw){
        if(raw==null)return ""; String x=raw.trim().toUpperCase(Locale.ROOT);
        return x.replace("♠","").replace("♥","").replace("♦","").replace("♣","").replaceAll("[SHDC]$","").trim();
    }
    private boolean sameOpeningState(Hand f,CompareHand c){
        if(f==null||c==null||f.player.size()<2||c.player.size()<2)return false;
        String fd=rankOnly(f.dealerUp), cd=rankOnly(c.dealerUp); if(!fd.equals(cd))return false;
        List<String> a=new ArrayList<>(List.of(rankOnly(f.player.get(0)),rankOnly(f.player.get(1))));
        List<String> b=new ArrayList<>(List.of(rankOnly(c.player.get(0)),rankOnly(c.player.get(1))));
        Collections.sort(a); Collections.sort(b); return a.equals(b);
    }

    class CompareWindow extends JFrame{
        Session frozen; List<CompareHand> casual; CObserved observedSource; int idx; boolean casualComplete; JLabel pos=new JLabel(), evidence=new JLabel(), decision=new JLabel(); JPanel left=new JPanel(),right=new JPanel();
        CompareWindow(Session f,List<CompareHand>c,int start,boolean casualComplete){
            super("Frozen vs Casual — "+sessionDisplayTitle(f)); frozen=f; casual=c; this.casualComplete=casualComplete;
            try{observedSource=observed(loadedOutputText,f.id);}catch(Exception ex){observedSource=null;}
            idx=Math.min(start,Math.max(f.hands.size(),c.size())-1);
            Rectangle usable=GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
            setSize(Math.min(1320,usable.width),Math.min(700,usable.height)); setLocationRelativeTo(BlackjackSessionReplay.this); setLayout(new BorderLayout());

            JLabel h=new JLabel(casualComplete ?
                    "SOURCE-VERIFIED JUXTAPOSED REPLAY  •  FROZEN vs CASUAL  •  SAME CAPTURED OBSERVED STREAM" :
                    "SOURCE-SUPPORTED JUXTAPOSED REPLAY  •  CASUAL INCOMPLETE / SOURCE EXHAUSTED  •  FINAL OUTCOME N/A",SwingConstants.CENTER);
            h.setOpaque(true); h.setBackground(NAVY); h.setForeground(Color.WHITE); h.setFont(new Font("SansSerif",Font.BOLD,17)); h.setBorder(new EmptyBorder(8,8,8,8));
            JButton prev=new JButton("◀ PREVIOUS HAND"),next=new JButton("NEXT HAND ▶");
            prev.addActionListener(e->{if(idx>0){idx--;showIt();}}); next.addActionListener(e->{if(idx+1<Math.max(frozen.hands.size(),casual.size())){idx++;showIt();}});
            pos.setFont(new Font("SansSerif",Font.BOLD,14));
            JPanel nav=new JPanel(new FlowLayout(FlowLayout.CENTER,18,4)); nav.add(prev); nav.add(pos); nav.add(next);

            evidence.setText(casualComplete ? comparisonEvidenceSummary(f.id) :
                    "Supported Casual trace: " + c.size() + " complete hands preserved • comparison stops before unsupported/incomplete hand • no final Casual bankroll inferred"); evidence.setHorizontalAlignment(SwingConstants.CENTER);
            evidence.setOpaque(true); evidence.setBackground(new Color(235,246,238)); evidence.setForeground(new Color(18,96,55));
            evidence.setFont(new Font("SansSerif",Font.BOLD,14)); evidence.setBorder(new CompoundBorder(new MatteBorder(1,0,1,0,new Color(165,205,178)),new EmptyBorder(5,8,5,8)));

            decision.setHorizontalAlignment(SwingConstants.CENTER); decision.setOpaque(true);
            decision.setFont(new Font("SansSerif",Font.BOLD,14)); decision.setBorder(new EmptyBorder(5,8,5,8));

            JPanel top=new JPanel(); top.setLayout(new BoxLayout(top,BoxLayout.Y_AXIS)); top.add(h); top.add(nav); top.add(evidence); top.add(decision); add(top,BorderLayout.NORTH);

            // Comparison mode intentionally has NO Same Starting State / previous-observations side panel.
            // The full width is reserved for the two actual journeys and their cards.
            JPanel body=new JPanel(new GridLayout(1,2,6,0)); body.setBackground(new Color(220,226,230));
            left.setLayout(new BoxLayout(left,BoxLayout.Y_AXIS)); right.setLayout(new BoxLayout(right,BoxLayout.Y_AXIS));
            JScrollPane ls=new JScrollPane(left),rs=new JScrollPane(right);
            ls.setBorder(BorderFactory.createEmptyBorder()); rs.setBorder(BorderFactory.createEmptyBorder());
            body.add(ls); body.add(rs); add(body,BorderLayout.CENTER);
            JPanel legend=new JPanel(new FlowLayout(FlowLayout.CENTER,8,4)); legend.setBackground(new Color(248,248,248));
            JPanel swatch=new JPanel(); swatch.setPreferredSize(new Dimension(30,16)); swatch.setBackground(new Color(255,249,153)); swatch.setBorder(new LineBorder(new Color(225,185,0),2));
            legend.add(swatch); legend.add(new JLabel("Differing card allocation at this hand position"));
            JLabel badgeLegend=new JLabel("   Blue number badge = captured stream draw order"); badgeLegend.setFont(new Font("SansSerif",Font.BOLD,12)); legend.add(badgeLegend); add(legend,BorderLayout.SOUTH);
            showIt();
        }
        JLabel cl(String s,int sz,boolean bold){JLabel x=new JLabel(s,SwingConstants.CENTER);x.setFont(new Font("SansSerif",bold?Font.BOLD:Font.PLAIN,sz));x.setAlignmentX(Component.CENTER_ALIGNMENT);x.setBorder(new EmptyBorder(2,6,2,6));return x;}
        String cardKey(String raw){return raw==null?"":raw.trim().toUpperCase(Locale.ROOT).replace(" ","");}
        boolean[] differingCards(List<String> cards,List<String> other){
            boolean[] diff=new boolean[cards.size()]; boolean[] used=new boolean[other==null?0:other.size()];
            for(int i=0;i<cards.size();i++){String k=cardKey(cards.get(i));int found=-1;for(int j=0;j<used.length;j++)if(!used[j]&&k.equals(cardKey(other.get(j)))){found=j;break;}if(found>=0)used[found]=true;else diff[i]=true;}
            return diff;
        }
        JPanel cardRow(String label,List<String> cards,List<String> otherCards,List<Integer> seqs){
            JPanel outer=new JPanel(); outer.setOpaque(false); outer.setLayout(new BoxLayout(outer,BoxLayout.Y_AXIS));
            JLabel l=cl(label,14,true); outer.add(l);
            JPanel row=new JPanel(new FlowLayout(FlowLayout.CENTER,6,1)); row.setOpaque(false);
            boolean[] diff=differingCards(cards,otherCards);
            for(int i=0;i<cards.size();i++){int q=(seqs!=null&&i<seqs.size())?seqs.get(i):0;row.add(new CompactCardPanel(cards.get(i),diff[i],q));} outer.add(row);
            outer.setPreferredSize(new Dimension(100, 124));
            outer.setMaximumSize(new Dimension(Integer.MAX_VALUE, 124));
            outer.setMinimumSize(new Dimension(100, 124));
            return outer;
        }
        List<Integer> frozenPlayerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;List<CCard>pool=new ArrayList<>();for(CCard c:all)if(c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))pool.add(c);boolean[]used=new boolean[pool.size()];for(String raw:h.player){int hit=-1;for(int i=0;i<pool.size();i++)if(!used[i]&&cardKey(raw).equals(cardKey(pool.get(i).toString()))){hit=i;break;}if(hit>=0){used[hit]=true;r.add(pool.get(hit).seq);}else r.add(0);}return r;}
        List<Integer> frozenDealerSeq(Hand h){List<Integer>r=new ArrayList<>();if(h==null||observedSource==null)return r;List<CCard>all=observedSource.byHand.get(h.number);if(all==null)return r;CCard up=null,hole=null;List<CCard>draws=new ArrayList<>();for(CCard c:all){String role=c.role.toUpperCase(Locale.ROOT);if(role.contains("DEALER UPCARD"))up=c;else if(role.contains("DEALER HOLE"))hole=c;else if(role.startsWith("DEALER DRAW"))draws.add(c);}if(up!=null)r.add(up.seq);else r.add(0);if(!h.dealerHidden.isEmpty()){if(hole!=null)r.add(hole.seq);else r.add(0);for(int i=1;i<h.dealerHidden.size();i++)r.add(i-1<draws.size()?draws.get(i-1).seq:0);}return r;}
        List<Integer> frozenSeqForCards(Hand h,List<String> cards){
            List<Integer> r=new ArrayList<>(); if(h==null||observedSource==null)return r;
            List<CCard> all=observedSource.byHand.get(h.number); if(all==null)return r;
            List<CCard> pool=new ArrayList<>(); for(CCard c:all)if(c.role.toUpperCase(Locale.ROOT).startsWith("PLAYER"))pool.add(c);
            boolean[] used=new boolean[pool.size()];
            for(String raw:cards){int hit=-1;for(int j=0;j<pool.size();j++)if(!used[j]&&cardKey(raw).equals(cardKey(pool.get(j).toString()))){hit=j;break;}if(hit>=0){used[hit]=true;r.add(pool.get(hit).seq);}else r.add(0);}
            return r;
        }
        JPanel splitCompareBlock(String side,List<List<String>> branches,List<List<Integer>> seqs,List<List<String>> otherBranches,Hand fh){
            JPanel box=new JPanel(new GridLayout(1,Math.max(1,branches.size()),8,0));box.setOpaque(false);
            for(int z=0;z<branches.size();z++){List<String>b=branches.get(z);List<String>other=z<otherBranches.size()?otherBranches.get(z):Collections.emptyList();List<Integer>sq=(seqs!=null&&z<seqs.size())?seqs.get(z):(fh==null?Collections.emptyList():frozenSeqForCards(fh,b));box.add(cardRow("SPLIT "+(char)('A'+z)+"  •  TOTAL "+cardTotal(b),b,other,sq));}
            box.setMaximumSize(new Dimension(Integer.MAX_VALUE,130));return box;
        }
        void fill(JPanel p,String title,int i,boolean cas){
            p.removeAll(); p.setBackground(cas?new Color(235,247,239):new Color(230,242,247));
            p.add(Box.createVerticalStrut(2)); p.add(cl(title,19,true));
            Hand fh=i<frozen.hands.size()?frozen.hands.get(i):null;
            CompareHand ch=i<casual.size()?casual.get(i):null;
            List<String> frozenDealer=new ArrayList<>(), frozenPlayer=new ArrayList<>(), casualDealer=new ArrayList<>(), casualPlayer=new ArrayList<>();
            List<Integer> frozenDealerSeq=new ArrayList<>(),frozenPlayerSeq=new ArrayList<>();
            if(fh!=null){frozenDealer.add(fh.dealerUp);frozenDealer.addAll(fh.dealerHidden);frozenPlayer.addAll(fh.player);frozenDealerSeq=frozenDealerSeq(fh);frozenPlayerSeq=frozenPlayerSeq(fh);}
            if(ch!=null){casualDealer.addAll(ch.dealer);casualPlayer.addAll(ch.player);}
            if(cas){
                if(ch==null){p.add(Box.createVerticalStrut(45));p.add(cl("JOURNEY ENDED • bankroll/table exit",19,true));p.revalidate();p.repaint();return;}
                p.add(cl("HAND "+ch.number+" / "+casual.size(),15,true));
                p.add(cl("Bankroll £"+money(ch.before)+" → £"+money(ch.after)+"     Wager £"+money(ch.wager)+"     Exposure £"+money(ch.committed),13,true));
                p.add(cardRow("DEALER  •  TOTAL "+cardTotal(ch.dealer),ch.dealer,frozenDealer,ch.dealerSeq));
                if(ch.branches.size()>1){List<List<String>> other=new ArrayList<>();if(fh!=null&&fh.split){other.add(fh.splitA);other.add(fh.splitB);}p.add(splitCompareBlock("CASUAL",ch.branches,ch.branchSeq,other,null));}
                else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(ch.player),ch.player,frozenPlayer,ch.playerSeq));
                p.add(cl("Actions: "+String.join(" → ",ch.actions),14,true)); p.add(cl("RESULT: "+friendlyOutcome(ch.result),17,true));
            }else{
                if(fh==null){p.add(Box.createVerticalStrut(45));p.add(cl("JOURNEY ENDED",19,true));p.revalidate();p.repaint();return;}
                p.add(cl("HAND "+fh.number+" / "+frozen.hands.size(),15,true));
                p.add(cl("Bankroll £"+money(fh.researchStart())+" → £"+money(fh.researchEnd())+"     Wager £"+fh.wager+"     Exposure £"+fh.committed,13,true));
                p.add(cardRow("DEALER  •  TOTAL "+cardTotal(frozenDealer),frozenDealer,casualDealer,frozenDealerSeq));
                if(fh.split){List<List<String>> branches=new ArrayList<>();branches.add(fh.splitA);branches.add(fh.splitB);List<List<String>> other=(ch!=null&&ch.branches.size()>1)?ch.branches:Collections.emptyList();p.add(splitCompareBlock("FROZEN",branches,null,other,fh));}
                else p.add(cardRow("PLAYER  •  TOTAL "+cardTotal(frozenPlayer),frozenPlayer,casualPlayer,frozenPlayerSeq));
                p.add(cl("Actions: "+(fh.actions.isEmpty()?"—":String.join(" → ",fh.actions)),14,true)); p.add(cl("RESULT: "+friendlyOutcome(fh.result),17,true));
            }
            p.revalidate(); p.repaint();
        }
        void showIt(){
            fill(left,"FROZEN — OBSERVED",idx,false); fill(right,"CASUAL — RECONSTRUCTED",idx,true); pos.setText("  HAND POSITION "+(idx+1)+"  ");
            if(idx<frozen.hands.size() && idx<casual.size()){
                Hand f=frozen.hands.get(idx); CompareHand c=casual.get(idx);
                if(sameOpeningState(f,c)){
                    String fa=firstFrozenAction(f), ca=firstCasualAction(c);
                    if(!fa.isEmpty()&&!ca.isEmpty()&&fa.equals(ca)){
                        decision.setText("✓ CURRENT COMPARABLE OPENING DECISION MATCHED  •  "+fa);
                        decision.setBackground(new Color(226,244,232)); decision.setForeground(new Color(20,104,57));
                    }else if(!fa.isEmpty()&&!ca.isEmpty()){
                        decision.setText("✕ CURRENT COMPARABLE OPENING DECISION DIFFERED  •  FROZEN "+fa+"   vs   CASUAL "+ca);
                        decision.setBackground(new Color(255,232,228)); decision.setForeground(new Color(170,38,32));
                    }else{
                        decision.setText("CURRENT OPENING STATE COMPARABLE  •  decision label unavailable");
                        decision.setBackground(new Color(242,242,242)); decision.setForeground(new Color(80,80,80));
                    }
                }else{
                    decision.setText("STREAMS DIVERGED AT THIS HAND POSITION  •  opening decisions are not directly comparable");
                    decision.setBackground(new Color(242,242,242)); decision.setForeground(new Color(85,85,85));
                }
            }else{
                decision.setText("ONE JOURNEY HAS ENDED  •  no direct hand-position comparison");
                decision.setBackground(new Color(242,242,242)); decision.setForeground(new Color(85,85,85));
            }
        }
    }
    static int cardTotal(List<String> xs){int t=0,a=0;for(String raw:xs){String x=raw.replaceAll("[♠♥♦♣SHDC]$","");int v;if(x.equals("A")){v=11;a++;}else if(x.matches("10|J|Q|K"))v=10;else try{v=Integer.parseInt(x);}catch(Exception e){continue;}t+=v;}while(t>21&&a-->0)t-=10;return t;}

    static class Session { String id=""; String summary=""; String architecture=""; String sessionName=""; int publicationNumber=0; double offset=0; boolean hadPreamble=false; List<Hand> hands=new ArrayList<>(); }
    static class Hand {
        int number; String startBank="?",endBank="?",refWager="?",wager="?",committed="?",result="?",dealerUp="?";
        double offset=0;
        double researchStart(){ return parseMoney(startBank)-offset; }
        double researchEnd(){ return parseMoney(endBank)-offset; }
        List<String> player=new ArrayList<>(), dealerHidden=new ArrayList<>(), actions=new ArrayList<>(), splitA=new ArrayList<>(),splitB=new ArrayList<>();
        String splitAResult="", splitBResult="";
        boolean split=false,evidenceGap=false,playerBust=false,dealerBust=false,natural=false;
        boolean shuffleBefore=false; int shufflePreviousCards=0; Boolean frozenFollowed=null;
    }

    static class Parser {
        private static final Pattern SESSION=Pattern.compile("(?m)^=+\\s*SESSION\\s+([^\\s=]+).*?=+\\s*$");
        private static final Pattern SUMMARY=Pattern.compile("(?m)^HAND\\s+(\\d{2})\\s*\\|\\s*([^|]+?)\\s*\\|\\s*ref\\s+([0-9.]+)\\s+actual\\s+([0-9.]+)\\s*\\|\\s*committed\\s+([0-9.]+)\\s*\\|\\s*([^|]+)\\|\\s*(.+)$");
        static List<Session> parse(String text){
            List<Session> out=new ArrayList<>(); Matcher sm=SESSION.matcher(text); List<Integer> starts=new ArrayList<>(); List<String> ids=new ArrayList<>();
            while(sm.find()){starts.add(sm.start());ids.add(sm.group(1));}
            for(int i=0;i<starts.size();i++){
                int a=starts.get(i),b=(i+1<starts.size()?starts.get(i+1):text.length()); String block=text.substring(a,b);
                Session s=parseSession(ids.get(i),block); if(!s.hands.isEmpty()) out.add(s);
            }
            // If no explicit project-ledger SESSION header, parse whole file as one session.
            if(out.isEmpty()){Session s=parseSession("FROM_OUTPUT",text);if(!s.hands.isEmpty())out.add(s);}
            int pub=0;
            for(Session s:out){
                if(s.id!=null && s.id.contains("20260903_135610")){s.publicationNumber=0;continue;}
                s.publicationNumber=++pub;
            }
            return out;
        }
        static Session parseSession(String id,String block){
            Session s=new Session();s.id=id;
            s.hadPreamble = Pattern.compile("(?im)^PREAMBLE\\s*:\\s*YES\\b|^PREAMBLE SOURCE\\s*:").matcher(block).find()
                    || Pattern.compile("(?im)^SESSION\\s+NAME\\s*:\\s*.*PREAMBLE").matcher(block).find();
            Matcher nameMatcher=Pattern.compile("(?im)^SESSION\\s+NAME\\s*:\\s*(.+)$").matcher(block);
            if(nameMatcher.find()) s.sessionName=nameMatcher.group(1).trim();
            else {
                Matcher ledgerName=Pattern.compile("(?im)^LEDGER_ENTRY\\s*\\|.*?\\|\\s*name\\s+([^|]+)\\|").matcher(block);
                if(ledgerName.find()) s.sessionName=ledgerName.group(1).trim();
            }
            Matcher modeMatcher=Pattern.compile("(?im)^LEDGER_ENTRY\\s*\\|.*?\\|\\s*mode\\s+([A-Z_-]+)\\s*\\|").matcher(block);
            if(modeMatcher.find()) s.architecture=modeMatcher.group(1).trim().toUpperCase();
            else {
                Matcher explicitMode=Pattern.compile("(?im)^MODE\\s*:\\s*(FROZEN|HYBRID)\\b").matcher(block);
                if(explicitMode.find()) s.architecture=explicitMode.group(1).trim().toUpperCase();
            }
            Matcher om = Pattern.compile("(?i)research-equivalent bankroll\s*=\s*platform balance\s*-\s*£?([0-9.]+)").matcher(block);
            if(om.find()) s.offset=parseMoney(om.group(1));
            else { Matcher bm=Pattern.compile("(?i)PLATFORM BALANCE BASIS:\s*Start £?([0-9.]+).*?£?([0-9.]+)\s*=\s*research-equivalent £?0").matcher(block); if(bm.find()) s.offset=parseMoney(bm.group(2)); }

            // A documented pre-Hand-1 balance correction changes the session floor as well.
            // Example: actual pre-Hand-1 platform £2545 means the research floor is £2445,
            // because every observed live session independently starts at research £100.
            // This is deliberately evidence-driven: no correction is applied unless the
            // session block explicitly records the corrected pre-Hand-1 platform balance.
            Matcher startCorrection = Pattern.compile(
                "(?i)actual\\s+pre-Hand-1\\s+platform\\s+balance\\s+was\\s*[^0-9]*([0-9]+(?:\\.[0-9]+)?)"
            ).matcher(block);
            if(startCorrection.find()) {
                double correctedPlatformStart = parseMoney(startCorrection.group(1));
                if(correctedPlatformStart >= 100.0) s.offset = correctedPlatformStart - 100.0;
            }
            LinkedHashMap<Integer,Hand> hands=new LinkedHashMap<>();
            Matcher m=SUMMARY.matcher(block);
            while(m.find()){
                int n=Integer.parseInt(m.group(1)); if(hands.containsKey(n)) continue;
                Hand h=new Hand();h.number=n; h.offset=s.offset;
                String[] bank=m.group(2).trim().split("->"); if(bank.length==2){h.startBank=bank[0].trim();h.endBank=bank[1].trim();}
                h.refWager=m.group(3);h.wager=m.group(4);h.committed=m.group(5); String tail=m.group(7).trim(); parseTail(h,tail); hands.put(n,h);
            }
            // action provenance from the detailed per-hand audit chunks
            for(Hand h:hands.values()){
                Pattern hp=Pattern.compile("(?s)=+\\s*HAND\\s+"+h.number+"\\s*/\\s*30\\s*=+(.*?)(?==+\\s*HAND\\s+"+(h.number+1)+"\\s*/\\s*30\\s*=+|$)");
                Matcher hm=hp.matcher(block); if(hm.find()){
                    String chunk=hm.group(1); Matcher am=Pattern.compile("actual\\s+(HIT|STAND|DOUBLE|SPLIT)",Pattern.CASE_INSENSITIVE).matcher(chunk);
                    while(am.find()){String a=am.group(1).toUpperCase(); if(h.actions.isEmpty()||!h.actions.get(h.actions.size()-1).equals(a)) h.actions.add(a);}
                    String lc=chunk.toLowerCase();
                    Matcher sh=Pattern.compile("(?i)SHUFFLE RECORDED before hand\\s+"+h.number+"\\s*\\|\\s*previous segment cards recorded:\s*(\\d+)").matcher(chunk); if(sh.find()){h.shuffleBefore=true;h.shufflePreviousCards=Integer.parseInt(sh.group(1));}
                    Matcher cm=Pattern.compile("(?i)\\bfrozen\\s+(HIT|STAND|DOUBLE|SPLIT)\\s*\\|\\s*actual\\s+(HIT|STAND|DOUBLE|SPLIT)").matcher(chunk); boolean saw=false,ok=true; while(cm.find()){saw=true;if(!cm.group(1).equalsIgnoreCase(cm.group(2)))ok=false;} if(saw)h.frozenFollowed=ok;
                    if(lc.contains("natural")) { h.natural=true; h.actions.add(0,"NATURAL"); }
                    if(Pattern.compile("(?i)MAIN\\s+BUST|PLAYER\\s+BUST").matcher(chunk).find()) h.playerBust=true;
                    if(Pattern.compile("(?i)DEALER[^\\n]*(BUST|=\\s*2[2-9]|=\\s*3[0-9])").matcher(chunk).find()) h.dealerBust=true;

                    // Later session summaries can retain only the original pair even when the hand was split.
                    // Reconstruct graphical child hands only from explicit Split A / Split B lines in the audit chunk.
                    if(Pattern.compile("(?i)actual\\s+SPLIT|FROZEN\\s+POLICY:\\s*SPLIT").matcher(chunk).find()) {
                        h.split = true;
                        Matcher sa = Pattern.compile("(?im)^\\s*Split A\\s+([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+\\s*$").matcher(chunk);
                        Matcher sb = Pattern.compile("(?im)^\\s*Split B\\s+([^\\n]+?)\\s*=\\s*(?:soft\\s+)?\\d+\\s*$").matcher(chunk);
                        if(sa.find()) h.splitA = cards(sa.group(1));
                        if(sb.find()) h.splitB = cards(sb.group(1));
                        if(h.splitA.isEmpty() && h.player.size()>=1) h.splitA.add(h.player.get(0));
                        if(h.splitB.isEmpty() && h.player.size()>=2) h.splitB.add(h.player.get(1));

                        Matcher sao = Pattern.compile("(?im)^\\s*Split A[^\\n]*(?:\\bWIN\\b|\\bLOSS\\b|\\bPUSH\\b)").matcher(chunk);
                        Matcher sbo = Pattern.compile("(?im)^\\s*Split B[^\\n]*(?:\\bWIN\\b|\\bLOSS\\b|\\bPUSH\\b)").matcher(chunk);
                        if(sao.find()) h.splitAResult = explicitOutcome(sao.group());
                        if(sbo.find()) h.splitBResult = explicitOutcome(sbo.group());
                    }
                }
            }
            s.hands.addAll(hands.values());
            if(!s.hands.isEmpty()){
                Hand first=s.hands.get(0),last=s.hands.get(s.hands.size()-1); s.summary="Recorded £"+money(first.researchStart())+" → £"+money(last.researchEnd());
            }
            return s;
        }
        static void parseTail(Hand h,String tail){
            String[] f=tail.split("\\s*\\|\\s*");
            for(String x:f){
                if(x.startsWith("P original ")){
                    h.split=true; String z=x.substring("P original ".length()); int ar=z.indexOf("-> A ");
                    if(ar>=0){h.player=cards(z.substring(0,ar));String rest=z.substring(ar+5);int semi=rest.indexOf("; B ");if(semi>=0){String aRaw=rest.substring(0,semi),bRaw=rest.substring(semi+4);h.splitA=cards(stripParen(aRaw));h.splitB=cards(stripParen(bRaw));h.splitAResult=splitResult(aRaw);h.splitBResult=splitResult(bRaw);}}
                } else if(x.startsWith("P ")) h.player=cards(x.substring(2));
                else if(x.startsWith("D-up ")) h.dealerUp=x.substring(5).trim();
                else if(x.startsWith("D-hidden/draw ")) h.dealerHidden=cards(x.substring(14));
                else if(x.startsWith("D ")) { // special split dealer full hand: first shown as upcard in historical summary
                    String z=x.substring(2); int eq=z.indexOf(" = "); if(eq>=0) z=z.substring(0,eq); List<String>d=cards(z); if(!d.isEmpty()){h.dealerUp=d.get(0);h.dealerHidden=new ArrayList<>(d.subList(1,d.size()));}
                } else if(x.matches("(?i)(W|L|P|SPLIT.*|dealer.*)")) h.result=x.trim();
            }
            if(h.result.equals("?")) h.result=f[f.length-1].trim();
            String up=tail.toUpperCase(); if(up.contains("UNKNOWN")||up.contains("INCOMPLETE")||up.contains("[?]"))h.evidenceGap=true;
            // Hand 28 in Session 1 is known in the research record to have incomplete retained split-B card evidence.
            if(h.number==28 && h.split==false && h.committed.equals("30.00")) h.evidenceGap=true;
        }
        static String explicitOutcome(String s){
            String u=s.toUpperCase();
            if(u.contains("WIN")) return "W";
            if(u.contains("LOSS")) return "L";
            if(u.contains("PUSH")) return "P";
            return "";
        }
        static String splitResult(String s){Matcher m=Pattern.compile("\\((?:[^,]*,){2}\\s*([WLP])\\s*\\)",Pattern.CASE_INSENSITIVE).matcher(s);return m.find()?m.group(1).toUpperCase():"";}
        static String stripParen(String s){int p=s.indexOf(" (");return p>=0?s.substring(0,p):s;}
        static List<String> cards(String s){
            List<String> r=new ArrayList<>(); for(String q:s.split(",")){q=q.trim(); if(q.isEmpty())continue; int p=q.indexOf(" (");if(p>0)q=q.substring(0,p).trim(); if(q.startsWith("+UNKNOWN"))q="?"; r.add(q);} return r;
        }
    }

    public static void main(String[] args){
        SwingUtilities.invokeLater(()->{ try{UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());}catch(Exception ignored){} new BlackjackSessionReplay().setVisible(true); });
    }
}


// -----------------------------------------------------------------------------
// LIVE GUI v15.10.42 NAVIGATION + STORY-EXPLANATION WRAPPER
// -----------------------------------------------------------------------------
/**
 * v15.10.42 retains the v15.10.40 navigation wrapper; Replay Stats Corner stories now add wager-assumption audits.
 * The inherited live/backend behaviour is unchanged. This class only adds
 * a top-right Stats Corner shortcut and updates visible GUI version text.
 */
class BlackjackLiveGuiUAT extends BlackjackLiveGuiOld {
    private static final String UI_VERSION = "15.10.59-UAT";
    private final JButton statsCornerButton = new JButton("STATS CORNER");
    private final JPanel liveCardRatePanel = new JPanel();
    private final JLabel liveThreeRate = new JLabel("—", SwingConstants.CENTER);
    private final JLabel liveFourRate = new JLabel("—", SwingConstants.CENTER);
    private final JLabel liveThreeCount = new JLabel("0 resolved", SwingConstants.CENTER);
    private final JLabel liveFourCount = new JLabel("0 resolved", SwingConstants.CENTER);
    private int live3Resolved=0, live3Wins=0, live4Resolved=0, live4Wins=0, lastRateHand=-1;
    private int pendingPlayerCardCount=0, pendingHand=-1;

    public BlackjackLiveGuiUAT() {
        super();
        setTitle("Deterministic Blackjack — Live Graphical Companion • " + UI_VERSION);
        replaceVisibleVersionText(getContentPane());
        installStatsCornerButton();
        rebalanceLiveTableAndTranscript();
        // Keep the established right-hand live area untouched.  Card-count context
        // is shown only as the new bottom row in Recent Player Experience.
    }

    private void rebalanceLiveTableAndTranscript() {
        // The original live table was 172 px high.  The extra recent-experience row
        // needs a little more room, so grow the table enough for split-hand cards and let the central
        // transcript scroll pane give up the same space.  The transcript remains
        // comfortably usable and the dealer/player card rows keep their normal height.
        Container c = dealerVisual;
        while (c != null) {
            Dimension d = c.getPreferredSize();
            if (c instanceof JPanel && d != null && d.height >= 160 && d.height <= 190) {
                c.setPreferredSize(new Dimension(Math.max(0, d.width), 285));
                c.setMinimumSize(new Dimension(0, 270));
                break;
            }
            c = c.getParent();
        }
        revalidate();
    }

    private void installStatsCornerButton() {
        statsCornerButton.setFont(new Font("SansSerif", Font.BOLD, 11));
        statsCornerButton.setForeground(new Color(10, 41, 75));
        statsCornerButton.setBackground(new Color(245, 248, 251));
        statsCornerButton.setFocusPainted(false);
        statsCornerButton.setToolTipText("Open Fascinating Stats Corner without closing the live companion");
        statsCornerButton.addActionListener(e -> BlackjackSessionReplay.openStatsCornerFromLive(this));

        JLayeredPane layered = getLayeredPane();
        layered.add(statsCornerButton, JLayeredPane.PALETTE_LAYER);
        repositionStatsButton();
        addComponentListener(new ComponentAdapter() {
            @Override public void componentResized(ComponentEvent e) { repositionStatsButton(); }
            @Override public void componentShown(ComponentEvent e) { repositionStatsButton(); }
        });
    }

    private void repositionStatsButton() {
        int w = 132;
        int h = 30;
        int x = Math.max(20, getLayeredPane().getWidth() - w - 24);
        int y = 14;
        statsCornerButton.setBounds(x, y, w, h);
        statsCornerButton.revalidate();
        statsCornerButton.repaint();
        repositionLiveCardRatePanel();
    }

    private void installLiveCardRatePanel() {
        liveCardRatePanel.setLayout(new GridLayout(2,1,0,5));
        liveCardRatePanel.setBackground(new Color(248,250,252));
        liveCardRatePanel.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(205,216,226)),
                BorderFactory.createEmptyBorder(8,9,8,9)));
        liveCardRatePanel.add(liveRateRow("3-CARD WIN RATE",liveThreeRate,liveThreeCount));
        liveCardRatePanel.add(liveRateRow("4+ CARD WIN RATE",liveFourRate,liveFourCount));
        getLayeredPane().add(liveCardRatePanel,JLayeredPane.PALETTE_LAYER);
        repositionLiveCardRatePanel();
        javax.swing.Timer refresh=new javax.swing.Timer(700,e->{
            boolean personal="PERSONAL".equalsIgnoreCase(liveMode);
            liveCardRatePanel.setVisible(personal);
            if(personal)refreshLiveRates();
        });
        refresh.start(); liveCardRatePanel.putClientProperty("refreshTimer",refresh);
    }
    private JPanel liveRateRow(String title,JLabel rate,JLabel count){
        JPanel p=new JPanel(new BorderLayout(5,0)); p.setOpaque(false);
        JLabel t=new JLabel("<html><b>"+title+"</b></html>"); t.setFont(new Font("SansSerif",Font.BOLD,11)); t.setForeground(new Color(10,41,75));
        rate.setFont(new Font("SansSerif",Font.BOLD,18)); rate.setForeground(new Color(30,105,170));
        count.setFont(new Font("SansSerif",Font.PLAIN,9)); count.setForeground(new Color(92,103,113));
        JPanel r=new JPanel();r.setOpaque(false);r.setLayout(new BoxLayout(r,BoxLayout.Y_AXIS));rate.setAlignmentX(Component.CENTER_ALIGNMENT);count.setAlignmentX(Component.CENTER_ALIGNMENT);r.add(rate);r.add(count);
        p.add(t,BorderLayout.WEST);p.add(r,BorderLayout.EAST);return p;
    }
    private void repositionLiveCardRatePanel(){
        if(liveCardRatePanel==null)return; int w=235,h=92;
        int x=Math.max(20,getLayeredPane().getWidth()-w-24); int y=52;
        liveCardRatePanel.setBounds(x,y,w,h); liveCardRatePanel.revalidate(); liveCardRatePanel.repaint();
    }
    private int currentSinglePlayerCardCount(){
        if(splitActive)return 0; int n=0;
        if(p1!=null&&!p1.isBlank())n++; if(p2!=null&&!p2.isBlank())n++;
        if(playerExtras!=null)n+=playerExtras.size(); return n;
    }
    private void capturePendingRateHand(){pendingPlayerCardCount=currentSinglePlayerCardCount();pendingHand=currentHand;}
    private void recordLiveRate(String outcome){
        if(!"PERSONAL".equalsIgnoreCase(liveMode)||pendingHand<=0||pendingHand==lastRateHand)return;
        int c=pendingPlayerCardCount; if(c<3)return;
        boolean win="W".equalsIgnoreCase(outcome)||"WIN".equalsIgnoreCase(outcome);
        if(c==3){live3Resolved++;if(win)live3Wins++;}else if(c>=4){live4Resolved++;if(win)live4Wins++;}
        lastRateHand=pendingHand;refreshLiveRates();
    }
    private void refreshLiveRates(){
        liveThreeRate.setText(live3Resolved==0?"—":String.format(Locale.ROOT,"%.1f%%",100.0*live3Wins/live3Resolved));
        liveFourRate.setText(live4Resolved==0?"—":String.format(Locale.ROOT,"%.1f%%",100.0*live4Wins/live4Resolved));
        liveThreeCount.setText(live3Resolved==0?"0 resolved":live3Wins+" wins / "+live3Resolved+" resolved");
        liveFourCount.setText(live4Resolved==0?"0 resolved":live4Wins+" wins / "+live4Resolved+" resolved");
    }
    @Override
    void captureRecentPlayerExperience(){
        // Preserve the authoritative inherited history capture, then add one compact
        // descriptive row value for the just-completed hand.  Splits and hands
        // outside the 3 / 4+ comparison are deliberately shown as an em dash.
        int cards=currentSinglePlayerCardCount();
        boolean wasSplit=splitActive;
        super.captureRecentPlayerExperience();
        if(recentJourney==null||recentJourney.isEmpty())return;
        int i=recentJourney.size()-1;
        String row=recentJourney.get(i);
        boolean won=row!=null && row.toUpperCase(Locale.ROOT).contains("WIN");
        String taken=(!won || wasSplit)?"—":(cards==3?"3":(cards>=4?"4+":"—"));
        if(row!=null&&!row.contains("PLAYER CARDS TAKEN")){
            recentJourney.set(i,row+" | PLAYER CARDS TAKEN "+taken);
            journeyBadge.showItems(recentJourney);
        }
        decorateJourneyCardCounts();
    }
    private void decorateJourneyCardCounts(){
        // Give the inherited Recent Player Experience strip a little more vertical
        // room, then add a dedicated fourth/bottom row: 3 / 4+ / —.
        Dimension pref=journeyBadge.getPreferredSize();
        int width=(pref==null||pref.width<=0)?1000:pref.width;
        journeyBadge.setPreferredSize(new Dimension(width,70));
        journeyBadge.setMinimumSize(new Dimension(0,70));
        journeyBadge.setMaximumSize(new Dimension(Integer.MAX_VALUE,70));
        // Small left-side key so the bottom-row values are self-explanatory in Live play.
        for(Component c:journeyBadge.getComponents()){
            if(c instanceof JLabel){
                JLabel l=(JLabel)c;
                String t=l.getText();
                if(t!=null && t.toUpperCase(Locale.ROOT).contains("RECENT PLAYER EXPERIENCE")){
                    l.setText("<html><b>RECENT PLAYER EXPERIENCE</b><br><br><br><span style='font-size:8px'>NUMBER PLAYER CARDS ON WIN</span></html>");
                    l.setToolTipText("Bottom row shows player-card count on wins only: 3, 4+, or — otherwise");
                    break;
                }
            }
        }
        int rowIndex=0;
        for(Component c:journeyBadge.getComponents()){
            if(!(c instanceof JPanel))continue;
            if(rowIndex>=recentJourney.size())break;
            String src=recentJourney.get(rowIndex++);
            String taken="—";
            int p=src==null?-1:src.lastIndexOf("PLAYER CARDS TAKEN ");
            if(p>=0)taken=src.substring(p+19).trim();
            JPanel hand=(JPanel)c;
            // Remove any prior card-count row before re-decoration.
            for(int j=hand.getComponentCount()-1;j>=3;j--)hand.remove(j);
            JLabel cardsTaken=new JLabel(taken,SwingConstants.CENTER);
            cardsTaken.setForeground(new Color(30,105,170));
            cardsTaken.setFont(new Font("SansSerif",Font.BOLD,10));
            cardsTaken.setAlignmentX(Component.CENTER_ALIGNMENT);
            cardsTaken.setToolTipText("Number of player cards on a winning hand: 3, 4+, or — otherwise");
            hand.add(cardsTaken);
        }
        journeyBadge.revalidate();journeyBadge.repaint();
    }

    private void installLiveRateCapture(){
        MouseAdapter pre=new MouseAdapter(){@Override public void mousePressed(MouseEvent e){capturePendingRateHand();}};
        win.addMouseListener(pre);loss.addMouseListener(pre);push.addMouseListener(pre);
        win.addActionListener(e->recordLiveRate("W"));loss.addActionListener(e->recordLiveRate("L"));push.addActionListener(e->recordLiveRate("P"));
        terminalInput.addKeyListener(new KeyAdapter(){@Override public void keyPressed(KeyEvent e){if(e.getKeyCode()==KeyEvent.VK_ENTER){String x=terminalInput.getText().trim().toUpperCase(Locale.ROOT);if(x.equals("W")||x.equals("WIN")||x.equals("L")||x.equals("LOSS")||x.equals("P")||x.equals("PUSH")){capturePendingRateHand();String o=x.substring(0,1);SwingUtilities.invokeLater(()->recordLiveRate(o));}}}});
    }

    private void replaceVisibleVersionText(Component c) {
        if (c instanceof JLabel) {
            JLabel l = (JLabel)c;
            String t = l.getText();
            if (t != null) l.setText(t.replace("15.10.15-UAT", UI_VERSION).replace("15.10.39", "15.10.42").replace("15.10.40", "15.10.42"));
        } else if (c instanceof AbstractButton) {
            AbstractButton b = (AbstractButton)c;
            String t = b.getText();
            if (t != null) b.setText(t.replace("15.10.15-UAT", UI_VERSION).replace("15.10.39", "15.10.42").replace("15.10.40", "15.10.42"));
        }
        if (c instanceof Container) {
            for (Component child : ((Container)c).getComponents()) replaceVisibleVersionText(child);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new BlackjackLiveGuiUAT().setVisible(true));
    }
}
