package org.schabi.newpipe.player.helper;

import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
import static org.schabi.newpipe.player.Player.DEBUG;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable;

import android.app.Dialog;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
import android.widget.SeekBar;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.math.MathUtils;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;

import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.SliderStrategy;

import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.DoubleConsumer;
import java.util.function.DoubleFunction;
import java.util.function.DoubleSupplier;

import icepick.Icepick;
import icepick.State;

public class PlaybackParameterDialog extends DialogFragment {
    private static final String TAG = "PlaybackParameterDialog";

    // Minimum allowable range in ExoPlayer
    private static final double MIN_PITCH_OR_SPEED = 0.10f;
    private static final double MAX_PITCH_OR_SPEED = 3.00f;

    private static final boolean PITCH_CTRL_MODE_PERCENT = false;
    private static final boolean PITCH_CTRL_MODE_SEMITONE = true;

    private static final double STEP_1_PERCENT_VALUE = 0.01f;
    private static final double STEP_5_PERCENT_VALUE = 0.05f;
    private static final double STEP_10_PERCENT_VALUE = 0.10f;
    private static final double STEP_25_PERCENT_VALUE = 0.25f;
    private static final double STEP_100_PERCENT_VALUE = 1.00f;

    private static final double DEFAULT_TEMPO = 1.00f;
    private static final double DEFAULT_PITCH_PERCENT = 1.00f;
    private static final double DEFAULT_STEP = STEP_25_PERCENT_VALUE;
    private static final boolean DEFAULT_SKIP_SILENCE = false;

    private static final SliderStrategy QUADRATIC_STRATEGY = new SliderStrategy.Quadratic(
            MIN_PITCH_OR_SPEED,
            MAX_PITCH_OR_SPEED,
            1.00f,
            10_000);

    private static final SliderStrategy SEMITONE_STRATEGY = new SliderStrategy() {
        @Override
        public int progressOf(final double value) {
            return PlayerSemitoneHelper.percentToSemitones(value) + 12;
        }

        @Override
        public double valueOf(final int progress) {
            return PlayerSemitoneHelper.semitonesToPercent(progress - 12);
        }
    };

    @Nullable
    private Callback callback;

    @State
    double initialTempo = DEFAULT_TEMPO;
    @State
    double initialPitchPercent = DEFAULT_PITCH_PERCENT;
    @State
    boolean initialSkipSilence = DEFAULT_SKIP_SILENCE;

    @State
    double tempo = DEFAULT_TEMPO;
    @State
    double pitchPercent = DEFAULT_PITCH_PERCENT;
    @State
    boolean skipSilence = DEFAULT_SKIP_SILENCE;

    private DialogPlaybackParameterBinding binding;

    public static PlaybackParameterDialog newInstance(
            final double playbackTempo,
            final double playbackPitch,
            final boolean playbackSkipSilence,
            final Callback callback
    ) {
        final PlaybackParameterDialog dialog = new PlaybackParameterDialog();
        dialog.callback = callback;

        dialog.initialTempo = playbackTempo;
        dialog.initialPitchPercent = playbackPitch;
        dialog.initialSkipSilence = playbackSkipSilence;

        dialog.tempo = dialog.initialTempo;
        dialog.pitchPercent = dialog.initialPitchPercent;
        dialog.skipSilence = dialog.initialSkipSilence;

        return dialog;
    }

    /*//////////////////////////////////////////////////////////////////////////
    // Lifecycle
    //////////////////////////////////////////////////////////////////////////*/

    @Override
    public void onAttach(@NonNull final Context context) {
        super.onAttach(context);
        if (context instanceof Callback) {
            callback = (Callback) context;
        } else if (callback == null) {
            dismiss();
        }
    }

    @Override
    public void onSaveInstanceState(@NonNull final Bundle outState) {
        super.onSaveInstanceState(outState);
        Icepick.saveInstanceState(this, outState);
    }

    /*//////////////////////////////////////////////////////////////////////////
    // Dialog
    //////////////////////////////////////////////////////////////////////////*/

    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
        assureCorrectAppLanguage(getContext());
        Icepick.restoreInstanceState(this, savedInstanceState);

        binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());
        initUI();

        final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity())
                .setView(binding.getRoot())
                .setCancelable(true)
                .setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
                    setAndUpdateTempo(initialTempo);
                    setAndUpdatePitch(initialPitchPercent);
                    setAndUpdateSkipSilence(initialSkipSilence);
                    updateCallback();
                })
                .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> {
                    setAndUpdateTempo(DEFAULT_TEMPO);
                    setAndUpdatePitch(DEFAULT_PITCH_PERCENT);
                    setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE);
                    updateCallback();
                })
                .setPositiveButton(R.string.ok, (dialogInterface, i) -> updateCallback());

        return dialogBuilder.create();
    }

    /*//////////////////////////////////////////////////////////////////////////
    // UI Initialization and Control
    //////////////////////////////////////////////////////////////////////////*/

    private void initUI() {
        // Tempo
        setText(binding.tempoMinimumText, PlayerHelper::formatSpeed, MIN_PITCH_OR_SPEED);
        setText(binding.tempoMaximumText, PlayerHelper::formatSpeed, MAX_PITCH_OR_SPEED);

        binding.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED));
        setAndUpdateTempo(tempo);
        binding.tempoSeekbar.setOnSeekBarChangeListener(
                getTempoOrPitchSeekbarChangeListener(
                        QUADRATIC_STRATEGY,
                        this::onTempoSliderUpdated));

        registerOnStepClickListener(
                binding.tempoStepDown,
                () -> tempo,
                -1,
                this::onTempoSliderUpdated);
        registerOnStepClickListener(
                binding.tempoStepUp,
                () -> tempo,
                1,
                this::onTempoSliderUpdated);

        // Pitch
        binding.pitchToogleControlModes.setOnClickListener(v -> {
            final boolean isCurrentlyVisible =
                    binding.pitchControlModeTabs.getVisibility() == View.GONE;
            binding.pitchControlModeTabs.setVisibility(isCurrentlyVisible
                    ? View.VISIBLE
                    : View.GONE);
            animateRotation(binding.pitchToogleControlModes,
                    VideoPlayerUi.DEFAULT_CONTROLS_DURATION,
                    isCurrentlyVisible ? 180 : 0);
        });

        getPitchControlModeComponentMappings()
                .forEach(this::setupPitchControlModeTextView);
        // Initialization is done at the end

        // Pitch - Percent
        setText(binding.pitchPercentMinimumText, PlayerHelper::formatPitch, MIN_PITCH_OR_SPEED);
        setText(binding.pitchPercentMaximumText, PlayerHelper::formatPitch, MAX_PITCH_OR_SPEED);

        binding.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED));
        setAndUpdatePitch(pitchPercent);
        binding.pitchPercentSeekbar.setOnSeekBarChangeListener(
                getTempoOrPitchSeekbarChangeListener(
                        QUADRATIC_STRATEGY,
                        this::onPitchPercentSliderUpdated));

        registerOnStepClickListener(
                binding.pitchPercentStepDown,
                () -> pitchPercent,
                -1,
                this::onPitchPercentSliderUpdated);
        registerOnStepClickListener(
                binding.pitchPercentStepUp,
                () -> pitchPercent,
                1,
                this::onPitchPercentSliderUpdated);

        // Pitch - Semitone
        binding.pitchSemitoneSeekbar.setOnSeekBarChangeListener(
                getTempoOrPitchSeekbarChangeListener(
                        SEMITONE_STRATEGY,
                        this::onPitchPercentSliderUpdated));

        registerOnSemitoneStepClickListener(
                binding.pitchSemitoneStepDown,
                -1,
                this::onPitchPercentSliderUpdated);
        registerOnSemitoneStepClickListener(
                binding.pitchSemitoneStepUp,
                1,
                this::onPitchPercentSliderUpdated);

        // Steps
        getStepSizeComponentMappings()
                .forEach(this::setupStepTextView);
        // Initialize UI
        setStepSizeToUI(getCurrentStepSize());

        // Bottom controls
        bindCheckboxWithBoolPref(
                binding.unhookCheckbox,
                R.string.playback_unhook_key,
                true,
                isChecked -> {
                    if (!isChecked) {
                        // when unchecked, slide back to the minimum of current tempo or pitch
                        ensureHookIsValidAndUpdateCallBack();
                    }
                });

        setAndUpdateSkipSilence(skipSilence);
        binding.skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
            skipSilence = isChecked;
            updateCallback();
        });

        // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox
        changePitchControlMode(isCurrentPitchControlModeSemitone());
    }

    // -- General formatting --

    private void setText(
            final TextView textView,
            final DoubleFunction<String> formatter,
            final double value
    ) {
        Objects.requireNonNull(textView).setText(formatter.apply(value));
    }

    // -- Steps --

    private void registerOnStepClickListener(
            final TextView stepTextView,
            final DoubleSupplier currentValueSupplier,
            final double direction, // -1 for step down, +1 for step up
            final DoubleConsumer newValueConsumer
    ) {
        stepTextView.setOnClickListener(view -> {
            newValueConsumer.accept(
                    currentValueSupplier.getAsDouble() + 1 * getCurrentStepSize() * direction);
            updateCallback();
        });
    }

    private void registerOnSemitoneStepClickListener(
            final TextView stepTextView,
            final int direction, // -1 for step down, +1 for step up
            final DoubleConsumer newValueConsumer
    ) {
        stepTextView.setOnClickListener(view -> {
            newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent(
                    PlayerSemitoneHelper.percentToSemitones(this.pitchPercent) + direction));
            updateCallback();
        });
    }

    // -- Pitch --

    private void setupPitchControlModeTextView(
            final boolean semitones,
            final TextView textView
    ) {
        textView.setOnClickListener(view -> {
            PreferenceManager.getDefaultSharedPreferences(requireContext())
                    .edit()
                    .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones)
                    .apply();

            changePitchControlMode(semitones);
        });
    }

    private Map<Boolean, TextView> getPitchControlModeComponentMappings() {
        return Map.of(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent,
                PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone);
    }

    private void changePitchControlMode(final boolean semitones) {
        // Bring all textviews into a normal state
        final Map<Boolean, TextView> pitchCtrlModeComponentMapping =
                getPitchControlModeComponentMappings();
        pitchCtrlModeComponentMapping.forEach((v, textView) -> textView.setBackground(
                resolveDrawable(requireContext(), R.attr.selectableItemBackground)));

        // Mark the selected textview
        final TextView textView = pitchCtrlModeComponentMapping.get(semitones);
        if (textView != null) {
            textView.setBackground(new LayerDrawable(new Drawable[]{
                    resolveDrawable(requireContext(), R.attr.dashed_border),
                    resolveDrawable(requireContext(), R.attr.selectableItemBackground)
            }));
        }

        // Show or hide component
        binding.pitchPercentControl.setVisibility(semitones ? View.GONE : View.VISIBLE);
        binding.pitchSemitoneControl.setVisibility(semitones ? View.VISIBLE : View.GONE);

        if (semitones) {
            // Recalculate pitch percent when changing to semitone
            // (as it could be an invalid semitone value)
            final double newPitchPercent = calcValidPitch(pitchPercent);

            // If the values differ set the new pitch
            if (this.pitchPercent != newPitchPercent) {
                if (DEBUG) {
                    Log.d(TAG, "Bringing pitchPercent to correct corresponding semitone: "
                            + "currentPitchPercent = " + pitchPercent + ", "
                            + "newPitchPercent = " + newPitchPercent
                    );
                }
                this.onPitchPercentSliderUpdated(newPitchPercent);
                updateCallback();
            }
        } else if (!binding.unhookCheckbox.isChecked()) {
            // When changing to percent it's possible that tempo is != pitch
            ensureHookIsValidAndUpdateCallBack();
        }
    }

    private boolean isCurrentPitchControlModeSemitone() {
        return PreferenceManager.getDefaultSharedPreferences(requireContext())
                .getBoolean(
                        getString(R.string.playback_adjust_by_semitones_key),
                        PITCH_CTRL_MODE_PERCENT);
    }

    // -- Steps (Set) --

    private void setupStepTextView(
            final double stepSizeValue,
            final TextView textView
    ) {
        setText(textView, PlaybackParameterDialog::getPercentString, stepSizeValue);
        textView.setOnClickListener(view -> {
            PreferenceManager.getDefaultSharedPreferences(requireContext())
                    .edit()
                    .putFloat(getString(R.string.adjustment_step_key), (float) stepSizeValue)
                    .apply();

            setStepSizeToUI(stepSizeValue);
        });
    }

    private Map<Double, TextView> getStepSizeComponentMappings() {
        return Map.of(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent,
                STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent,
                STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent,
                STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent,
                STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent);
    }

    private void setStepSizeToUI(final double newStepSize) {
        // Bring all textviews into a normal state
        final Map<Double, TextView> stepSiteComponentMapping = getStepSizeComponentMappings();
        stepSiteComponentMapping.forEach((v, textView) -> textView.setBackground(
                resolveDrawable(requireContext(), R.attr.selectableItemBackground)));

        // Mark the selected textview
        final TextView textView = stepSiteComponentMapping.get(newStepSize);
        if (textView != null) {
            textView.setBackground(new LayerDrawable(new Drawable[]{
                    resolveDrawable(requireContext(), R.attr.dashed_border),
                    resolveDrawable(requireContext(), R.attr.selectableItemBackground)
            }));
        }

        // Bind to the corresponding control components
        binding.tempoStepUp.setText(getStepUpPercentString(newStepSize));
        binding.tempoStepDown.setText(getStepDownPercentString(newStepSize));

        binding.pitchPercentStepUp.setText(getStepUpPercentString(newStepSize));
        binding.pitchPercentStepDown.setText(getStepDownPercentString(newStepSize));
    }

    private double getCurrentStepSize() {
        return PreferenceManager.getDefaultSharedPreferences(requireContext())
                .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP);
    }

    // -- Additional options --

    private void setAndUpdateSkipSilence(final boolean newSkipSilence) {
        this.skipSilence = newSkipSilence;
        binding.skipSilenceCheckbox.setChecked(newSkipSilence);
    }

    @SuppressWarnings("SameParameterValue") // this method was written to be reusable
    private void bindCheckboxWithBoolPref(
            @NonNull final CheckBox checkBox,
            @StringRes final int resId,
            final boolean defaultValue,
            @NonNull final Consumer<Boolean> onInitialValueOrValueChange
    ) {
        final boolean prefValue = PreferenceManager
                .getDefaultSharedPreferences(requireContext())
                .getBoolean(getString(resId), defaultValue);

        checkBox.setChecked(prefValue);

        onInitialValueOrValueChange.accept(prefValue);

        checkBox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
            // save whether pitch and tempo are unhooked or not
            PreferenceManager.getDefaultSharedPreferences(requireContext())
                    .edit()
                    .putBoolean(getString(resId), isChecked)
                    .apply();

            onInitialValueOrValueChange.accept(isChecked);
        });
    }

    /**
     * Ensures that the slider hook is valid and if not sets and updates the sliders accordingly.
     * <br/>
     * You have to ensure by yourself that the hooking is active.
     */
    private void ensureHookIsValidAndUpdateCallBack() {
        if (tempo != pitchPercent) {
            setSliders(Math.min(tempo, pitchPercent));
            updateCallback();
        }
    }

    /*//////////////////////////////////////////////////////////////////////////
    // Sliders
    //////////////////////////////////////////////////////////////////////////*/

    private SeekBar.OnSeekBarChangeListener getTempoOrPitchSeekbarChangeListener(
            final SliderStrategy sliderStrategy,
            final DoubleConsumer newValueConsumer
    ) {
        return new SimpleOnSeekBarChangeListener() {
            @Override
            public void onProgressChanged(@NonNull final SeekBar seekBar,
                                          final int progress,
                                          final boolean fromUser) {
                if (fromUser) { // ensure that the user triggered the change
                    newValueConsumer.accept(sliderStrategy.valueOf(progress));
                    updateCallback();
                }
            }
        };
    }

    private void onTempoSliderUpdated(final double newTempo) {
        if (!binding.unhookCheckbox.isChecked()) {
            setSliders(newTempo);
        } else {
            setAndUpdateTempo(newTempo);
        }
    }

    private void onPitchPercentSliderUpdated(final double newPitch) {
        if (!binding.unhookCheckbox.isChecked()) {
            setSliders(newPitch);
        } else {
            setAndUpdatePitch(newPitch);
        }
    }

    private void setSliders(final double newValue) {
        setAndUpdateTempo(newValue);
        setAndUpdatePitch(newValue);
    }

    private void setAndUpdateTempo(final double newTempo) {
        this.tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED);

        binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo));
        setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo);
    }

    private void setAndUpdatePitch(final double newPitch) {
        this.pitchPercent = calcValidPitch(newPitch);

        binding.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent));
        binding.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent));
        setText(binding.pitchPercentCurrentText,
                PlayerHelper::formatPitch,
                pitchPercent);
        setText(binding.pitchSemitoneCurrentText,
                PlayerSemitoneHelper::formatPitchSemitones,
                pitchPercent);
    }

    private double calcValidPitch(final double newPitch) {
        final double calcPitch = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED);

        if (!isCurrentPitchControlModeSemitone()) {
            return calcPitch;
        }

        return PlayerSemitoneHelper.semitonesToPercent(
                PlayerSemitoneHelper.percentToSemitones(calcPitch));
    }

    /*//////////////////////////////////////////////////////////////////////////
    // Helper
    //////////////////////////////////////////////////////////////////////////*/

    private void updateCallback() {
        if (callback == null) {
            return;
        }
        if (DEBUG) {
            Log.d(TAG, "Updating callback: "
                    + "tempo = " + tempo + ", "
                    + "pitchPercent = " + pitchPercent + ", "
                    + "skipSilence = " + skipSilence
            );
        }
        callback.onPlaybackParameterChanged((float) tempo, (float) pitchPercent, skipSilence);
    }

    @NonNull
    private static String getStepUpPercentString(final double percent) {
        return '+' + getPercentString(percent);
    }

    @NonNull
    private static String getStepDownPercentString(final double percent) {
        return '-' + getPercentString(percent);
    }

    @NonNull
    private static String getPercentString(final double percent) {
        return PlayerHelper.formatPitch(percent);
    }

    public interface Callback {
        void onPlaybackParameterChanged(float playbackTempo, float playbackPitch,
                                        boolean playbackSkipSilence);
    }
}
