package thb.jeanluc.adventure.io;

import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;

import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
 * a styled output pane (centre), a side panel for exits / future map (right),
 * and an input field (bottom). A bundled TrueType font is loaded if present so
 * colours and glyphs work without the player installing anything.
 *
 * <p>Font sizes are multiplied by a detected display scale (HiDPI) and by a
 * user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so
 * text stays readable on high-DPI laptops even when the JVM does not pick up
 * the desktop scaling factor.</p>
 */
public class SwingIO implements GameIO {

    /** Unscaled base size for the main output/input font, in points. */
    private static final float BASE_SIZE = 15f;

    /** Unscaled size for the HUD and side-panel font, in points. */
    private static final float SMALL_SIZE = 12.5f;

    /** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
    private static final long TRAVEL_STEP_MS = 300L;

    /** Side-panel width as a fraction of the window width, with a lower bound only. */
    private static final double SIDE_RATIO = 0.34;
    private static final int SIDE_MIN = 220;

    private final MusicController music = new MusicController(new OggMusicBackend());
    private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
    private final JFrame frame;
    private final JTextPane output;
    private final StyledDocument doc;
    private final JTextField input;
    private final JLabel hud;
    private final MapPanel map;
    private final JScrollPane sideScroll;
    private final QuestPanel quests;
    private final JScrollPane questScroll;
    private final JPanel sidePanel;
    private final Font baseFont;

    /** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
    private final float uiScale;

    /** User zoom multiplier, adjusted live via keyboard. */
    private float zoom = 1f;

