package magic.data;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.nio.file.Path;
import java.text.Normalizer;
import java.text.Normalizer.Form;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer;
import org.codehaus.groovy.control.customizers.ImportCustomizer;

import groovy.lang.GroovyShell;
import groovy.transform.CompileStatic;
import magic.model.MagicCardDefinition;
import magic.model.MagicChangeCardDefinition;
import magic.model.MagicColor;
import magic.ui.MagicCardImages;
import magic.ui.screen.images.download.CardImageDisplayMode;
import magic.utility.FileIO;
import magic.utility.MagicFileSystem;
import magic.utility.MagicFileSystem.DataPath;
import magic.utility.MagicSystem;
import magic.utility.ProgressReporter;

public class CardDefinitions {

    private static final File CARDS_SNAPSHOT_FILE =
            MagicFileSystem.getDataPath().resolve("snapshot.dat").toFile();

    private static final File SCRIPTS_DIRECTORY =
            MagicFileSystem.getDataPath(DataPath.SCRIPTS).toFile();

    private static final GeneralConfig CONFIG = GeneralConfig.getInstance();

    // A MagicCardDefinition is a bit of a misnomer in that it represents a single
    // playable aspect of a card. For example, double faced or flip cards will be
    // represented by two MagicCardDefinitions, one for each of the faces or aspects
    // of that card that can be played.

    // Contains reference to all playable MagicCardDefinitions indexed by card name.
    private static final Map<String, MagicCardDefinition> playableCards = new ConcurrentHashMap<>();

    private static final Map<String, MagicCardDefinition> missingCards = new ConcurrentHashMap<>();

    private static final AtomicInteger cdefIndex = new AtomicInteger(1);

    static {
        CompilerConfiguration.DEFAULT.getOptimizationOptions().put(CompilerConfiguration.INVOKEDYNAMIC, Boolean.TRUE);
    }

    // groovy shell for evaluating groovy card scripts with automatic imports
    private static final GroovyShell shell = new GroovyShell(
        new CompilerConfiguration().addCompilationCustomizers(
            new ImportCustomizer()
            .addStarImports(
                "java.util",
                "magic.data",
                "magic.model",
                "magic.model.action",
                "magic.model.choice",
                "magic.model.condition",
                "magic.model.event",
                "magic.model.mstatic",
                "magic.model.stack",
                "magic.model.target",
                "magic.model.trigger",
                "magic.model.phase",
                "magic.card"
            ).addStaticStars(
                "magic.model.target.MagicTargetFilterFactory",
                "magic.model.choice.MagicTargetChoice"
            ),
            new ASTTransformationCustomizer(CompileStatic.class)
        )
    );

    private static void setProperty(final MagicCardDefinition card,final String property,final String value) {
        try {
            CardProperty.valueOf(property.toUpperCase(Locale.ENGLISH)).setProperty(card, value);
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("unknown card property value \"" + property + "\" = \"" + value + "\"", e);
        }
    }

    private static void addDefinition(final MagicCardDefinition cardDef) {
        assert cardDef != null : "CardDefinitions.addDefinition passed null";
        assert cardDef.getIndex() == -1 : "cardDefinition has been assigned index";

        cardDef.setIndex(cdefIndex.getAndIncrement());

        playableCards.put(cardDef.getAsciiName(), cardDef);
    }

    private static MagicCardDefinition prop2carddef(final File scriptFile, final boolean isMissing) {
        final Properties content = FileIO.toProp(scriptFile);
        final MagicCardDefinition cardDefinition = new MagicCardDefinition();

        if (isMissing) {
            cardDefinition.setInvalid();
        }

        for (final String key : content.stringPropertyNames()) {
            try {
                setProperty(cardDefinition, key, content.getProperty(key));
            } catch (Exception e) {
                if (isMissing) {
                    cardDefinition.setInvalid();
                } else {
                    throw e;
                }
            }
        }

        try {
            cardDefinition.validate();
        } catch (Exception e) {
            if (isMissing) {
                cardDefinition.setInvalid();
            } else {
                throw e;
            }
        }

        return cardDefinition;
    }

