package org.implab.gradle.common.sources;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import javax.inject.Inject;

import org.implab.gradle.common.core.lang.Closures;
import org.implab.gradle.common.core.lang.Strings;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.gradle.api.Action;
import org.gradle.api.InvalidUserDataException;
import org.gradle.api.NamedDomainObjectProvider;
import org.gradle.api.file.ProjectLayout;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;

import groovy.lang.Closure;
import groovy.lang.DelegatesTo;

import static org.implab.gradle.common.core.lang.Strings.sanitizeName;

/**
 * Adapter extension that registers source sets for variant/layer pairs.
 */
@NonNullByDefault
public abstract class VariantSourcesExtension {
    private static final Logger logger = Logging.getLogger(VariantSourcesExtension.class);
    private static final Pattern SOURCE_SET_NAME_TOKEN = Pattern.compile("\\{([A-Za-z][A-Za-z0-9]*)\\}");

    private final ObjectFactory objects;
    private final ProjectLayout layout;
    private final LinkedHashMap<String, LayerBinding> bindingsByName = new LinkedHashMap<>();
    private final List<Action<? super SourceSetRegistration>> registeredActions = new ArrayList<>();
    private final List<Action<? super SourceSetUsageBinding>> boundActions = new ArrayList<>();
    private final List<SourceSetRegistration> registeredContexts = new ArrayList<>();
    private final List<SourceSetUsageBinding> boundContexts = new ArrayList<>();
    private final LinkedHashMap<String, NamedDomainObjectProvider<GenericSourceSet>> sourceSetsByName = new LinkedHashMap<>();
    private final LinkedHashMap<String, String> sourceSetLayersByName = new LinkedHashMap<>();
    private boolean sourceSetsRegistered;

    @Inject
    public VariantSourcesExtension(ObjectFactory objects, ProjectLayout layout) {
        this.objects = objects;
        this.layout = layout;
    }

    public List<LayerBindingSpec> getBindings() {
        return bindingsByName.values().stream().map(x -> (LayerBindingSpec)x).toList();
    }

    public LayerBindingSpec bind(String layer) {
        return bindingsByName.computeIfAbsent(
                normalize(layer, "Layer name must not be null or blank"),
                name -> new LayerBinding(name, objects));
    }

    public LayerBindingSpec bind(LayoutLayer layer) {
        return bind(layer.getName());
    }

    /**
     * Configures per-layer binding.
     */
    public LayerBindingSpec bind(String layer, Action<? super LayerBindingSpec> configure) {
        var binding = bind(layer);
        configure.execute(binding);
        return binding;
    }

    public LayerBindingSpec bind(LayoutLayer layer, Action<? super LayerBindingSpec> configure) {
        var binding = bind(layer);
        configure.execute(binding);
        return binding;
    }

    public LayerBindingSpec bind(String layer,
            @DelegatesTo(value = LayerBindingSpec.class, strategy = Closure.DELEGATE_FIRST) Closure<?> configure) {
        return bind(layer, Closures.action(configure));
    }

    public LayerBindingSpec bind(LayoutLayer layer,
            @DelegatesTo(value = LayerBindingSpec.class, strategy = Closure.DELEGATE_FIRST) Closure<?> configure) {
        return bind(layer, Closures.action(configure));
    }

    /**
     * Global callback fired for each registered source set.
     * Already emitted registrations are delivered immediately (replay).
     * For simple callbacks you can use delegate-only style
     * (for example {@code whenRegistered { sourceSetName() }}).
     * For nested closures prefer explicit parameter
     * ({@code whenRegistered { ctx -> ... }}).
     */
    public void whenRegistered(Action<? super SourceSetRegistration> action) {
        registeredActions.add(action);
        for (var context : registeredContexts)
            action.execute(context);
    }

