package ctbrec;

import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import ctbrec.io.*;
import ctbrec.recorder.postprocessing.PostProcessor;
import ctbrec.sites.Site;
import ctbrec.sites.chaturbate.ChaturbateModel;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.*;
import java.util.stream.Collectors;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.StandardOpenOption.*;

// TODO don't use singleton pattern
public class Config {

    private static final Logger LOG = LoggerFactory.getLogger(Config.class);
    private static final String SYSPROP_CONFIG_DIR = "ctbrec.config.dir";
    private static final String V_4_7_5 = "4.7.5";


    private static Config instance;
    private Settings settings;
    private final String filename;
    private final List<Site> sites;
    private final File configDir;
    /**
     * to temporarily disable saving of the config
     * this is useful for the SettingsTab, because setting the initial values of some components causes an immediate save
     */
    private boolean savingDisabled = false;
    public static final String RECORDING_DATE_FORMAT = "yyyy-MM-dd_HH-mm-ss_SSS";

    public Config(List<Site> sites) throws IOException {
        this.sites = sites;

        copyConfigIfNewVersion();

        if (System.getProperty(SYSPROP_CONFIG_DIR) != null) {
            configDir = new File(System.getProperty(SYSPROP_CONFIG_DIR), Version.getVersion().toString());
        } else {
            configDir = new File(OS.getConfigDir(), Version.getVersion().toString());
        }

        backupConfig(configDir);

        if (System.getProperty("ctbrec.config") != null) {
            filename = System.getProperty("ctbrec.config");
        } else {
            filename = "settings.json";
        }
    }

    private void backupConfig(File currentConfigDir) throws IOException {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(RECORDING_DATE_FORMAT);
        File src = currentConfigDir;
        if (src.exists()) {
            File target = new File(src.getParentFile(), src.getName() + "_backup_" + dateTimeFormatter.format(LocalDateTime.now()));
            LOG.info("Creating a backup of {} the config in {}", src, target);
            FileUtils.copyDirectory(src, target, pathname -> !(pathname.toString().contains("minimal-browser") && pathname.toString().contains("Cache")), true);
            deleteOldBackups(currentConfigDir);
        }
    }

    private void deleteOldBackups(File currentConfigDir) {
        File parent = currentConfigDir.getParentFile();
        File[] backupDirectories = parent.listFiles(file -> file.isDirectory() && file.getName().matches(".*?_backup_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}_\\d{3}"));
        Arrays.sort(backupDirectories, Comparator.comparing(File::getName));
        for (int i = 0; i < backupDirectories.length - 5; i++) {
            File dirToDelete = backupDirectories[i];
            try {
                LOG.info("Delete old config backup {}", dirToDelete);
                IoUtils.deleteDirectory(dirToDelete);
            } catch (IOException e) {
                LOG.error("Couldn't delete old config backup {}. You might have to delete it manually.", dirToDelete, e);
            }
        }
    }

    private void copyConfigIfNewVersion() throws IOException {
        File configDirectory;
        if (System.getProperty(SYSPROP_CONFIG_DIR) != null) {
            configDirectory = new File(System.getProperty(SYSPROP_CONFIG_DIR));
        } else {
            configDirectory = OS.getConfigDir();
        }

        Version currentVersion = Version.getVersion();
        Version previousVersion = getPreviousVersion(configDirectory);

        File src;
        File target = new File(configDirectory, currentVersion.toString());
        if (target.exists()) {
            return;
        }

        if (previousVersion.compareTo(Version.of(V_4_7_5)) <= 0) {
            src = configDirectory;
        } else {
            src = new File(configDirectory, previousVersion.toString());
        }
        if (!src.exists()) {
            // new installation
            return;
        }
        if (!Objects.equals(previousVersion, currentVersion)) {
            LOG.debug("Version update {} -> {}", previousVersion, currentVersion);
            LOG.debug("Copying config from {} to {}", src, target);
            FileUtils.copyDirectory(src, target, pathname -> !(pathname.toString().contains("minimal-browser") && pathname.toString().contains("Cache")), true);
        }
    }

