/*
 * citygml-tools - Collection of tools for processing CityGML files
 * https://github.com/citygml4j/citygml-tools
 *
 * citygml-tools is part of the citygml4j project
 *
 * Copyright 2018-2023 Claus Nagel <claus.nagel@gmail.com>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.citygml4j.tools;

import org.citygml4j.cityjson.ExtensionLoader;
import org.citygml4j.core.ade.ADEException;
import org.citygml4j.core.ade.ADERegistry;
import org.citygml4j.tools.command.*;
import org.citygml4j.tools.log.LogLevel;
import org.citygml4j.tools.log.Logger;
import org.citygml4j.tools.option.Option;
import org.citygml4j.tools.util.PidFile;
import org.citygml4j.tools.util.URLClassLoader;
import org.citygml4j.xml.CityGMLADELoader;
import picocli.CommandLine;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.stream.Stream;

@CommandLine.Command(
        name = CityGMLTools.APP_NAME,
        scope = CommandLine.ScopeType.INHERIT,
        description = "Collection of tools for processing CityGML files.",
        mixinStandardHelpOptions = true,
        versionProvider = CityGMLTools.class,
        showAtFileInUsageHelp = true,
        sortOptions = false,
        subcommands = {
                CommandLine.HelpCommand.class,
                StatsCommand.class,
                ValidateCommand.class,
                ApplyXSLTCommand.class,
                ChangeHeightCommand.class,
                RemoveAppsCommand.class,
                ToLocalAppsCommand.class,
                ClipTexturesCommand.class,
                SubsetCommand.class,
                FilterLodsCommand.class,
                ReprojectCommand.class,
                FromCityJSONCommand.class,
                ToCityJSONCommand.class,
                UpgradeCommand.class
        }
)
public class CityGMLTools implements Command, CommandLine.IVersionProvider {
    @CommandLine.Option(names = {"-L", "--log-level"}, scope = CommandLine.ScopeType.INHERIT, paramLabel = "<level>",
            defaultValue = "info", description = "Log level: ${COMPLETION-CANDIDATES} (default: ${DEFAULT-VALUE}).")
    private LogLevel logLevel;

    @CommandLine.Option(names = "--log-file", scope = CommandLine.ScopeType.INHERIT, paramLabel = "<file>",
            description = "Write log messages to this file.")
    private Path logFile;

    @CommandLine.Option(names = "--pid-file", scope = CommandLine.ScopeType.INHERIT, paramLabel = "<file>",
            description = "Create a file containing the process ID.")
    private Path pidFile;

    @CommandLine.Option(names = "--extensions", scope = CommandLine.ScopeType.INHERIT, paramLabel = "<folder>",
            description = "Load extensions from this folder.")
    private Path extensionsDir;

    public static final String APP_NAME = "citygml-tools";
    public static final String EXTENSIONS_DIR = "extensions";
    public static final Path APP_HOME;
    public static final Path WORKING_DIR;

    private final Logger log = Logger.getInstance();
    private String commandLine;
    private String subCommandName;

    static {
        String appHomeEnv = System.getenv("APP_HOME");
        if (appHomeEnv == null) {
            appHomeEnv = ".";
        }

        String workingDirEnv = System.getenv("WORKING_DIR");
        if (workingDirEnv == null) {
            workingDirEnv = ".";
        }

        APP_HOME = Paths.get(appHomeEnv).normalize().toAbsolutePath();
        WORKING_DIR = Paths.get(workingDirEnv).normalize().toAbsolutePath();
        System.setProperty("picocli.disable.closures", "true");
    }

    public static void main(String[] args) {
        CityGMLTools app = new CityGMLTools();
        try {
            System.exit(app.execute(args));
        } catch (Exception e) {
            app.logException(e);
            System.exit(CommandLine.ExitCode.SOFTWARE);
        }
    }

    private int execute(String[] args) throws Exception {
        Instant start = Instant.now();
        int exitCode = CommandLine.ExitCode.SOFTWARE;

        CommandLine cmd = new CommandLine(this)
                .setCaseInsensitiveEnumValuesAllowed(true)
                .setAbbreviatedOptionsAllowed(true)
                .setAbbreviatedSubcommandsAllowed(true)
                .setExecutionStrategy(new CommandLine.RunAll());

        try {
            CommandLine.ParseResult parseResult = cmd.parseArgs(args);
            List<CommandLine> commandLines = parseResult.asCommandLineList();

            // check for help options
            for (CommandLine commandLine : commandLines) {
                if (commandLine.isUsageHelpRequested() || commandLine.isVersionHelpRequested()) {
                    return CommandLine.executeHelpRequest(parseResult);
                } else if (commandLine.getCommand() instanceof CommandLine.HelpCommand) {
                    return cmd.getExecutionStrategy().execute(parseResult);
                }
            }

            // check for required subcommand
            if (!parseResult.hasSubcommand()) {
                throw new CommandLine.ParameterException(cmd, "Missing required subcommand.");
            }

            for (CommandLine commandLine : commandLines) {
                Object command = commandLine.getCommand();

                // preprocess options
                for (Field field : command.getClass().getDeclaredFields()) {
                    if (Option.class.isAssignableFrom(field.getType())) {
                        field.setAccessible(true);
                        Option option = (Option) field.get(command);
                        if (option != null) {
                            option.preprocess(commandLine);
                        }
                    }
                }

                // preprocess commands
                if (command instanceof Command) {
                    ((Command) command).preprocess(commandLine);
                }
            }

            // execute commands
            commandLine = APP_NAME + " " + String.join(" ", args);
            subCommandName = commandLines.get(1).getCommandName();
            exitCode = cmd.getExecutionStrategy().execute(parseResult);

            log.info("Total execution time: " + formatElapsedTime(Duration.between(start, Instant.now())) + ".");
            int warnings = log.getNumberOfWarnings();
            int errors = log.getNumberOfErrors();

            if (exitCode == 1) {
                log.warn(APP_NAME + " execution failed.");
            } else if (warnings != 0 || errors != 0) {
                log.info(APP_NAME + " finished with " + (warnings != 0 ? warnings + " warning(s)" : "") +
                        (warnings != 0 && errors != 0 ? " and " : "") +
                        (errors != 0 ? errors + " error(s)" : "") + ".");
            } else {
                log.info(APP_NAME + " successfully completed.");
            }
        } catch (CommandLine.ParameterException e) {
            cmd.getParameterExceptionHandler().handleParseException(e, args);
            exitCode = CommandLine.ExitCode.USAGE;
        } catch (CommandLine.ExecutionException e) {
            logException(e.getCause());
        } catch (Exception e) {
            logException(e);
        } finally {
            log.close();
        }

        return exitCode;
    }

    @Override
    public Integer call() throws ExecutionException {
        initializeLogging();

        log.info("Starting " + APP_NAME + ".");
        loadADEExtensions(extensionsDir);
        createPidFile();

        log.info("Executing '" + subCommandName + "' command.");
        return CommandLine.ExitCode.OK;
    }

    private void initializeLogging() throws ExecutionException {
        log.setLogLevel(logLevel);

        if (logFile != null) {
            try {
                if (logFile.getParent() != null && !Files.exists(logFile.getParent())) {
                    Files.createDirectories(logFile);
                } else if (Files.isDirectory(logFile)) {
                    logFile = logFile.resolve(APP_NAME + ".log");
                }

                log.debug("Writing log messages to " + logFile.toAbsolutePath() + ".");
                log.setLogFile(logFile).writeToFile("# " + commandLine);
            } catch (IOException e) {
                throw new ExecutionException("Failed to create log file " + logFile.toAbsolutePath() + ".", e);
            }
        }

        // use default logging configuration for external loggers
        Path loggingProperties = Files.exists(APP_HOME.resolve("lib")) ?
                APP_HOME.resolve("lib").resolve("logging.properties") :
                APP_HOME.resolve("resources").resolve("logging.properties");

        if (Files.exists(loggingProperties)) {
            System.setProperty("java.util.logging.config.file", loggingProperties.toString());
        }
    }

    private void loadADEExtensions(Path extensionsDir) throws ExecutionException {
        extensionsDir = extensionsDir != null ?
                WORKING_DIR.resolve(extensionsDir) :
                APP_HOME.resolve(EXTENSIONS_DIR);

        if (Files.exists(extensionsDir) && Files.isDirectory(extensionsDir)) {
            URLClassLoader classLoader = new URLClassLoader(Thread.currentThread().getContextClassLoader());
            try {
                try (Stream<Path> stream = Files.walk(extensionsDir)
                        .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar"))) {
                    stream.forEach(classLoader::addPath);
                }

                if (classLoader.getURLs().length > 0) {
                    log.info("Loading extensions from " + extensionsDir.toAbsolutePath() + ".");

                    ADERegistry registry = ADERegistry.getInstance();
                    registry.loadADEs(classLoader);

                    registry.getADELoader(CityGMLADELoader.class).getADEModules().forEach(
                            module -> log.debug("Loaded CityGML ADE " + module.getNamespaceURI() +
                                    " for CityGML version " + module.getCityGMLVersion() + "."));

                    registry.getADELoader(ExtensionLoader.class).getExtensions().forEach(
                            extension -> log.debug("Loaded CityJSON Extension " + extension.getName() +
                                    " for CityJSON version " + extension.getCityJSONVersion() + "."));
                }
            } catch (ADEException | IOException e) {
                throw new ExecutionException("Failed to load ADE extensions.", e);
            }
        } else if (this.extensionsDir != null) {
            log.warn("The ADE extensions folder " + extensionsDir.toAbsolutePath() + " does not exist.");
        }
    }

    private void createPidFile() throws ExecutionException {
        if (pidFile != null) {
            try {
                log.debug("Creating PID file at " + pidFile.toAbsolutePath() + ".");
                PidFile.create(pidFile, true);
            } catch (IOException e) {
                throw new ExecutionException("Failed to create PID file.", e);
            }
        }
    }

    private void logException(Throwable e) {
        if (e instanceof ExecutionException) {
            if (log.getLogLevel() == LogLevel.DEBUG) {
                log.error(e.getMessage());
                log.logStackTrace(e.getCause());
            } else {
                log.error(e.getMessage(), e.getCause());
            }
        } else {
            log.error("An unexpected error occurred during execution.");
            log.logStackTrace(e);
        }

        log.warn(APP_NAME + " execution failed.");
    }

    private String formatElapsedTime(Duration elapsed) {
        long d = elapsed.toDaysPart();
        long h = elapsed.toHoursPart();
        long m = elapsed.toMinutesPart();
        long s = elapsed.toSecondsPart();

        if (d > 0) {
            return String.format("%02d d, %02d h, %02d m, %02d s", d, h, m, s);
        } else if (h > 0) {
            return String.format("%02d h, %02d m, %02d s", h, m, s);
        } else if (m > 0) {
            return String.format("%02d m, %02d s", m, s);
        } else {
            return String.format("%02d s", s);
        }
    }

    @Override
    public String[] getVersion() {
        try (InputStream stream = getClass().getResourceAsStream("/org/citygml4j/tools/application.properties")) {
            Properties properties = new Properties();
            properties.load(stream);
            return new String[]{
                    properties.getProperty("name") + ", version " + properties.getProperty("version"),
                    "(C) 2018-" + LocalDate.now().getYear() + " Claus Nagel <claus.nagel@gmail.com>"
            };
        } catch (IOException e) {
            return new String[]{};
        }
    }
}