    //link to groovy script that returns array of MagicChangeCardDefinition objects
    static void addCardSpecificGroovyCode(final MagicCardDefinition cardDefinition, final String cardName) {
        try {
            final File groovyFile = new File(SCRIPTS_DIRECTORY, getCanonicalName(cardName) + ".groovy");
            if (!groovyFile.isFile()) {
                throw new RuntimeException("groovy file not found: " + groovyFile);
            }
            @SuppressWarnings("unchecked")
            final List<MagicChangeCardDefinition> defs = (List<MagicChangeCardDefinition>)shell.evaluate(groovyFile);
            for (MagicChangeCardDefinition ccd : defs) {
                ccd.change(cardDefinition);
            }
        } catch (final IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static String getCanonicalName(String fullName) {
        return fullName.replace("\u00C6", "Ae").replaceAll("[^A-Za-z0-9]", "_");
    }

    public static String getASCII(String fullName) {
        return Normalizer.normalize(fullName, Form.NFD)
                         .replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
                         .replace("\u00C6", "Ae");
    }

    private static void loadCardDefinition(final File file) {
        try {
            final MagicCardDefinition cdef = prop2carddef(file, false);
            addDefinition(cdef);
        } catch (final Throwable cause) {
            if (MagicSystem.isParseMissing()) {
                System.out.println("ERROR file: " + file + " cause: " + cause.getMessage());
            } else {
                throw new RuntimeException("Error loading " + file, cause);
            }
        }
    }

    public static void loadCardDefinition(final String cardName) {
        final File cardFile = new File(SCRIPTS_DIRECTORY, getCanonicalName(cardName) + ".txt");
        if (!cardFile.isFile()) {
            throw new RuntimeException("card script file not found: " + cardFile);
        }
        loadCardDefinition(cardFile);
    }

    /**
     * loads playable cards.
     */
    public static void loadCardDefinitions(final ProgressReporter reporter) {

        reporter.setMessage("Sorting card script files...");
        final File[] scriptFiles = MagicFileSystem.getSortedScriptFiles(SCRIPTS_DIRECTORY);

        reporter.setMessage("Loading cards...0%");
        final double totalFiles = scriptFiles.length;
        int fileCount = 0;
        for (final File file : scriptFiles) {
            loadCardDefinition(file);
            //
            // display percentage complete message every 10%.
            final double percentageComplete = (fileCount++ / totalFiles) * 100;
            final double m = percentageComplete % 10d;
            if (isZero(m, 0.01d)) {
                // This should only be called ten times.
                // It can have a serious effect on load time if called too many times.
                reporter.setMessage("Loading cards..." + ((int)percentageComplete + 10) + "%");
            }
        }
        reporter.setMessage("Loading cards...100%");

    }

    public static void postCardDefinitions() {
        printStatistics();
        updateNewCardsLog(loadCardsSnapshotFile());
    }

    private static boolean isZero(double value, double delta){
        return value >= -delta && value <= delta;
    }

    public static void loadCardAbilities() {
        final ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        Stream.concat(getDefaultPlayableCardDefStream(), getTokensCardDefStream()).forEach(cdef -> {
            executor.execute(() -> {
                try {
                    cdef.loadAbilities();
                    if (MagicSystem.isParseMissing() && !cdef.isToken()) {
                        System.out.println("OK card: " + cdef);
                    }
                } catch (Throwable cause) {
                    if (MagicSystem.isParseMissing()) {
                        System.out.println("ERROR card: " + cdef + " cause: " + cause.getMessage());
                    } else {
                        throw new RuntimeException("Unable to load " + cdef, cause);
                    }
                }
            });
        });
        executor.shutdown();
        try {
            executor.awaitTermination(100, TimeUnit.SECONDS);
        } catch (final InterruptedException ex) {
            throw new RuntimeException(ex);
        }
    }

    public static MagicCardDefinition getToken(final int p, final int t, final String name) {
        return MagicCardDefinition.token(CardDefinitions.getToken(name), cdef -> cdef.setPowerToughness(p, t));
    }

    public static MagicCardDefinition getToken(final String original) {
        final MagicCardDefinition token = getCard(original);
        if (token.isToken()) {
            return token;
        } else {
            throw new RuntimeException("unknown token: \"" + original + "\"");
        }
    }

    public static MagicCardDefinition getMissingOrCard(final String original) {
        final String key = getASCII(original);
        return missingCards.containsKey(key) ? missingCards.get(key) : getCard(original);
    }

    public static MagicCardDefinition getCard(final String original) {
        final String key = getASCII(original);
        // lazy loading of card scripts
        if (!playableCards.containsKey(key)) {
            loadCardDefinition(original);
        }
        if (playableCards.containsKey(key)) {
            return playableCards.get(key);
        } else {
            throw new RuntimeException("unknown card: \"" + original + "\"");
        }
    }

    public static MagicCardDefinition getBasicLand(final MagicColor color) {
        switch (color) {
            case Black:
                return getCard("Swamp");
            case Blue:
                return getCard("Island");
            case Green:
                return getCard("Forest");
            case Red:
                return getCard("Mountain");
            case White:
                return getCard("Plains");
        }
        throw new RuntimeException("No matching basic land for MagicColor " + color);
    }

    private static Stream<MagicCardDefinition> getDefaultPlayableCardDefStream() {
        return getAllPlayableCardDefs().stream()
            .filter(MagicCardDefinition::isPlayable);
    }

    private static Stream<MagicCardDefinition> getTokensCardDefStream() {
        return getAllPlayableCardDefs().stream()
            .filter(MagicCardDefinition::isToken);
    }

    /**
     * Returns a list of all playable MagicCardDefinitions except those classed as hidden.
     * <p>
     * Only contains reference to the main MagicCardDefinition aspect of a card. This is
     * required for functions like the Deck Editor where you should not be able to select
     * the reverse side of a double-side card, for example.
     */
    public static List<MagicCardDefinition> getDefaultPlayableCardDefs() {
        return getDefaultPlayableCardDefStream()
            .collect(Collectors.toList());
    }

    /**
     * Returns a list all playable MagicCardDefinitions INCLUDING those classed as hidden.
     */
    public static Collection<MagicCardDefinition> getAllPlayableCardDefs() {
        MagicSystem.waitForPlayableCards();
        return playableCards.values();
    }

    public static synchronized List<MagicCardDefinition> getAllCards() {
        final List<MagicCardDefinition> combined = new ArrayList<>();
        combined.addAll(getAllPlayableCardDefs());
        combined.addAll(getMissingCards());
        return combined;
    }

    public static Stream<MagicCardDefinition> getNonBasicLandCards() {
        return getDefaultPlayableCardDefStream()
            .filter(card -> card.isLand() && !card.isBasic());
    }

    public static List<MagicCardDefinition> getSpellCards() {
        return getDefaultPlayableCardDefStream()
            .filter(card -> !card.isLand())
            .collect(Collectors.toList());
    }

    private static void printStatistics() {
        if (MagicSystem.showStartupStats()) {
            final CardStatistics statistics=new CardStatistics(getDefaultPlayableCardDefs());
            statistics.printStatistics(System.err);
        }
    }

    public static void loadMissingCards() {
        final File[] scriptFiles = getSortedMissingScriptFiles();
        if (scriptFiles != null) {
            for (final File file : scriptFiles) {
                MagicCardDefinition cdef = prop2carddef(file, true);
                missingCards.put(cdef.getAsciiName(), cdef);
            }
        }
    }

    /**
     * Gets a sorted list of all the script files in the "missing" folder.
     */
    private static File[] getSortedMissingScriptFiles() {
        final Path cardsPath = MagicFileSystem.getDataPath(DataPath.SCRIPTS_MISSING);
        final File[] files = cardsPath.toFile().listFiles((dir, name) -> {
            return name.toLowerCase(Locale.ENGLISH).endsWith(".txt");
        });
        if (files != null) {
            Arrays.sort(files);
        }
        return files;
    }

    public static boolean requiresNewImageDownload(MagicCardDefinition card, Date lastDownloadDate) {
        if (!card.hasImageUrl()) {
            return false;
        }
        if (MagicCardImages.isCustomCardImageFound(card)) {
            return false;
        }
        if (card.isImageUpdatedAfter(lastDownloadDate)) {
            return true;
        }
        if (CONFIG.getCardImageDisplayMode() == CardImageDisplayMode.PRINTED) {
            return MagicCardImages.isPrintedCardImageMissing(card);
        } else { // PROXY
            return MagicCardImages.isCroppedCardImageMissing(card)
                && MagicCardImages.isPrintedCardImageMissing(card);
        }
    }

    public static boolean isMissingPlayableImages() {
        Date aDate = CONFIG.getPlayableImagesDownloadDate();
        return getAllPlayableCardDefs().stream()
            .anyMatch(card -> requiresNewImageDownload(card, aDate));
    }

    public static void checkForMissingFiles() {
        new Thread(() -> {
            GeneralConfig.setIsMissingFiles(isMissingPlayableImages());
        }).start();
    }

    public static String getScriptFilename(final MagicCardDefinition card) {
        return card.getFilename() + ".txt";
    }

    public static String getGroovyFilename(final MagicCardDefinition card) {
        return card.getFilename() + ".groovy";
    }

    public static boolean isCardPlayable(MagicCardDefinition card) {
        return playableCards.containsKey(card.getAsciiName());
    }

    public static boolean isCardMissing(MagicCardDefinition card) {
        return missingCards.containsKey(card.getAsciiName());
    }

    public static boolean isPotential(MagicCardDefinition card) {
        return card.hasStatus() ? isCardMissing(card) && !card.getStatus().contains("not supported") : isCardMissing(card);
    }

    public static Collection<MagicCardDefinition> getMissingCards() {
        MagicSystem.waitForMissingCards();
        return missingCards.values();
    }

    public static List<String> getMissingCardNames() {
        List<String> names = new ArrayList<>(getMissingCards().size());
        for (final MagicCardDefinition cdef : getMissingCards()) {
            names.add(cdef.getName());
        }
        return names;
    }


    private static void saveCardsSnapshotFile() {
        MagicFileSystem.serializeStringList(getPlayableNonTokenCardNames(), CARDS_SNAPSHOT_FILE);
    }

    private static List<String> loadCardsSnapshotFile() {
        if (CARDS_SNAPSHOT_FILE.exists()) {
            try {
                return MagicFileSystem.deserializeStringList(CARDS_SNAPSHOT_FILE);
            } catch (Exception ex) {
                Logger.getLogger(CardDefinitions.class.getName()).log(Level.WARNING, null, ex);
                return new ArrayList<>();
            }
        } else {
            saveCardsSnapshotFile();
            return new ArrayList<>();
        }
    }

    private static List<String> getPlayableNonTokenCardNames() {
        return getAllPlayableCardDefs().stream().filter(card -> !card.isToken()).map(MagicCardDefinition::getName).collect(Collectors.toList());
    }

    public static void updateNewCardsLog(final List<String> snapshot) {
        final List<String> cardNames = getPlayableNonTokenCardNames();
        cardNames.removeAll(snapshot);
        if (!cardNames.isEmpty()) {
            saveNewCardsLog(cardNames);
            saveCardsSnapshotFile();
        }
    }

    private static void saveNewCardsLog(final Collection<String> cardNames) {
        final Path LOGS_PATH = MagicFileSystem.getDataPath(DataPath.LOGS);
        final File LOG_FILE = LOGS_PATH.resolve("newcards.log").toFile();
        try (final PrintWriter writer = new PrintWriter(LOG_FILE, UTF_8.name())) {
            cardNames.forEach(writer::println);
        } catch (FileNotFoundException|UnsupportedEncodingException ex) {
            System.err.println("Failed to save " + LOG_FILE + " - " + ex);
        }
    }
}
