package dev.photosync.ui.widget;

import dev.photosync.mcapi.Clipboard;
import dev.photosync.mcapi.Keys;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

import java.util.function.Consumer;

/**
 * A single line of editable text.
 *
 * <p>Paste is the reason this widget is as complete as it is. An Immich API key
 * is sixty-odd random characters that nobody types by hand, so Control-V --
 * Command-V on macOS -- has to work, and once you have paste you need a
 * selection to paste over, and once you have a selection you need shift-arrow
 * and Control-A to make one.
 *
 * <p>{@code masked} draws asterisks. It is display-only: the value is still the
 * real string, and the widget offers no "reveal" toggle because the player can
 * always paste it somewhere they control.
 */
@Accessors(fluent = true)
public final class TextField extends Widget {

    private final Clipboard clipboard;
    private final Consumer<String> onChange;

    @Getter
    private String value = "";
    private int cursor;
    private int selectionAnchor;
    private int scrollX;

    @Setter
    private String hint = "";

    @Setter
    private boolean masked;

    @Setter
    private int maxLength = 512;

    public TextField(Chrome chrome, Clipboard clipboard, Consumer<String> onChange) {
        super(chrome);
        this.clipboard = clipboard;
        this.onChange = onChange;
    }

    /** Replaces the contents without notifying {@code onChange} -- for loading a draft in. */
    public void reset(String newValue) {
        this.value = newValue == null ? "" : newValue;
        this.cursor = this.value.length();
        this.selectionAnchor = this.cursor;
        this.scrollX = 0;
    }

    @Override
    public boolean focusable() {
        return true;
    }

    @Override
    public void focus(boolean value) {
        super.focus(value);
        if (!value) {
            selectionAnchor = cursor;
        }
    }

    // -----------------------------------------------------------------------
    // Rendering
    // -----------------------------------------------------------------------

