package dev.photosync.ui.screen;

import dev.photosync.core.config.AutoCaptureSettings;
import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.config.ConfigLocation;
import dev.photosync.core.config.NotificationSettings;
import dev.photosync.core.config.PhotoSyncConfig;
import dev.photosync.core.config.UploadSettings;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderConnection;
import dev.photosync.core.provider.ProviderDescriptor;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderId;
import dev.photosync.core.provider.ProviderIdentity;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import dev.photosync.ui.widget.Slider;
import dev.photosync.ui.widget.TextField;
import dev.photosync.ui.widget.Toggle;
import dev.photosync.ui.widget.Widget;
import dev.photosync.ui.widget.WidgetList;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;

/**
 * Everything the player configures, in one scrolling column.
 *
 * <p>Edits go into a draft rather than straight into the live config, for two
 * reasons. Typing a server URL a character at a time would otherwise rebuild the
 * HTTP client on every keystroke; and "test connection" has to be able to try
 * credentials that have not been committed yet. The draft is applied when the
 * player leaves the screen, which is stated in the footer -- there is no way to
 * make an edit and quietly lose it.
 *
 * <p>The controls read and write the draft through lambdas instead of holding
 * their own copies, so the draft stays the single source of truth even when
 * something else -- reverting, or picking an album on another screen -- changes
 * it underneath them.
 */
public final class SettingsScreen extends PhotoSyncScreen {

    /** One line of the form. A null widget makes it a section heading. */
    private static final class Row {

        private final String labelKey;
        private final Widget widget;
        private final boolean fullWidth;
        private int top;
        private int height;

        private Row(String labelKey, Widget widget, boolean fullWidth) {
            this.labelKey = labelKey;
            this.widget = widget;
            this.fullWidth = fullWidth;
        }

        private boolean isHeading() {
            return widget == null;
        }
    }

    private final ScrollModel scroll;
    private final WidgetList content = new WidgetList();
    private final List<Row> rows = new ArrayList<>();

    private Rect viewport = Rect.EMPTY;
    private int measuredLineHeight = -1;

    private Button save;
    private Button revert;
    private Button test;
    private volatile String testStatus = "";

    public SettingsScreen(PhotoSyncUi ui) {
        super(ui);
        this.scroll = new ScrollModel(ui.chrome());
    }

    @Override
    protected Tab tab() {
        return Tab.SETTINGS;
    }

    private PhotoSyncConfig draft() {
        return ui.draft();
    }

    // -----------------------------------------------------------------------
    // Building the form
    // -----------------------------------------------------------------------

    @Override
    protected void layoutBody(Rect area) {
        int gap = theme().gap();
        Rect actions = area.bottom(theme().controlHeight());
        viewport = area.dropBottom(theme().controlHeight() + gap);
        scroll.viewport(viewport);

        int half = (actions.width() - gap) / 2;
        save = widgets.add(new Button(chrome, chrome.translate("photosync.settings.save"), ui::applyDraft));
        save.emphasized(Button.Emphasis.PRIMARY).bounds(actions.left(half));
        revert = widgets.add(new Button(chrome, chrome.translate("photosync.settings.revert"), () -> {
            ui.resetDraft();
            rebuild();
        }));
        revert.bounds(actions.right(half));

        rebuild();
    }

    /** Rebuilds every control from scratch, which is also how "revert" takes effect. */
    private void rebuild() {
        content.clear();
        rows.clear();
        measuredLineHeight = -1;

        buildStorage();
        buildConnection();
        buildUpload();
        buildAutoCapture();
        buildNotifications();
        buildBrowser();
    }

    private void buildStorage() {
        heading("photosync.settings.section.storage");
        Toggle outsideGame = new Toggle(chrome, chrome.translate("photosync.settings.user_config_location"),
                () -> draft().location() == ConfigLocation.USER,
                value -> edit(config -> config.toBuilder()
                        .location(value ? ConfigLocation.USER : ConfigLocation.INSTANCE)
                        .build()));
        outsideGame.describedAs(chrome.translate("photosync.settings.user_config_location.detail",
                ui.core().locations().directory(ConfigLocation.USER)));
        rows.add(new Row("photosync.settings.user_config_location", content.add(outsideGame), true));
        control("photosync.settings.config_folder",
                new Button(chrome, chrome.translate("photosync.settings.show_folder"), this::revealConfigFolder));
    }

    private void revealConfigFolder() {
        ui.game().reveal(ui.core().dataDirectory());
    }