    public void whenRegistered(
            @DelegatesTo(value = SourceSetRegistration.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
        whenRegistered(Closures.action(action));
    }

    /**
     * Global callback fired for every resolved variant/role/layer usage.
     * Already emitted usage bindings are delivered immediately (replay).
     * For simple callbacks you can use delegate-only style
     * (for example {@code whenBound { variantName() }}).
     * For nested closures prefer explicit parameter
     * ({@code whenBound { ctx -> ... }}).
     */
    public void whenBound(Action<? super SourceSetUsageBinding> action) {
        boundActions.add(action);
        for (var context : boundContexts)
            action.execute(context);
    }

    public void whenBound(
            @DelegatesTo(value = SourceSetUsageBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
        whenBound(Closures.action(action));
    }

    public void whenBound(String variantName, Action<? super SourceSetUsageBinding> action) {
        var normalizedVariantName = normalize(variantName, "variantName must not be null or blank");
        whenBound(filterByVariant(normalizedVariantName, action));
    }

    public void whenBound(String variantName,
            @DelegatesTo(value = SourceSetUsageBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
        whenBound(variantName, Closures.action(action));
    }

    void registerSourceSets(BuildVariantsExtension variants, org.gradle.api.NamedDomainObjectContainer<GenericSourceSet> sources) {
        if (sourceSetsRegistered) {
            throw new InvalidUserDataException("variantSources source sets are already registered");
        }

        resolveBindings(variants);

        var usages = layerUsages(variants).toList();
        var registeredBefore = registeredContexts.size();
        var boundBefore = boundContexts.size();

        logger.debug(
                "Starting variant source-set registration (variants={}, layers={}, bindings={}, usages={})",
                variants.getVariants().size(),
                variants.getLayers().size(),
                bindingsByName.size(),
                usages.size());

        usages.forEach(usage -> registerLayerUsage(usage, sources));

        logger.debug(
                "Completed variant source-set registration (newSourceSets={}, newBounds={}, totalSourceSets={})",
                registeredContexts.size() - registeredBefore,
                boundContexts.size() - boundBefore,
                sourceSetsByName.size());

        sourceSetsRegistered = true;
    }

    private Stream<LayerUsage> layerUsages(BuildVariantsExtension variants) {
        return variants.getVariants().stream()
                .flatMap(variant -> variant.getRoles().stream()
                        .flatMap(role -> role.getLayers().getOrElse(List.of()).stream()
                                .map(layerName -> new LayerUsage(
                                        variant.getName(),
                                        role.getName(),
                                        variants.requireLayer(normalize(layerName, "Layer name in variant '"
                                                + variant.getName() + "' and role '" + role.getName()
                                                + "' must not be null or blank"))))));
    }

    private void registerLayerUsage(LayerUsage usage, org.gradle.api.NamedDomainObjectContainer<GenericSourceSet> sources) {
        var resolvedBinding = binding(usage.layer().getName());
        var sourceSetNamePattern = resolvedBinding.getSourceSetNamePattern();
        sourceSetNamePattern.finalizeValueOnRead();

        var sourceSetName = sourceSetName(usage, sourceSetNamePattern.get());

        ensureSourceSetNameBoundToSingleLayer(sourceSetName, usage.layer().getName());
        var isNewSourceSet = !sourceSetsByName.containsKey(sourceSetName);
        var sourceSet = sourceSetsByName.computeIfAbsent(sourceSetName,
                name -> {
                    var ssp = sources.register(name);
                    ssp.configure(x -> {
                        x.getSourceSetDir().set(layout.getProjectDirectory().dir("src/" + usage.layer().getName()));
                    });
                    return ssp;
                });

        var binding = new SourceSetUsageBinding(
                usage.variantName(),
                usage.roleName(),
                usage.layer().getName(),
                sourceSetName,
                sourceSet);

        if (isNewSourceSet) {
            var registration = new SourceSetRegistration(
                    usage.layer().getName(),
                    sourceSetName,
                    sourceSet);
            resolvedBinding.notifyRegistered(registration);
            notifyRegistered(registration);
        }

        resolvedBinding.notifyBound(binding);
        notifyBound(binding);
    }

    private void notifyRegistered(SourceSetRegistration registration) {
        registeredContexts.add(registration);
        for (var action : registeredActions)
            action.execute(registration);
    }

    private void notifyBound(SourceSetUsageBinding binding) {
        boundContexts.add(binding);
        for (var action : boundActions)
            action.execute(binding);
    }

    private static Action<? super SourceSetUsageBinding> filterByVariant(String variantName,
            Action<? super SourceSetUsageBinding> action) {
        return binding -> {
            if (variantName.equals(binding.variantName()))
                action.execute(binding);
        };
    }

    private void ensureSourceSetNameBoundToSingleLayer(String sourceSetName, String layerName) {
        var existingLayer = sourceSetLayersByName.putIfAbsent(sourceSetName, layerName);
        if (existingLayer != null && !existingLayer.equals(layerName)) {
            throw new InvalidUserDataException("Source set '" + sourceSetName + "' is resolved from multiple layers: '"
                    + existingLayer + "' and '" + layerName + "'");
        }
    }

    private void resolveBindings(BuildVariantsExtension variants) {
        var errors = new ArrayList<String>();
        for (var binding : bindingsByName.values()) {
            if (variants.findLayer(binding.getName()).isEmpty()) {
                errors.add("Layer binding '" + binding.getName() + "' references unknown layer");
            }
        }

        if (!errors.isEmpty()) {
            var message = new StringBuilder("Invalid variantSources model:");
            for (var error : errors)
                message.append("\n - ").append(error);
            throw new InvalidUserDataException(message.toString());
        }
    }

    private LayerBinding binding(String layerName) {
        return bindingsByName.computeIfAbsent(layerName, name -> new LayerBinding(name, objects));
    }

    private static String sourceSetName(LayerUsage usage, String pattern) {
        var normalizedPattern = normalize(pattern, "sourceSetNamePattern must not be null or blank");
        var resolved = resolveSourceSetNamePattern(normalizedPattern, usage);
        var result = sanitizeName(resolved);

        if (result.isEmpty())
            throw new InvalidUserDataException(
                    "sourceSetNamePattern '" + pattern + "' resolved to empty source set name");

        return result;
    }

    private static String resolveSourceSetNamePattern(String pattern, LayerUsage usage) {
        var matcher = SOURCE_SET_NAME_TOKEN.matcher(pattern);
        var output = new StringBuffer();

        while (matcher.find()) {
            var token = matcher.group(1);
            matcher.appendReplacement(output, Matcher.quoteReplacement(tokenValue(token, usage)));
        }
        matcher.appendTail(output);

        return output.toString();
    }

    private static String tokenValue(String token, LayerUsage usage) {
        return switch (token) {
            case "variant" -> sanitizeName(usage.variantName());
            case "variantCap" -> Strings.capitalize(sanitizeName(usage.variantName()));
            case "role" -> sanitizeName(usage.roleName());
            case "roleCap" -> Strings.capitalize(sanitizeName(usage.roleName()));
            case "layer" -> sanitizeName(usage.layer().getName());
            case "layerCap" -> Strings.capitalize(sanitizeName(usage.layer().getName()));
            default -> throw new InvalidUserDataException(
                    "sourceSetNamePattern contains unsupported token '{" + token + "}'");
        };
    }

    private static String normalize(@Nullable String value, String errorMessage) {
        if (value == null)
            throw new InvalidUserDataException(errorMessage);
        var trimmed = value.trim();
        if (trimmed.isEmpty())
            throw new InvalidUserDataException(errorMessage);
        return trimmed;
    }

    private record LayerUsage(String variantName, String roleName, LayoutLayer layer) {
    }
}