    private Version getPreviousVersion(File configDirectory) {
        File[] versionDirectories = configDirectory.listFiles((dir, name) -> name.matches("\\d+\\.\\d+\\.\\d+"));
        if (versionDirectories != null) {
            Optional<Version> previousVersion = Arrays.stream(versionDirectories)
                    .map(File::getName)
                    .map(Version::of)
                    .max(Comparator.naturalOrder());
            return previousVersion.orElse(Version.of(V_4_7_5));
        } else {
            return Version.of(V_4_7_5);
        }
    }

    private void load() throws IOException {
        Moshi moshi = new Moshi.Builder()
                .add(Model.class, new ModelJsonAdapter(sites))
                .add(PostProcessor.class, new PostProcessorJsonAdapter())
                .add(File.class, new FileJsonAdapter())
                .add(UUID.class, new UuidJSonAdapter())
                .add(LocalTime.class, new LocalTimeJsonAdapter())
                .build();
        JsonAdapter<Settings> adapter = moshi.adapter(Settings.class).lenient();
        File configFile = new File(configDir, filename);
        LOG.info("Loading config from {}", configFile.getAbsolutePath());
        if (configFile.exists()) {
            try {
                byte[] fileContent = Files.readAllBytes(configFile.toPath());
                if (fileContent[0] == -17 && fileContent[1] == -69 && fileContent[2] == -65) {
                    // found BOM (byte order mark)
                    LOG.debug("Removing BOM from config file");
                    fileContent[0] = ' ';
                    fileContent[1] = ' ';
                    fileContent[2] = ' ';
                }
                String json = new String(fileContent, UTF_8).trim();
                settings = Objects.requireNonNull(adapter.fromJson(json));
                settings.httpTimeout = Math.max(settings.httpTimeout, 10_000);
                if (settings.recordingsDir.endsWith("/")) {
                    settings.recordingsDir = settings.recordingsDir.substring(0, settings.recordingsDir.length() - 1);
                }
            } catch (Exception e) {
                settings = OS.getDefaultSettings();
                for (Site site : sites) {
                    site.setEnabled(!settings.disabledSites.contains(site.getName()));
                }
                throw e;
            }
        } else {
            LOG.error("Config file does not exist. Falling back to default values.");
            settings = OS.getDefaultSettings();
        }
        for (Site site : sites) {
            site.setEnabled(!settings.disabledSites.contains(site.getName()));
        }

        migrateOldSettings();
    }

    private void migrateOldSettings() {
        // 5.0.0
        convertChaturbateModelNamesToLowerCase();
    }

    private void convertChaturbateModelNamesToLowerCase() {
        final String CTB = "chaturbate.com";

        // convert mode notes
        getSettings().modelNotes.forEach((key, value) -> {
            if (key.contains(CTB)) {
                getSettings().modelNotes.remove(key);
                getSettings().modelNotes.put(key.toLowerCase(), value);
            }
        });

        // convert model portraits
        getSettings().modelPortraits.forEach((key, value) -> {
            if (key.contains(CTB)) {
                getSettings().modelPortraits.remove(key);
                getSettings().modelPortraits.put(key.toLowerCase(), value);
            }
        });

        // convert model groups
        getSettings().modelGroups.forEach(mg -> mg.setModelUrls(mg.getModelUrls().stream().map(url -> url.contains(CTB) ? url.toLowerCase() : url)
                .collect(Collectors.toList()))); // NOSONAR - has to be mutable

        // convert ignored models
        getSettings().ignoredModels = getSettings().ignoredModels.stream().map(url -> url.contains(CTB) ? url.toLowerCase() : url)
                .collect(Collectors.toList()); // NOSONAR - has to be mutable

        // change the model objects
        getSettings().models.stream()
                .filter(ChaturbateModel.class::isInstance)
                .forEach(m -> {
                    m.setDisplayName(m.getName());
                    m.setName(m.getName().toLowerCase());
                    m.setUrl(m.getUrl().toLowerCase());
                });
        getSettings().recordLater.stream()
                .filter(ChaturbateModel.class::isInstance)
                .forEach(m -> {
                    m.setDisplayName(m.getName());
                    m.setName(m.getName().toLowerCase());
                    m.setUrl(m.getUrl().toLowerCase());
                });
    }