    private void buildConnection() {
        heading("photosync.settings.section.connection");

        List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
        ProviderDescriptor descriptor = ui.core().catalog().describe(draft().provider()).orElse(providers.get(0));
        if (providers.size() > 1) {
            // Only worth a control when there is something to choose between.
            control("photosync.settings.provider", new Button(chrome, chrome.translate(descriptor.nameKey()),
                    this::cycleProvider));
        }

        TextField endpoint = new TextField(chrome, ui.bridge().clipboard(),
                value -> edit(config -> config.toBuilder()
                        .connection(new ProviderConnection(value, config.connection().secret()))
                        .build()));
        endpoint.reset(draft().connection().endpoint());
        endpoint.hint(chrome.translate(descriptor.endpointHintKey()));
        control(descriptor.endpointKey(), endpoint);

        TextField secret = new TextField(chrome, ui.bridge().clipboard(),
                value -> edit(config -> config.toBuilder()
                        .connection(new ProviderConnection(config.connection().endpoint(), value))
                        .build()));
        secret.reset(draft().connection().secret());
        secret.hint(chrome.translate(descriptor.secretHintKey()));
        secret.masked(true);
        control(descriptor.secretKey(), secret);

        test = new Button(chrome, chrome.translate("photosync.settings.test"), this::testConnection);
        control("photosync.settings.connection_state", test);

        if (descriptor.supportsAlbums()) {
            control("photosync.settings.album",
                    new Button(chrome, ui.albumLabel(), () -> ui.openAlbumPicker()));
        }
    }

    private void buildUpload() {
        heading("photosync.settings.section.upload");
        toggle("photosync.settings.upload_on_capture", "photosync.settings.upload_on_capture.detail",
                () -> draft().upload().uploadOnCapture(),
                value -> upload(settings -> settings.toBuilder().uploadOnCapture(value).build()));
        control("photosync.settings.concurrency", new Slider(chrome,
                () -> draft().upload().concurrency(),
                value -> upload(settings -> settings.toBuilder().concurrency(value).build()),
                1, UploadSettings.MAX_CONCURRENCY, String::valueOf));
        control("photosync.settings.attempts", new Slider(chrome,
                () -> draft().upload().maxAttempts(),
                value -> upload(settings -> settings.toBuilder().maxAttempts(value).build()),
                1, 20, String::valueOf));
        control("photosync.settings.backoff", new Slider(chrome,
                () -> draft().upload().retryBackoffSeconds(),
                value -> upload(settings -> settings.toBuilder().retryBackoffSeconds(value).build()),
                1, 300, this::formatSeconds));
        toggle("photosync.settings.wait_on_quit", "photosync.settings.wait_on_quit.detail",
                () -> draft().upload().waitOnQuit(),
                value -> upload(settings -> settings.toBuilder().waitOnQuit(value).build()));
        toggle("photosync.settings.delete_local", "photosync.settings.delete_local.detail",
                () -> draft().upload().deleteLocalAfterUpload(),
                value -> upload(settings -> settings.toBuilder().deleteLocalAfterUpload(value).build()));
    }

    private void buildAutoCapture() {
        heading("photosync.settings.section.auto_capture");
        toggle("photosync.settings.auto_capture", "photosync.settings.auto_capture.detail",
                () -> draft().autoCapture().enabled(),
                value -> autoCapture(settings -> settings.toBuilder().enabled(value).build()));
        control("photosync.settings.interval", new Slider(chrome,
                () -> draft().autoCapture().intervalSeconds(),
                value -> autoCapture(settings -> settings.toBuilder().intervalSeconds(value).build()),
                AutoCaptureSettings.MIN_INTERVAL_SECONDS, AutoCaptureSettings.MAX_INTERVAL_SECONDS,
                this::formatSeconds));

        TextField suffix = new TextField(chrome, ui.bridge().clipboard(),
                value -> autoCapture(settings -> settings.toBuilder().fileNameSuffix(value).build()));
        suffix.reset(draft().autoCapture().fileNameSuffix());
        suffix.hint("_auto");
        suffix.maxLength(24);
        control("photosync.settings.suffix", suffix);

        toggle("photosync.settings.only_in_world", "",
                () -> draft().autoCapture().onlyInWorld(),
                value -> autoCapture(settings -> settings.toBuilder().onlyInWorld(value).build()));
        toggle("photosync.settings.skip_when_screen_open", "",
                () -> draft().autoCapture().skipWhenScreenOpen(),
                value -> autoCapture(settings -> settings.toBuilder().skipWhenScreenOpen(value).build()));
        toggle("photosync.settings.skip_when_still", "photosync.settings.skip_when_still.detail",
                () -> draft().autoCapture().skipWhenStill(),
                value -> autoCapture(settings -> settings.toBuilder().skipWhenStill(value).build()));
    }