    @Override
    public void render(RenderBridge render, int mouseX, int mouseY) {
        boolean hovered = hovering(mouseX, mouseY);
        render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), theme().surfaceSunken());
        render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(),
                focused() ? theme().accent() : hovered ? theme().scrollThumbHover() : theme().panelBorder());

        int innerX = bounds().x() + 4;
        int innerWidth = bounds().width() - 8;
        int baseline = bounds().y() + (bounds().height() - render.lineHeight()) / 2 + 1;

        if (value.isEmpty() && !focused()) {
            render.text(chrome.elide(render, hint, innerWidth), innerX, baseline, theme().textFaint(), false);
            return;
        }

        String shown = displayed();
        keepCursorVisible(render, innerWidth);

        render.pushClip(innerX, bounds().y() + 1, innerWidth, bounds().height() - 2);
        int textX = innerX - scrollX;

        if (hasSelection()) {
            int from = Math.min(cursor, selectionAnchor);
            int to = Math.max(cursor, selectionAnchor);
            int selectionStart = textX + render.textWidth(shown.substring(0, from));
            int selectionWidth = render.textWidth(shown.substring(from, to));
            render.fill(selectionStart, bounds().y() + 2, selectionWidth, bounds().height() - 4,
                    theme().fade(theme().accent(), 0.45f));
        }

        render.text(shown, textX, baseline, enabled() ? theme().text() : theme().textFaint(), false);

        // 530ms is close enough to the blink rate everything else uses that it
        // reads as a cursor rather than as something being wrong.
        if (focused() && (System.currentTimeMillis() / 530) % 2 == 0) {
            int caretX = textX + render.textWidth(shown.substring(0, cursor));
            render.fill(caretX, bounds().y() + 3, 1, bounds().height() - 6, theme().text());
        }
        render.popClip();
    }

    private void keepCursorVisible(RenderBridge render, int innerWidth) {
        String shown = displayed();
        int caretX = render.textWidth(shown.substring(0, cursor));
        if (caretX - scrollX > innerWidth - 2) {
            scrollX = caretX - innerWidth + 2;
        }
        if (caretX - scrollX < 0) {
            scrollX = caretX;
        }
        int total = render.textWidth(shown);
        scrollX = Math.max(0, Math.min(scrollX, Math.max(0, total - innerWidth + 2)));
    }

    private String displayed() {
        return masked ? "*".repeat(value.length()) : value;
    }

    // -----------------------------------------------------------------------
    // Input
    // -----------------------------------------------------------------------

    @Override
    public boolean mouseClicked(double mouseX, double mouseY, int button) {
        if (button != 0 || !hovering(mouseX, mouseY)) {
            return false;
        }
        // Focus is enough to place the caret sensibly for these fields, which
        // hold a URL or a key rather than prose. Click-to-position would need a
        // font metric per character and buys very little here.
        cursor = value.length();
        selectionAnchor = 0;
        return true;
    }

    @Override
    public boolean keyPressed(int key, int scanCode, int modifiers) {
        if (!focused() || !enabled()) {
            return false;
        }
        if (Keys.shortcut(modifiers)) {
            return shortcut(key);
        }
        return switch (key) {
            case Keys.BACKSPACE -> {
                if (hasSelection()) {
                    deleteSelection();
                } else if (cursor > 0) {
                    replace(cursor - 1, cursor, "");
                }
                yield true;
            }
            case Keys.DELETE -> {
                if (hasSelection()) {
                    deleteSelection();
                } else if (cursor < value.length()) {
                    replace(cursor, cursor + 1, "");
                }
                yield true;
            }
            case Keys.LEFT -> {
                moveTo(Math.max(0, cursor - 1), Keys.shift(modifiers));
                yield true;
            }
            case Keys.RIGHT -> {
                moveTo(Math.min(value.length(), cursor + 1), Keys.shift(modifiers));
                yield true;
            }
            case Keys.HOME -> {
                moveTo(0, Keys.shift(modifiers));
                yield true;
            }
            case Keys.END -> {
                moveTo(value.length(), Keys.shift(modifiers));
                yield true;
            }
            // Swallowed so the screen does not also act on them while typing.
            case Keys.ESCAPE, Keys.TAB -> false;
            default -> Keys.confirms(key);
        };
    }

    private boolean shortcut(int key) {
        switch (key) {
            case Keys.A -> {
                selectionAnchor = 0;
                cursor = value.length();
                return true;
            }
            case Keys.C -> {
                if (hasSelection() && !masked) {
                    clipboard.write(selectedText());
                }
                return true;
            }
            case Keys.X -> {
                if (hasSelection()) {
                    if (!masked) {
                        clipboard.write(selectedText());
                    }
                    deleteSelection();
                }
                return true;
            }
            case Keys.V -> {
                insert(clipboard.read());
                return true;
            }
            default -> {
                return false;
            }
        }
    }

    @Override
    public boolean charTyped(char character, int modifiers) {
        if (!focused() || !enabled() || character < ' ' || character == 127) {
            return false;
        }
        insert(String.valueOf(character));
        return true;
    }

    // -----------------------------------------------------------------------
    // Editing
    // -----------------------------------------------------------------------

    private void insert(String text) {
        if (text == null || text.isEmpty()) {
            return;
        }
        // Newlines and control characters arrive via paste and would render as
        // boxes; a pasted key with a trailing newline is the common case.
        String clean = text.replaceAll("[\\p{Cntrl}]", "");
        int from = Math.min(cursor, selectionAnchor);
        int to = Math.max(cursor, selectionAnchor);
        int room = maxLength - (value.length() - (to - from));
        if (room <= 0) {
            return;
        }
        replace(from, to, clean.length() > room ? clean.substring(0, room) : clean);
    }

    private void deleteSelection() {
        replace(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor), "");
    }

    private void replace(int from, int to, String replacement) {
        value = value.substring(0, from) + replacement + value.substring(to);
        cursor = from + replacement.length();
        selectionAnchor = cursor;
        onChange.accept(value);
    }

    private void moveTo(int position, boolean extendSelection) {
        cursor = position;
        if (!extendSelection) {
            selectionAnchor = cursor;
        }
    }

    private boolean hasSelection() {
        return cursor != selectionAnchor;
    }

    private String selectedText() {
        return value.substring(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor));
    }
}
