package org.implab.gradle.variants.sources;

import java.io.File;
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.SourceDirectorySet;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.tasks.TaskProvider;

/**
 * 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.
 * Source and output base directories are explicit model properties. They do
 * not define source directories or outputs by themselves; plugins and adapters
 * may attach conventions or use them as layout hints for their own
 * materialization rules.
 * </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 {
    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) {
        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());
    }

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

    /**
     * Base directory associated with this source set.
     *
     * <p>
     * This property is intentionally convention-free in the base model. A
     * plugin may attach a convention when it knows the surrounding layout
     * policy, for example when materializing variant compile units.
     * </p>
     */
    public abstract DirectoryProperty getSourceSetDir();

    /**
     * Base directory associated with outputs of this source set.
     *
     * <p>
     * This property is a layout hint. Actual named outputs are declared and
     * populated through {@link #declareOutputs(String, String...)} and
     * {@link #registerOutput(String, Object...)}.
     * </p>
     */
    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);
    }

    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());
    }

}