    private void buildNotifications() {
        heading("photosync.settings.section.notifications");
        toggle("photosync.settings.notify", "photosync.settings.notify.detail",
                () -> draft().notifications().enabled(),
                value -> notifications(settings -> settings.toBuilder().enabled(value).build()));
        toggle("photosync.settings.notify_capture", "",
                () -> draft().notifications().onCapture(),
                value -> notifications(settings -> settings.toBuilder().onCapture(value).build()));
        toggle("photosync.settings.notify_uploaded", "",
                () -> draft().notifications().onUploaded(),
                value -> notifications(settings -> settings.toBuilder().onUploaded(value).build()));
        toggle("photosync.settings.notify_failed", "",
                () -> draft().notifications().onFailed(),
                value -> notifications(settings -> settings.toBuilder().onFailed(value).build()));
        control("photosync.settings.linger", new Slider(chrome,
                () -> draft().notifications().lingerMillis(),
                value -> notifications(settings -> settings.toBuilder().lingerMillis(value).build()),
                500, 15_000, millis -> String.format(Locale.ROOT, "%.1fs", millis / 1000.0)));
    }

    private void buildBrowser() {
        heading("photosync.settings.section.browser");
        control("photosync.settings.tile_size", new Slider(chrome,
                () -> draft().browser().tileSize(),
                value -> browser(settings -> settings.toBuilder().tileSize(value).build()),
                48, 192, value -> value + " px"));
        control("photosync.settings.cache", new Slider(chrome,
                () -> draft().browser().thumbnailCacheEntries(),
                value -> browser(settings -> settings.toBuilder().thumbnailCacheEntries(value).build()),
                32, 2048, String::valueOf));
        toggle("photosync.settings.video_badge", "",
                () -> draft().browser().showVideoBadge(),
                value -> browser(settings -> settings.toBuilder().showVideoBadge(value).build()));
    }

    // -----------------------------------------------------------------------
    // Row helpers
    // -----------------------------------------------------------------------

    private void heading(String key) {
        rows.add(new Row(key, null, true));
    }

    private void control(String labelKey, Widget widget) {
        rows.add(new Row(labelKey, content.add(widget), false));
    }

    private void toggle(String labelKey, String detailKey, BooleanSupplier reader, Consumer<Boolean> writer) {
        Toggle widget = new Toggle(chrome, chrome.translate(labelKey), reader, writer);
        if (!detailKey.isEmpty()) {
            widget.describedAs(chrome.translate(detailKey));
        }
        rows.add(new Row(labelKey, content.add(widget), true));
    }

    // -----------------------------------------------------------------------
    // Draft edits
    // -----------------------------------------------------------------------

    private void edit(UnaryOperator<PhotoSyncConfig> change) {
        ui.draft(change.apply(draft()));
    }

    private void upload(UnaryOperator<UploadSettings> change) {
        edit(config -> config.toBuilder().upload(change.apply(config.upload())).build());
    }

    private void autoCapture(UnaryOperator<AutoCaptureSettings> change) {
        edit(config -> config.toBuilder().autoCapture(change.apply(config.autoCapture())).build());
    }

    private void notifications(UnaryOperator<NotificationSettings> change) {
        edit(config -> config.toBuilder().notifications(change.apply(config.notifications())).build());
    }

    private void browser(UnaryOperator<BrowserSettings> change) {
        edit(config -> config.toBuilder().browser(change.apply(config.browser())).build());
    }

    private void cycleProvider() {
        List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
        ProviderId current = draft().provider();
        int index = 0;
        for (int i = 0; i < providers.size(); i++) {
            if (providers.get(i).id().equals(current)) {
                index = i;
            }
        }
        ProviderId next = providers.get((index + 1) % providers.size()).id();
        edit(config -> config.toBuilder().provider(next).build());
        // The next provider labels its credentials differently, so the whole
        // connection section has to be built again.
        rebuild();
    }

    /**
     * Tries the draft's credentials without committing them, on a worker so a
     * server that is down does not freeze the screen.
     */
    private void testConnection() {
        ProviderId id = draft().provider();
        ProviderConnection connection = draft().connection();
        if (!connection.isConfigured()) {
            testStatus = chrome.translate("photosync.settings.test.incomplete");
            return;
        }
        testStatus = chrome.translate("photosync.settings.test.running");
        test.enabled(false);
        CompletableFuture.runAsync(() -> {
            try (PhotoProvider probe = ui.core().session().probe(id, connection)) {
                ProviderIdentity identity = probe.identify();
                testStatus = chrome.translate("photosync.settings.test.ok",
                        identity.accountName(), identity.serverVersion());
            } catch (ProviderException e) {
                testStatus = chrome.translate("photosync.settings.test.failed", e.getMessage());
            } catch (RuntimeException e) {
                testStatus = chrome.translate("photosync.settings.test.failed", e.toString());
            }
        }).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
    }

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

    @Override
    protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
        measure(render);
        scroll.advance(System.currentTimeMillis());
        save.enabled(ui.draftIsDirty());
        revert.enabled(ui.draftIsDirty());