    public SwingIO(String title) {
        baseFont = loadFont();
        uiScale = detectScale();

        output = new JTextPane();
        output.setEditable(false);
        output.setBackground(new Color(0x0b, 0x0e, 0x13));
        output.setForeground(new Color(0xcf, 0xd6, 0xe0));
        output.setMargin(new Insets(8, 8, 8, 8));
        doc = output.getStyledDocument();

        hud = new JLabel(" ");
        hud.setOpaque(true);
        hud.setBackground(new Color(0x11, 0x15, 0x1c));
        hud.setForeground(new Color(0x8b, 0x94, 0xa3));
        hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));

        map = new MapPanel(baseFont);
        sideScroll = new JScrollPane(map);
        sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));

        quests = new QuestPanel(baseFont);
        questScroll = new JScrollPane(quests);
        questScroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(0x2a, 0x2f, 0x3a)));

        sidePanel = new JPanel(new BorderLayout());
        sidePanel.add(sideScroll, BorderLayout.CENTER);
        sidePanel.add(questScroll, BorderLayout.SOUTH);

        input = new JTextField();
        input.addActionListener(e -> {
            String line = input.getText();
            input.setText("");
            appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
            inputs.offer(line);
        });

        frame = new JFrame(title);
        frame.setLayout(new BorderLayout());
        frame.add(hud, BorderLayout.NORTH);
        frame.add(new JScrollPane(output), BorderLayout.CENTER);
        frame.add(sidePanel, BorderLayout.EAST);
        frame.add(input, BorderLayout.SOUTH);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
        frame.setLocationRelativeTo(null);
        frame.addComponentListener(new ComponentAdapter() {
            @Override
            public void componentResized(ComponentEvent e) {
                updateSideWidth();
                applyFonts();
                rescaleMenuOverlayIfVisible();
            }
        });

        installZoomKeys();
        applyFonts();
        updateSideWidth();

        frame.setVisible(true);
        input.requestFocusInWindow();
    }

    /** Loads the bundled font if present, else falls back to a logical monospaced font. */
    private Font loadFont() {
        try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
            if (in != null) {
                Font f = Font.createFont(Font.TRUETYPE_FONT, in);
                GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
                return f;
            }
        } catch (Exception e) {
            // fall through to the logical monospaced fallback
        }
        return new Font(Font.MONOSPACED, Font.PLAIN, 12);
    }

    /** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
    private float detectScale() {
        try {
            GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
                    .getDefaultScreenDevice().getDefaultConfiguration();
            double transform = gc.getDefaultTransform().getScaleX();
            if (transform > 1.0) {
                return (float) transform;
            }
        } catch (Exception ignored) {
            // fall through to DPI heuristic
        }
        try {
            int dpi = Toolkit.getDefaultToolkit().getScreenResolution();
            return Math.max(1f, dpi / 96f);
        } catch (Exception ignored) {
            return 1f;
        }
    }

    /** Sizes the side panel to a fraction of the window width, with a scaled lower bound. */
    private void updateSideWidth() {
        int min = (int) (SIDE_MIN * uiScale);
        int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
        sidePanel.setPreferredSize(new Dimension(target, 0));
        questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
        sidePanel.revalidate();
        frame.validate();
        // The side panel changed size: let the map re-fit into it.
        map.revalidate();
        map.repaint();
    }

    /**
     * Auto-scale derived from the window size relative to the design size
     * (900x600). At the initial size this equals {@link #uiScale}; growing the
     * window grows the text, mirroring how the map fits its panel.
     */
    private float frameScale() {
        double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
        return (float) Math.max(0.6, Math.min(2.6, s));
    }

    /** Re-derives and applies all component fonts from the window auto-scale and zoom. */
    private void applyFonts() {
        float auto = frameScale();
        float main = BASE_SIZE * auto * zoom;
        float small = SMALL_SIZE * auto * zoom;
        output.setFont(baseFont.deriveFont(main));
        input.setFont(baseFont.deriveFont(main));
        hud.setFont(baseFont.deriveFont(small));
        quests.setFont(baseFont.deriveFont(small));
        map.setZoom(zoom);
        frame.revalidate();
        frame.repaint();
    }

    /** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */
    private void installZoomKeys() {
        JComponent root = frame.getRootPane();
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
        bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset");

        root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f)));
        root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.1f)));
        root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
    }

    private void bind(JComponent c, KeyStroke ks, String name) {
        c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
    }

    private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
        return new AbstractAction() {
            @Override
            public void actionPerformed(ActionEvent e) {
                body.accept(e);
            }
        };
    }

    private void setZoom(float z) {
        zoom = Math.max(0.6f, Math.min(3.0f, z));
        applyFonts();
    }

    private Color colorFor(Style s) {
        return switch (s) {
            case HEADING -> new Color(0xc9, 0x8a, 0xe0);
            case ITEM -> new Color(0x46, 0xc8, 0xd8);
            case NPC -> new Color(0xe6, 0xc3, 0x4a);
            case EXIT -> new Color(0x6f, 0xcf, 0x73);
            case DANGER -> new Color(0xe0, 0x6c, 0x6c);
            case DIM -> new Color(0x6b, 0x72, 0x80);
            case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
        };
    }

    private void appendSpans(List<Span> spans) {
        SwingUtilities.invokeLater(() -> {
            try {
                for (Span sp : spans) {
                    SimpleAttributeSet as = new SimpleAttributeSet();
                    StyleConstants.setForeground(as, colorFor(sp.style()));
                    if (sp.style() == Style.HEADING) {
                        StyleConstants.setBold(as, true);
                    }
                    doc.insertString(doc.getLength(), sp.text(), as);
                }
                output.setCaretPosition(doc.getLength());
            } catch (BadLocationException ignored) {
                // append-only; cannot occur with getLength()
            }
        });
    }

    @Override
    public String readLine() {
        try {
            return inputs.take();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "";
        }
    }

    @Override
    public void print(StyledText text) {
        List<Span> spans = new ArrayList<>(text.spans());
        spans.add(new Span("\n", Style.PLAIN));
        appendSpans(spans);
    }

    @Override
    public void showRoom(RoomView room) {
        List<Span> spans = new ArrayList<>();
        spans.add(new Span(room.name() + "\n", Style.HEADING));
        spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
        if (!room.items().isEmpty()) {
            spans.add(new Span("You see ", Style.PLAIN));
            addList(spans, room.items(), Style.ITEM);
            spans.add(new Span(".\n", Style.PLAIN));
        }
        if (!room.npcs().isEmpty()) {
            addList(spans, room.npcs(), Style.NPC);
            spans.add(new Span(" is here.\n", Style.PLAIN));
        }
        appendSpans(spans);
        // Exits are shown by the map panel, not as text.
    }

    @Override
    public void setHud(Hud h) {
        String txt = "  " + h.location() + "     gold " + h.gold() + "     turn " + h.turn()
                + "     light " + (h.lightOn() ? "on" : "off");
        SwingUtilities.invokeLater(() -> hud.setText(txt));
    }

    @Override
    public void setMusic(String track) {
        music.room(track);
    }

    @Override
    public void setMusicLevel(MusicLevel level) {
        music.level(level);
    }

    /**
     * Closes the GUI when the player quits from the main menu. Stops music,
     * disposes the window, and terminates the JVM — mirroring the native
     * window-close (EXIT_ON_CLOSE); without this the EDT keeps the process
     * alive and the dismissed menu overlay just reveals the game underneath.
     */
    @Override
    public void shutdown() {
        music.shutdown();
        SwingUtilities.invokeLater(() -> {
            frame.dispose();
            System.exit(0);
        });
    }

    @Override
    public void setMap(MapView view) {
        map.show(view);
    }

    @Override
    public void travelStep(MapView view) {
        setMap(view);
        try {
            Thread.sleep(TRAVEL_STEP_MS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void setQuests(QuestView view) {
        quests.show(view);
    }

    @Override
    public void showQuests(QuestView view) {
        // The GUI quest box is always visible; nothing extra to do.
    }

    @Override
    public void showMap(MapView view) {
        // The GUI map panel is always visible; nothing extra to do on the 'map' command.
    }

    @Override
    public int choose(String title, List<String> options) {
        // Full-window overlay (glass pane), not a floating dialog: it covers the game
        // chrome so no panels/outlines show behind the menu, and it fills + scales with
        // the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
        // a button click / Escape offers the chosen index.
        LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
        SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
        try {
            return picked.take();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return options.size() - 1;
        }
    }

    /** Current menu being shown, so a window resize can rebuild it at the new scale. */
    private String menuTitle;
    private List<String> menuOptions;
    private LinkedBlockingQueue<Integer> menuPicked;

    /** Rebuilds the visible menu overlay at the current window scale (called on resize). */
    private void rescaleMenuOverlayIfVisible() {
        if (menuOptions != null && frame.getGlassPane().isVisible()) {
            showMenuOverlay(menuTitle, menuOptions, menuPicked);
        }
    }

    /** Installs the menu as the frame's glass pane (covers everything) and shows it. */
    private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
        menuTitle = title;
        menuOptions = options;
        menuPicked = picked;
        float scale = frameScale() * zoom; // same scaling the rest of the UI uses

        JPanel glass = new JPanel(new GridBagLayout());
        glass.setOpaque(true);
        glass.setBackground(MENU_BG);
        glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks

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

        JLabel heading = new JLabel(title.toUpperCase());
        heading.setForeground(MENU_TITLE);
        heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
        heading.setAlignmentX(Component.CENTER_ALIGNMENT);
        heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
        card.add(heading);

        Runnable cancel = () -> {
            hideMenuOverlay();
            picked.offer(options.size() - 1);
        };
        for (int i = 0; i < options.size(); i++) {
            int idx = i;
            JButton b = menuButton(options.get(i), scale);
            b.addActionListener(e -> {
                hideMenuOverlay();
                picked.offer(idx);
            });
            card.add(b);
            if (i < options.size() - 1) {
                card.add(Box.createVerticalStrut((int) (10 * scale)));
            }
        }

        // Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
        glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
                .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
        glass.getActionMap().put("menuCancel", action(e -> cancel.run()));

        glass.add(card); // single child → centered by GridBagLayout
        frame.setGlassPane(glass);
        glass.setVisible(true);
        glass.requestFocusInWindow();
    }

    private void hideMenuOverlay() {
        menuOptions = null; // stop resize rebuilds once the menu is dismissed
        frame.getGlassPane().setVisible(false);
    }

    /** Palette for the themed menu overlay, matching the game window's dark look. */
    private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
    private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
    private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
    private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
    private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
    private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
    private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);

    /** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
    private JButton menuButton(String text, float scale) {
        int padV = (int) (11 * scale);
        int padH = (int) (18 * scale);
        JButton b = new JButton(text);
        b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
        b.setForeground(BTN_FG);
        b.setBackground(BTN_BG);
        b.setFocusPainted(false);
        b.setOpaque(true);
        b.setHorizontalAlignment(SwingConstants.LEFT);
        b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
        b.setAlignmentX(Component.CENTER_ALIGNMENT);
        b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
        Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
        b.setPreferredSize(size);
        b.setMinimumSize(size);
        b.setMaximumSize(size);
        b.addMouseListener(new java.awt.event.MouseAdapter() {
            @Override
            public void mouseEntered(java.awt.event.MouseEvent e) {
                b.setBackground(BTN_BG_HOVER);
                b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
            }

            @Override
            public void mouseExited(java.awt.event.MouseEvent e) {
                b.setBackground(BTN_BG);
                b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
            }
        });
        return b;
    }

    private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
        return BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(line, 1),
                BorderFactory.createEmptyBorder(padV, padH, padV, padH));
    }

    private void addList(List<Span> spans, List<String> xs, Style st) {
        for (int i = 0; i < xs.size(); i++) {
            if (i > 0) {
                spans.add(new Span(", ", Style.PLAIN));
            }
            spans.add(new Span(xs.get(i), st));
        }
    }
}
