/*
 * 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.command;

import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.citygml4j.core.model.appearance.Appearance;
import org.citygml4j.core.model.core.AbstractGenericAttribute;
import org.citygml4j.core.model.core.ImplicitGeometry;
import org.citygml4j.tools.CityGMLTools;
import org.citygml4j.tools.ExecutionException;
import org.citygml4j.tools.log.LogLevel;
import org.citygml4j.tools.option.IdOption;
import org.citygml4j.tools.option.InputOptions;
import org.citygml4j.tools.util.*;
import org.citygml4j.xml.reader.ChunkOptions;
import org.citygml4j.xml.schema.CityGMLSchemaHandler;
import org.xmlobjects.gml.adapter.feature.BoundingShapeAdapter;
import org.xmlobjects.gml.model.feature.BoundingShape;
import org.xmlobjects.gml.model.geometry.AbstractGeometry;
import org.xmlobjects.gml.util.GMLConstants;
import org.xmlobjects.schema.SchemaHandler;
import org.xmlobjects.stream.EventType;
import org.xmlobjects.stream.XMLReadException;
import org.xmlobjects.stream.XMLReader;
import org.xmlobjects.stream.XMLReaderFactory;
import org.xmlobjects.xml.Attributes;
import picocli.CommandLine;

import javax.xml.namespace.QName;
import java.nio.file.Path;
import java.util.*;

@CommandLine.Command(name = "stats",
        description = "Generates statistics about the content of CityGML files.")
public class StatsCommand extends CityGMLTool {
    @CommandLine.Option(names = {"-c", "--compute-extent"},
            description = "Compute the extent for the entire CityGML file. By default, the extent is taken from " +
                    "the CityModel and calculated only as fallback. No coordinate transformation is applied in " +
                    "the calculation.")
    private boolean computeEnvelope;

    @CommandLine.ArgGroup
    private IdOption idOption;

    @CommandLine.Option(names = {"-t", "--only-top-level"},
            description = "Only count top-level city objects.")
    private boolean onlyTopLevelFeatures;

    @CommandLine.Option(names = {"-H", "--object-hierarchy"},
            description = "Generate an aggregated overview of the nested hierarchies of the city objects.")
    private boolean generateObjectHierarchy;

    @CommandLine.Option(names = "--no-json-report", negatable = true, defaultValue = "true",
            description = "Save the statistics as separate JSON report for each input file " +
                    "(default: ${DEFAULT-VALUE}).")
    private boolean jsonReport;

    @CommandLine.Option(names = {"-r", "--summary-report"}, paramLabel = "<file>",
            description = "Write the overall statistics over all input file(s) as JSON report to the " +
                    "specified output file.")
    private Path summaryFile;

    @CommandLine.Option(names = {"-f", "--fail-on-missing-schema"},
            description = "Fail if elements in the input file(s) are not associated with an XML schema.")
    private boolean failOnMissingSchema;

    @CommandLine.Option(names = {"-s", "--schema"}, split = ",", paramLabel = "<URI>",
            description = "One or more files or URLs of additional XML schemas to use for generating the statistics. " +
                    "Note that the official CityGML schemas cannot be replaced.")
    private Set<String> schemas;

    @CommandLine.Mixin
    private InputOptions inputOptions;

    private final String suffix = "__statistics";

    @Override
    public Integer call() throws ExecutionException {
        log.debug("Searching for CityGML input files.");
        List<Path> inputFiles = InputFiles.of(inputOptions.getFiles())
                .withFilter(path -> !stripFileExtension(path).endsWith(suffix))
                .find();

        if (inputFiles.isEmpty()) {
            log.warn("No files found at " + inputOptions.joinFiles() + ".");
            return CommandLine.ExitCode.OK;
        }

        log.info("Found " + inputFiles.size() + " file(s) at " + inputOptions.joinFiles() + ".");

        SchemaHandler schemaHandler;
        try {
            schemaHandler = CityGMLSchemaHandler.newInstance();
        } catch (Exception e) {
            throw new ExecutionException("Failed to create schema handler.", e);
        }

        if (schemas != null) {
            for (String schema : schemas) {
                try {
                    Path schemaFile = CityGMLTools.WORKING_DIR.resolve(schema).toAbsolutePath();
                    log.debug("Reading additional XML schema from " + schemaFile + ".");
                    schemaHandler.parseSchema(schema);
                } catch (Exception e) {
                    throw new ExecutionException("Failed to read XML schema from " + schema + ".", e);
                }
            }
        }

        Statistics summary = null;
        ObjectMapper objectMapper = new ObjectMapper();
        ChunkOptions chunkOptions = ChunkOptions.defaults();
        SchemaHelper schemaHelper = SchemaHelper.of(schemaHandler)
                .failOnMissingSchema(failOnMissingSchema);

        for (int i = 0; i < inputFiles.size(); i++) {
            Path inputFile = inputFiles.get(i);

            log.info("[" + (i + 1) + "|" + inputFiles.size() + "] Processing file " + inputFile.toAbsolutePath() + ".");

            Statistics statistics = Statistics.of(inputFile);
            StatisticsProcessor processor = StatisticsProcessor.of(statistics, getCityGMLContext())
                    .computeEnvelope(computeEnvelope)
                    .onlyTopLevelFeatures(onlyTopLevelFeatures)
                    .generateObjectHierarchy(generateObjectHierarchy);

            if (idOption != null) {
                statistics.withCityObjectIds(idOption.getIds());

                log.debug("Reading global appearances from input file.");
                processor.withGlobalAppearances(GlobalObjectsReader.onlyAppearances()
                        .read(inputFile, getCityGMLContext())
                        .getAppearances());
            }

            log.debug("Reading city objects and generating statistics.");

            try (XMLReader reader = XMLReaderFactory.newInstance(getCityGMLContext().getXMLObjects())
                    .withSchemaHandler(schemaHandler)
                    .createReader(inputFile, inputOptions.getEncoding())) {
                Deque<QName> elements = new ArrayDeque<>();
                Deque<Integer> features = new ArrayDeque<>();
                boolean isTopLevel = false;

                while (reader.hasNext()) {
                    EventType event = reader.nextTag();
                    QName lastElement = elements.peek();
                    int depth = reader.getDepth();
                    int lastFeature = Objects.requireNonNullElseGet(features.peek(),
                            () -> idOption == null ? 0 : Integer.MAX_VALUE);

                    if (event == EventType.START_ELEMENT) {
                        QName element = reader.getName();
                        if (chunkOptions.containsProperty(element)) {
                            isTopLevel = true;
                        } else {
                            if (schemaHelper.isAppearance(element) && depth > lastFeature) {
                                processor.process(element, reader.getObject(Appearance.class), isTopLevel);
                            } else if (schemaHelper.isFeature(element)
                                    && (idOption == null || depth > lastFeature || hasMatchingIdentifier(reader))) {
                                processor.process(element, reader.getPrefix(), isTopLevel, depth, statistics);
                                features.push(depth);
                            } else if (schemaHelper.isBoundingShape(element)) {
                                boolean isCityModel = lastElement != null && schemaHelper.isCityModel(lastElement);
                                if (isCityModel || depth > lastFeature) {
                                    BoundingShape boundedBy = reader.getObjectUsingBuilder(BoundingShapeAdapter.class);
                                    processor.process(boundedBy, depth - 1, isCityModel, statistics);
                                }
                            } else if (depth > lastFeature) {
                                if (schemaHelper.isGeometry(element)) {
                                    processor.process(element, reader.getObject(AbstractGeometry.class), lastElement);
                                } else if (schemaHelper.isImplicitGeometry(element)) {
                                    processor.process(element, reader.getObject(ImplicitGeometry.class), lastElement);
                                } else if (schemaHelper.isGenericAttribute(element)) {
                                    processor.process(reader.getObject(AbstractGenericAttribute.class));
                                }
                            }

                            isTopLevel = false;
                        }

                        if (reader.getDepth() == depth) {
                            elements.push(element);
                        }
                    } else if (event == EventType.END_ELEMENT) {
                        processor.updateDepth(depth);
                        elements.pop();
                        if (lastFeature == depth + 1) {
                            features.pop();
                        }
                    }
                }
            } catch (Exception e) {
                throw new ExecutionException("Failed to read file " + inputFile.toAbsolutePath() + ".", e);
            }

            if (schemaHelper.hasMissingSchemas()) {
                schemaHelper.getAndResetMissingSchemas().forEach(statistics::addMissingSchema);
            }

            if (jsonReport) {
                Path outputFile = inputFile.resolveSibling(appendFileNameSuffix(inputFile.resolveSibling(
                        replaceFileExtension(inputFile, "json")), suffix));

                log.info("Writing statistics as JSON report to file " + outputFile + ".");
                writeStatistics(outputFile, statistics, objectMapper);
            }

            if (summary == null) {
                summary = statistics;
            } else {
                summary.merge(statistics);
            }
        }

        int exitCode = CommandLine.ExitCode.OK;
        if (summary != null) {
            if (summaryFile != null) {
                log.info("Writing overall statistics over all input file(s) as JSON report to file " + summaryFile + ".");
                writeStatistics(summaryFile, summary, objectMapper);
            }

            if (summary.hasMissingSchemas()) {
                log.warn("The statistics might be incomplete due to missing XML schemas in the input file(s).");
                exitCode = 3;
            }

            summary.print(objectMapper, (msg) -> log.print(LogLevel.INFO, msg));
        }

        return exitCode;
    }

    private boolean hasMatchingIdentifier(XMLReader reader) {
        try {
            Attributes attributes = reader.getAttributes();
            String id = attributes.getValue(GMLConstants.GML_3_2_NAMESPACE, "id").get();
            if (id == null) {
                id = attributes.getValue(GMLConstants.GML_3_1_NAMESPACE, "id").get();
            }

            return id != null && idOption.getIds().contains(id);
        } catch (XMLReadException e) {
            return false;
        }
    }

    private void writeStatistics(Path outputFile, Statistics statistics, ObjectMapper objectMapper) throws ExecutionException {
        try (JsonGenerator writer = objectMapper.createGenerator(outputFile.toFile(), JsonEncoding.UTF8)) {
            writer.setPrettyPrinter(new DefaultPrettyPrinter());
            writer.writeObject(statistics.toJson(objectMapper));
        } catch (Exception e) {
            throw new ExecutionException("Failed to write JSON file " + outputFile.toAbsolutePath() + ".", e);
        }
    }

    @Override
    public void preprocess(CommandLine commandLine) throws Exception {
        if (onlyTopLevelFeatures && generateObjectHierarchy) {
            throw new CommandLine.ParameterException(commandLine,
                    "Error: --only-top-level and --object-hierarchy are mutually exclusive (specify only one)");
        }
    }
}