        chrome.well(render, viewport);
        int labelWidth = Math.min(130, viewport.width() * 45 / 100);
        int usable = viewport.width() - scroll.gutter();

        render.pushClip(viewport.x(), viewport.y(), viewport.width(), viewport.height());
        for (Row row : rows) {
            int y = viewport.y() + row.top - scroll.offset();
            boolean onScreen = y + row.height >= viewport.y() && y <= viewport.bottom();
            if (row.isHeading()) {
                if (onScreen) {
                    renderHeading(render, row, y, usable);
                }
                continue;
            }
            row.widget.visible(onScreen);
            if (!onScreen) {
                continue;
            }
            if (row.fullWidth) {
                row.widget.bounds(new Rect(viewport.x() + 6, y + 2, usable - 12, row.height - 4));
            } else {
                chrome.fitted(render, chrome.translate(row.labelKey),
                        new Rect(viewport.x() + 6, y, labelWidth - 8, row.height), theme().textMuted());
                row.widget.bounds(new Rect(viewport.x() + labelWidth, y + 3,
                        usable - labelWidth - 6, theme().controlHeight()));
            }
        }
        content.render(render, mouseX, mouseY);
        render.popClip();

        scroll.render(render, mouseX, mouseY);
    }

    private void renderHeading(RenderBridge render, Row row, int y, int usable) {
        int baseline = y + row.height - render.lineHeight() - 3;
        chrome.label(render, chrome.translate(row.labelKey), viewport.x() + 4, baseline, theme().accent());
        chrome.divider(render, viewport.x() + 4, y + row.height - 2, usable - 8);
    }

    /**
     * Heights depend on the font, which is only knowable at render time -- a
     * toggle with a description is two lines tall and one without is one.
     */
    private void measure(RenderBridge render) {
        if (measuredLineHeight == render.lineHeight()) {
            return;
        }
        measuredLineHeight = render.lineHeight();
        int y = 0;
        for (Row row : rows) {
            row.height = heightOf(row, render);
            row.top = y;
            y += row.height;
        }
        scroll.contentHeight(y);
    }

    private int heightOf(Row row, RenderBridge render) {
        if (row.isHeading()) {
            return render.lineHeight() + 12;
        }
        if (row.widget instanceof Toggle toggle) {
            return toggle.preferredHeight(render) + 4;
        }
        return theme().controlHeight() + 6;
    }

    @Override
    protected String footerText() {
        if (!testStatus.isEmpty()) {
            return testStatus;
        }
        return chrome.translate(ui.draftIsDirty()
                ? "photosync.settings.dirty"
                : "photosync.settings.clean");
    }

    @Override
    protected int footerColour() {
        return ui.draftIsDirty() && testStatus.isEmpty() ? theme().warning() : theme().textFaint();
    }

    private String formatSeconds(int seconds) {
        if (seconds < 60) {
            return seconds + "s";
        }
        return seconds % 60 == 0
                ? seconds / 60 + "m"
                : String.format(Locale.ROOT, "%dm %ds", seconds / 60, seconds % 60);
    }

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

    @Override
    public boolean mouseClicked(double mouseX, double mouseY, int button) {
        if (scroll.mouseClicked(mouseX, mouseY, button)) {
            return true;
        }
        if (viewport.contains(mouseX, mouseY)) {
            return content.mouseClicked(mouseX, mouseY, button);
        }
        content.focus(null);
        return super.mouseClicked(mouseX, mouseY, button);
    }

    @Override
    public boolean mouseReleased(double mouseX, double mouseY, int button) {
        boolean handled = scroll.mouseReleased();
        handled |= content.mouseReleased(mouseX, mouseY, button);
        return handled || super.mouseReleased(mouseX, mouseY, button);
    }

    @Override
    public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
        if (scroll.mouseDragged(mouseX, mouseY, button)
                || content.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
            return true;
        }
        return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
    }

    @Override
    public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
        // The control under the cursor gets first refusal, so a slider can be
        // nudged by the wheel without the page moving underneath it.
        if (viewport.contains(mouseX, mouseY) && content.mouseScrolled(mouseX, mouseY, amount)) {
            return true;
        }
        return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
                || super.mouseScrolled(mouseX, mouseY, amount);
    }

    @Override
    public boolean keyPressed(int key, int scanCode, int modifiers) {
        return content.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
    }

    @Override
    public boolean charTyped(char character, int modifiers) {
        return content.charTyped(character, modifiers) || super.charTyped(character, modifiers);
    }

    /** Escape leaves the screen; the draft is applied on the way out. */
    @Override
    public boolean closeOnEscape() {
        return !content.hasFocus();
    }

    @Override
    public void closed() {
        ui.applyDraft();
    }
}
