package org.implab.gradle.common.utils;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Immutable Semantic Version (SemVer 2.0.0) of the form:
 *
 * <pre>
 * MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
 * </pre>
 *
 * Ordering (as defined by {@link #compareTo(SemVersion)}) follows
 * the SemVer 2.0.0 precedence rules:
 * <ul>
 * <li>Compare {@code major}, then {@code minor}, then {@code patch}.</li>
 * <li>Pre-release versions have lower precedence than the corresponding
 * normal version.</li>
 * <li>Build metadata does not affect precedence.</li>
 * </ul>
 *
 * Public API does not use {@code null} for semantic values:
 * {@link Optional} is used for pre-release and build metadata.
 */
public record SemVersion(
        int major,
        int minor,
        int patch,
        Optional<String> preRelease,
        Optional<String> buildMetadata) implements Comparable<SemVersion> {

    // Pattern close to the official SemVer 2.0.0 recommendation.
    // Groups:
    // 1 - major
    // 2 - minor
    // 3 - patch
    // 4 - pre-release (without '-')
    // 5 - build metadata (without '+')
    private static final Pattern SEMVER_PATTERN = Pattern.compile(
            "^(0|[1-9]\\d*)" + // major
                    "\\.(0|[1-9]\\d*)" + // minor
                    "\\.(0|[1-9]\\d*)" + // patch
                    "(?:-((?:0|[1-9]\\d*|[0-9A-Za-z-][0-9A-Za-z-]*)" +
                    "(?:\\.(?:0|[1-9]\\d*|[0-9A-Za-z-][0-9A-Za-z-]*))*" +
                    "))?" + // pre-release
                    "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" // build metadata
    );

    /**
     * Compact constructor with basic invariants.
     */
    public SemVersion {
        if (major < 0 || minor < 0 || patch < 0) {
            throw new IllegalArgumentException("Version numbers must be >= 0");
        }
        // Be tolerant internally, but never expose null Optionals.
        preRelease = (preRelease != null) ? preRelease : Optional.empty();
        buildMetadata = (buildMetadata != null) ? buildMetadata : Optional.empty();
    }

    /**
     * Creates a version without pre-release and build metadata.
     *
     * @param major non-negative MAJOR number
     * @param minor non-negative MINOR number
     * @param patch non-negative PATCH number
     */
    public static SemVersion of(int major, int minor, int patch) {
        return new SemVersion(major, minor, patch, Optional.empty(), Optional.empty());
    }

    /**
     * Creates a version from components.
     *
     * @param major         non-negative MAJOR number
     * @param minor         non-negative MINOR number
     * @param patch         non-negative PATCH number
     * @param preRelease    optional pre-release part (without '-')
     * @param buildMetadata optional build metadata (without '+')
     */
    public static SemVersion of(
            int major,
            int minor,
            int patch,
            Optional<String> preRelease,
            Optional<String> buildMetadata) {
        return new SemVersion(
                major,
                minor,
                patch,
                Objects.requireNonNull(preRelease, "preRelease"),
                Objects.requireNonNull(buildMetadata, "buildMetadata"));
    }

    /**
     * Parses a SemVer string of the form
     * {@code MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]}.
     *
     * @param value the string to parse (must not be {@code null})
     * @return a new {@code SemVersion}
     * @throws IllegalArgumentException if the string is not a valid SemVer
     */
    public static SemVersion parse(String value) {
        Objects.requireNonNull(value, "value");

        Matcher m = SEMVER_PATTERN.matcher(value);
        if (!m.matches()) {
            throw new IllegalArgumentException("Invalid SemVer: " + value);
        }

        int major = Integer.parseInt(m.group(1));
        int minor = Integer.parseInt(m.group(2));
        int patch = Integer.parseInt(m.group(3));

        String pre = m.group(4);
        String meta = m.group(5);

        Optional<String> preOpt = Optional.ofNullable(pre);
        Optional<String> metaOpt = Optional.ofNullable(meta);

        // Extra validation for numeric pre-release identifiers (no leading zeros)
        preOpt.ifPresent(SemVersion::validatePreReleaseIdentifiers);

        return new SemVersion(major, minor, patch, preOpt, metaOpt);
    }

    private static void validatePreReleaseIdentifiers(String preRelease) {
        String[] parts = preRelease.split("\\.");
        for (String p : parts) {
            if (isNumericIdentifier(p)) {
                // numeric identifiers must not have leading zeros (except "0")
                if (p.length() > 1 && p.charAt(0) == '0') {
                    throw new IllegalArgumentException(
                            "Numeric pre-release identifier must not contain leading zeros: " + p);
                }
            }
        }
    }

    private static boolean isNumericIdentifier(String s) {
        int len = s.length();
        if (len == 0) {
            return false;
        }
        for (int i = 0; i < len; i++) {
            char c = s.charAt(i);
            if (c < '0' || c > '9') {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns {@code true} if this version has a pre-release part.
     *
     * @return {@code true} if {@link #preRelease()} is present
     */
    public boolean isPreRelease() {
        return preRelease().isPresent();
    }

    /**
     * Returns {@code true} if this version is considered "stable".
     * <p>
     * By convention:
     * <ul>
     * <li>MAJOR must be greater than 0</li>
     * <li>No pre-release part is present</li>
     * </ul>
     *
     * @return {@code true} if this version is a stable release
     */
    public boolean isStable() {
        return !isPreRelease() && major() > 0;
    }

    /**
     * Returns {@code true} if this version has strictly lower precedence
     * than the given {@code other} version according to
     * {@link #compareTo(SemVersion)}.
     *
     * @param other the version to compare to
     * @return {@code true} if {@code this.compareTo(other) < 0}
     */
    public boolean isBefore(SemVersion other) {
        return compareTo(other) < 0;
    }

    /**
     * Returns {@code true} if this version has strictly higher precedence
     * than the given {@code other} version according to
     * {@link #compareTo(SemVersion)}.
     *
     * @param other the version to compare to
     * @return {@code true} if {@code this.compareTo(other) > 0}
     */
    public boolean isAfter(SemVersion other) {
        return compareTo(other) > 0;
    }

    /**
     * Canonical SemVer string representation.
     * <p>
     * This method is also used by Jackson during serialization.
     *
     * @return canonical SemVer string, e.g. {@code "1.2.3-alpha+build.1"}
     */
    @JsonValue
    public String asString() {
        StringBuilder sb = new StringBuilder()
                .append(major).append('.')
                .append(minor).append('.')
                .append(patch);

        preRelease.ifPresent(pr -> sb.append('-').append(pr));
        buildMetadata.ifPresent(md -> sb.append('+').append(md));

        return sb.toString();
    }

    /**
     * Creates a {@code SemVersion} from a canonical SemVer string.
     * <p>
     * Jackson will use this factory method when deserializing from a JSON string.
     *
     * @param value canonical SemVer string
     * @return parsed {@code SemVersion}
     */
    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public static SemVersion fromJson(String value) {
        return parse(value);
    }

    /**
     * Compares this version to another {@link SemVersion} according to the
     * SemVer 2.0.0 precedence rules.
     * <p>
     * Build metadata is ignored for ordering.
     */
    @Override
    public int compareTo(SemVersion other) {
        // 1. major / minor / patch
        int c = Integer.compare(this.major, other.major);
        if (c != 0)
            return c;

        c = Integer.compare(this.minor, other.minor);
        if (c != 0)
            return c;

        c = Integer.compare(this.patch, other.patch);
        if (c != 0)
            return c;

        // 2. pre-release (absence > presence)
        boolean thisHasPre = this.preRelease.isPresent();
        boolean otherHasPre = other.preRelease.isPresent();

        if (!thisHasPre && !otherHasPre) {
            return 0;
        } else if (!thisHasPre) {
            // normal version > pre-release
            return 1;
        } else if (!otherHasPre) {
            return -1;
        }

        // 3. both have pre-release: compare identifiers
        return comparePreRelease(this.preRelease.get(), other.preRelease.get());
    }

    private static int comparePreRelease(String a, String b) {
        String[] aParts = a.split("\\.");
        String[] bParts = b.split("\\.");

        int len = Math.min(aParts.length, bParts.length);
        for (int i = 0; i < len; i++) {
            String ai = aParts[i];
            String bi = bParts[i];

            boolean aNum = isNumericIdentifier(ai);
            boolean bNum = isNumericIdentifier(bi);

            if (aNum && bNum) {
                int aiVal = Integer.parseInt(ai);
                int biVal = Integer.parseInt(bi);
                int c = Integer.compare(aiVal, biVal);
                if (c != 0)
                    return c;
            } else if (aNum && !bNum) {
                // numeric identifiers have lower precedence than non-numeric
                return -1;
            } else if (!aNum && bNum) {
                return 1;
            } else {
                int c = ai.compareTo(bi);
                if (c != 0)
                    return c;
            }
        }

        // If all common identifiers are equal, the shorter one has lower precedence
        return Integer.compare(aParts.length, bParts.length);
    }

    @Override
    public String toString() {
        return asString();
    }
}