    public static synchronized void init(List<Site> sites) throws IOException {
        if (instance == null) {
            instance = new Config(sites);
            instance.load();
        }
    }

    public static synchronized Config getInstance() {
        if (instance == null) {
            throw new IllegalStateException("Config not initialized, call init() first");
        }
        return instance;
    }

    public Settings getSettings() {
        return settings;
    }

    public synchronized void save() throws IOException {
        if (savingDisabled) {
            return;
        }
        Moshi moshi = new Moshi.Builder()
                .add(Model.class, new ModelJsonAdapter())
                .add(PostProcessor.class, new PostProcessorJsonAdapter())
                .add(File.class, new FileJsonAdapter())
                .add(UUID.class, new UuidJSonAdapter())
                .add(LocalTime.class, new LocalTimeJsonAdapter())
                .build();
        JsonAdapter<Settings> adapter = moshi.adapter(Settings.class).indent("  ");
        String json = adapter.toJson(settings);
        File configFile = new File(configDir, filename);
        LOG.debug("Saving config to {}", configFile.getAbsolutePath());
        Files.createDirectories(configDir.toPath());
        Files.writeString(configFile.toPath(), json, CREATE, WRITE, TRUNCATE_EXISTING);
    }

    public static boolean isServerMode() {
        return Objects.equals(System.getProperty("ctbrec.server.mode"), "1");
    }

    public static boolean isDevMode() {
        return Objects.equals(System.getenv("CTBREC_DEV"), "1");
    }

    public File getConfigDir() {
        return configDir;
    }

    public File getFileForRecording(Model model, String suffix, Instant startTime) {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(RECORDING_DATE_FORMAT);
        LocalDateTime startDateTime = LocalDateTime.ofInstant(startTime, ZoneId.systemDefault());
        String formattedDate = dateTimeFormatter.format(startDateTime);
        File dirForRecording = getDirForRecording(model, formattedDate);
        File targetFile = new File(dirForRecording, model.getSanitizedNamed() + '_' + formattedDate + '.' + suffix);
        return targetFile;
    }

    private File getDirForRecording(Model model, String formattedDate) {
        switch (getSettings().recordingsDirStructure) {
            case ONE_PER_MODEL -> {
                return new File(getSettings().recordingsDir, model.getSanitizedNamed());
            }
            case ONE_PER_RECORDING -> {
                File modelDir = new File(getSettings().recordingsDir, model.getSanitizedNamed());
                return new File(modelDir, formattedDate);
            }
            default -> {
                return new File(getSettings().recordingsDir);
            }
        }
    }

    public String getServerUrl() {
        String scheme = getSettings().transportLayerSecurity ? "https" : "http";
        int port = getSettings().httpPort;
        String baseUrl = scheme + "://" + getSettings().httpServer + ":" + port + getContextPath();
        return baseUrl;
    }

    public String getContextPath() {
        String context = Optional.ofNullable(getSettings().servletContext).orElse("");
        if (!context.startsWith("/") && !context.isEmpty()) {
            context = '/' + context;
        }
        if (context.endsWith("/")) {
            context = context.substring(0, context.length() - 1);
        }
        return context;
    }

    public DateTimeFormatter getDateTimeFormatter() {
        var dtf = getSettings().dateTimeFormat;
        if (StringUtil.isNotBlank(dtf)) {
            return DateTimeFormatter.ofPattern(dtf);
        } else {
            return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
        }
    }

    public String getModelNotes(Model m) {
        return Config.getInstance().getSettings().modelNotes.getOrDefault(m.getUrl(), "");
    }

    public void disableSaving() {
        savingDisabled = true;
    }

    public void enableSaving() {
        savingDisabled = false;
    }

    public boolean isIgnored(Model model) {
        List<String> ignored = Config.getInstance().getSettings().ignoredModels;
        return ignored.contains(model.getUrl());
    }
}
