package org.implab.gradle.variants.sources;

import java.io.File;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.inject.Inject;

import org.gradle.api.InvalidUserDataException;
import org.gradle.api.Named;
import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.Task;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.ProjectLayout;
import org.gradle.api.file.SourceDirectorySet;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.util.Configurable;
import org.implab.gradle.common.core.lang.Closures;

import groovy.lang.Closure;

/**
 * A configurable source set abstraction with named outputs.
 *
 * <p>
 * Each instance aggregates multiple {@link SourceDirectorySet source sets}
 * under a shared name and exposes typed outputs that must be declared up front.
 * Default locations are {@code src/<name>} for sources and
 * {@code build/<name>} for outputs, both of which can be customized via the
 * exposed {@link DirectoryProperty} setters.
 * </p>
 *
 * <p>
 * Outputs are grouped by names to make task wiring explicit. An output must be
 * declared with {@link #declareOutputs(String, String...)} before files can be
 * registered against it. Attempting to register or retrieve an undeclared
 * output results in
 * {@link InvalidUserDataException}.
 * </p>
 */
public abstract class GenericSourceSet
        implements Named, Configurable<GenericSourceSet> {
    private final String name;

    private final NamedDomainObjectContainer<SourceDirectorySet> sourceDirectorySets;

    private final Map<String, ConfigurableFileCollection> outputs;

    private final FileCollection allOutputs;

    private final FileCollection allSourceDirectories;

    private final ObjectFactory objects;

    private final Set<String> declaredOutputs = new HashSet<>();

    @Inject
    public GenericSourceSet(String name, ObjectFactory objects, ProjectLayout layout) {
        this.name = name;
        this.objects = objects;

        sourceDirectorySets = objects.domainObjectContainer(
                SourceDirectorySet.class,
                this::createSourceDirectorySet);

        outputs = new LinkedHashMap<>();

        allSourceDirectories = objects.fileCollection().from(sourceDirectoriesProvider());

        allOutputs = objects.fileCollection().from(outputsProvider());

        getSourceSetDir().convention(layout
                .getProjectDirectory()
                .dir(Paths.get("src", name).toString()));

        getOutputsDir().convention(layout
                .getBuildDirectory()
                .dir(name));
    }

    @Override
    public String getName() {
        return name;
    }

    /**
     * Base directory for this source set. Defaults to {@code src/<name>} under
     * the project directory.
     */
    public abstract DirectoryProperty getSourceSetDir();

    /**
     * Base directory for outputs of this source set. Defaults to
     * {@code build/<name>}.
     */
    public abstract DirectoryProperty getOutputsDir();

    /**
     * The container of {@link SourceDirectorySet} instances that belong to this
     * logical source set.
     */
    public NamedDomainObjectContainer<SourceDirectorySet> getSets() {
        return sourceDirectorySets;
    }

    /**
     * All registered outputs grouped across output names.
     */
    public FileCollection getAllOutputs() {
        return allOutputs;
    }

    /**
     * All source directories from every contained {@link SourceDirectorySet}.
     */
    public FileCollection getAllSourceDirectories() {
        return allSourceDirectories;
    }

    /**
     * Returns the file collection for the specified output name, creating it
     * if necessary.
     *
     * @throws InvalidUserDataException if the output was not declared
     */
    public FileCollection output(String name) {
        return configurableOutput(name);
    }

    private ConfigurableFileCollection configurableOutput(String name) {
        requireDeclaredOutput(name);
        return outputs.computeIfAbsent(name, key -> objects.fileCollection());
    }

    /**
     * Declares allowed output names. Outputs must be declared before registering
     * files under them.
     */
    public void declareOutputs(String name, String... extra) {
        Stream.concat(Stream.of(name), Stream.of(extra))
                .map(Objects::requireNonNull)
                .forEach(declaredOutputs::add);
    }

    /**
     * Registers files produced elsewhere under the given output.
     */
    public void registerOutput(String name, Object... files) {
        configurableOutput(name).from(files);
    }

    /**
     * Registers output files produced by a task, using a mapper to extract the
     * output from the task. The task will be added as a build dependency of this
     * output.
     */
    public <T extends Task> void registerOutput(String name, TaskProvider<T> task,
            Function<? super T, ?> mapper) {
        configurableOutput(name).from(task.map(mapper::apply))
                .builtBy(task);
    }

    /**
     * Applies a Groovy closure to this source set, enabling DSL-style
     * configuration.
     */
    @Override
    public GenericSourceSet configure(Closure configure) {
        Closures.apply(configure, this);
        return this;
    }

    private SourceDirectorySet createSourceDirectorySet(String name) {
        return objects.sourceDirectorySet(name, name);
    }

    private void requireDeclaredOutput(String outputName) {
        if (!declaredOutputs.contains(outputName)) {
            throw new InvalidUserDataException(
                    "Output '" + outputName + "' is not declared for source set '" + name + "'");
        }
    }

    private Callable<List<? extends FileCollection>> outputsProvider() {
        return () -> outputs.values().stream().toList();
    }

    private Callable<Set<File>> sourceDirectoriesProvider() {
        return () -> sourceDirectorySets.stream()
                .flatMap(x -> x.getSrcDirs().stream())
                .collect(Collectors.toSet());
    }

}
