ScaffoldConventionReconciler.java

package network.ike.plugin.ws.reconcile;

import network.ike.plugin.ws.MavenWrapper;
import network.ike.workspace.IdeSettings;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import org.apache.maven.api.plugin.Log;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Reconciler that upgrades workspace scaffold conventions to the
 * current plugin version (IKE-Network/ike-issues#393). Subsumes the
 * retired {@code ws:scaffold-upgrade-{draft,publish}} goals.
 *
 * <p>This reconciler owns the cross-cutting "scaffold layer" of a
 * workspace: gitignore, gitattributes, Syncthing ignore flags, Maven
 * wrapper, IntelliJ language level, POM {@code root="true"},
 * {@code .mvn/maven.config}, and the {@code ike-tooling.version}
 * property. It does not touch Maven dependency versions or workspace
 * alignment state.
 *
 * <p>Each upgrade step is idempotent — running the reconciler twice
 * produces the same result. {@code detect} reports the steps that
 * would change state; {@code apply} performs the mutations.
 *
 * <h2>Upgrade steps</h2>
 * <ol>
 *   <li><b>global-gitignore</b> — ensure {@code .ike/vcs-state} and
 *       {@code _git-init*} are in the user's global gitignore.</li>
 *   <li><b>workspace-gitignore</b> — sectioned whitelist enforcement
 *       for the workspace {@code .gitignore}.</li>
 *   <li><b>stignore-shared</b> — {@code (?d)} prefix on directory
 *       ignore patterns in Syncthing's {@code stignore-shared}.</li>
 *   <li><b>pom-root</b> — Maven 4.1.0 {@code root="true"} on the
 *       workspace POM.</li>
 *   <li><b>maven-config</b> — ensure {@code .mvn/maven.config} exists
 *       with {@code -T 1C}.</li>
 *   <li><b>gitattributes</b> — line-ending policy at workspace root
 *       (ike-issues#189: Windows {@code mvnw.cmd} must be CRLF).</li>
 *   <li><b>mvnw</b> — regenerate missing Maven wrapper files, and
 *       replace the legacy custom wrapper with the standard
 *       {@code only-script} Apache wrapper (ike-issues#405).</li>
 *   <li><b>ide-language-level</b> — apply the {@code ide:} section
 *       from {@code workspace.yaml} to {@code .idea/misc.xml}.</li>
 *   <li><b>plugin-version</b> — bump {@code ike-tooling.version} in
 *       the workspace POM (and migrate the legacy
 *       {@code ike-maven-plugin.version} property name).</li>
 * </ol>
 *
 * <p>Opt out with {@code -DupdateScaffold=false}.
 */
public class ScaffoldConventionReconciler implements Reconciler {

    @Override
    public String dimension() {
        return "Scaffold conventions";
    }

    @Override
    public String optOutFlag() {
        return "updateScaffold";
    }

    @Override
    public DriftReport detect(WorkspaceContext ctx) {
        Run run = compute(ctx, false);
        if (run.drift.isEmpty()) {
            return DriftReport.noDrift(dimension());
        }
        List<String> detail = new ArrayList<>(run.drift);
        String summary = run.drift.size() + " scaffold convention(s) drift";
        String action = "apply scaffold upgrades on scaffold-publish";
        String optOut = "mvn ws:scaffold-publish -D" + optOutFlag() + "=false";
        return new DriftReport(dimension(), true, summary, detail, action, optOut);
    }

    @Override
    public void apply(WorkspaceContext ctx) {
        if (ctx.options().isOptedOut(optOutFlag())) {
            ctx.log().info("  " + dimension() + ": skipped (opted out via -D"
                    + optOutFlag() + "=false)");
            return;
        }
        Run run = compute(ctx, true);
        if (run.applied == 0) {
            return;
        }
        ctx.log().info("  " + dimension() + ": applied "
                + run.applied + " upgrade(s)");
    }

    // ── Drift run (shared by detect and apply) ──────────────────────

    /**
     * Mutable accumulator for one reconciliation pass. {@code drift}
     * collects human-readable summaries of steps that would change
     * state (used by {@link #detect}); {@code applied} counts the
     * steps that actually wrote to disk (used by {@link #apply}).
     */
    private static final class Run {
        final List<String> drift = new ArrayList<>();
        int applied = 0;
    }

    /**
     * Resolve the plugin's own version from manifest metadata. Falls
     * back to a non-zero default when the manifest entry is missing
     * (which happens in test classpath setups). Matches the original
     * Mojo's fallback to "49".
     */
    private static String resolvePluginVersion() {
        String v = ScaffoldConventionReconciler.class.getPackage()
                .getImplementationVersion();
        return v != null ? v : "49";
    }

    private Run compute(WorkspaceContext ctx, boolean publish) {
        Run run = new Run();
        Path root = ctx.workspaceRoot().toPath();
        String pluginVersion = resolvePluginVersion();
        Log log = ctx.log();

        upgradeGlobalGitignore(run, publish, log);
        upgradeWorkspaceGitignore(root, run, publish, log);
        upgradeStignoreShared(root, run, publish, log);
        upgradePomRoot(root, run, publish, log);
        upgradeMavenConfig(root, run, publish, log);
        upgradeGitattributes(root, run, publish, log);
        upgradeMvnw(ctx, root, run, publish, log);
        upgradeIdeLanguageLevel(ctx, root, run, publish, log);
        upgradePluginVersion(root, pluginVersion, run, publish, log);

        return run;
    }

    // ── 1. Global gitignore ────────────────────────────────────────

    private static void upgradeGlobalGitignore(Run run, boolean publish, Log log) {
        Path globalIgnore = Path.of(System.getProperty("user.home"),
                ".gitignore_global");
        try {
            String content = Files.exists(globalIgnore)
                    ? Files.readString(globalIgnore, StandardCharsets.UTF_8)
                    : "";

            boolean needsVcsState = !content.contains("vcs-state");
            boolean needsGitInit = !content.contains("_git-init");

            if (!needsVcsState && !needsGitInit) {
                return;
            }

            StringBuilder additions = new StringBuilder();
            if (needsGitInit) additions.append("_git-init*\n");
            if (needsVcsState) additions.append(".ike/vcs-state\n");

            run.drift.add("global-gitignore: add "
                    + (needsVcsState ? ".ike/vcs-state " : "")
                    + (needsGitInit ? "_git-init*" : "").trim());

            if (publish) {
                Files.writeString(globalIgnore,
                        content + (content.endsWith("\n") ? "" : "\n")
                                + additions,
                        StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update global gitignore: " + e.getMessage());
        }
    }

    // ── 2. Workspace .gitignore whitelist ──────────────────────────

    /**
     * Required {@code .gitignore} entries grouped into named sections.
     * Order mirrors what {@code WorkspaceBootstrap#generateGitignore()}
     * emits so that output from {@code ws:scaffold-init} and this
     * reconciler stays visually consistent.
     */
    static final List<GitignoreSection> GITIGNORE_SECTIONS = List.of(
            new GitignoreSection(
                    "# ── Whitelist workspace-level files ──────────────────────────────",
                    "!.gitignore", "!.gitattributes", "!pom.xml", "!workspace.yaml"
            ),
            new GitignoreSection(
                    "# ── Whitelist workspace-owned directories ────────────────────────",
                    "!.mvn/", "!.mvn/**", "!checkpoints/", "!checkpoints/**"
            ),
            new GitignoreSection(
                    "# ── IntelliJ project config (curated slice) ──────────────────────\n"
                            + "# Small, stable project-wide settings shared across collaborators.\n"
                            + "# compiler.xml and vcs.xml are excluded — they regenerate per\n"
                            + "# Maven reload or per workspace membership.",
                    "!.idea/", "!.idea/.gitignore", "!.idea/misc.xml",
                    "!.idea/kotlinc.xml", "!.idea/encodings.xml",
                    "!.idea/jarRepositories.xml"
            )
    );

    /**
     * A named group of {@code .gitignore} entries sharing a section
     * header comment. The reconciler emits the full header when no
     * entries from this section are present; otherwise appends only
     * the missing entries individually.
     *
     * @param header  the section header comment block
     * @param entries the required entries for this section
     */
    record GitignoreSection(String header, List<String> entries) {
        GitignoreSection(String header, String... entries) {
            this(header, List.of(entries));
        }
    }

    /**
     * Compute the additions needed to bring an existing {@code .gitignore}
     * up to spec. Package-private for test access.
     *
     * @param content the current {@code .gitignore} content
     * @return the text to append (empty string if already current)
     */
    public static String computeGitignoreAdditions(String content) {
        Set<String> existingLines = new LinkedHashSet<>();
        for (String line : content.split("\n")) {
            existingLines.add(line.trim());
        }

        StringBuilder additions = new StringBuilder();
        for (GitignoreSection section : GITIGNORE_SECTIONS) {
            List<String> missing = new ArrayList<>();
            for (String entry : section.entries()) {
                if (!existingLines.contains(entry)) {
                    missing.add(entry);
                }
            }
            if (missing.isEmpty()) {
                continue;
            }
            boolean fullSection = missing.size() == section.entries().size();
            if (fullSection) {
                additions.append("\n").append(section.header()).append("\n");
            }
            for (String entry : missing) {
                additions.append(entry).append("\n");
            }
        }
        return additions.toString();
    }

    private static void upgradeWorkspaceGitignore(Path root, Run run,
                                                   boolean publish, Log log) {
        Path gitignore = root.resolve(".gitignore");
        try {
            if (!Files.exists(gitignore)) {
                return;
            }

            String content = Files.readString(gitignore, StandardCharsets.UTF_8);
            String additions = computeGitignoreAdditions(content);

            if (additions.isEmpty()) {
                return;
            }

            run.drift.add("workspace-gitignore: add missing whitelist entries");

            if (publish) {
                Files.writeString(gitignore,
                        content + (content.endsWith("\n") ? "" : "\n")
                                + additions,
                        StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update .gitignore: " + e.getMessage());
        }
    }

    // ── 3. stignore-shared (?d) flags ──────────────────────────────

    private static void upgradeStignoreShared(Path root, Run run,
                                               boolean publish, Log log) {
        Path stignore = root.getParent() != null
                ? root.getParent().resolve("stignore-shared")
                : null;
        if (stignore == null || !Files.exists(stignore)) {
            return;
        }

        try {
            String content = Files.readString(stignore, StandardCharsets.UTF_8);
            boolean changed = false;
            String updated = content;

            String[][] upgrades = {
                    {"\n.git/", "\n(?d).git/"},
                    {"\ntarget/", "\n(?d)target/"},
                    {"\nout/", "\n(?d)out/"},
                    {"\n.gradle/", "\n(?d).gradle/"},
                    {"\nbuild/", "\n(?d)build/"},
            };
            for (String[] pair : upgrades) {
                if (updated.contains(pair[0]) && !updated.contains(pair[1])) {
                    updated = updated.replace(pair[0], pair[1]);
                    changed = true;
                }
            }
            if (updated.startsWith(".git/") && !updated.startsWith("(?d).git/")) {
                updated = "(?d)" + updated;
                changed = true;
            }

            if (!changed) {
                return;
            }

            run.drift.add("stignore-shared: add (?d) prefix to directory patterns");

            if (publish) {
                Files.writeString(stignore, updated, StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update stignore-shared: " + e.getMessage());
        }
    }

    // ── 4. POM root="true" ────────────────────────────────────────

    private static void upgradePomRoot(Path root, Run run,
                                        boolean publish, Log log) {
        Path pom = root.resolve("pom.xml");
        try {
            if (!Files.exists(pom)) {
                return;
            }

            String content = Files.readString(pom, StandardCharsets.UTF_8);
            if (content.contains("root=\"true\"")) {
                return;
            }

            String updated = content.replaceFirst(
                    "(<project\\s[^>]*?)(>)",
                    "$1\n         root=\"true\"$2");

            run.drift.add("pom-root: add root=\"true\" to <project>");

            if (publish) {
                Files.writeString(pom, updated, StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update pom.xml: " + e.getMessage());
        }
    }

    // ── 5. .mvn/maven.config ──────────────────────────────────────

    private static void upgradeMavenConfig(Path root, Run run,
                                            boolean publish, Log log) {
        Path config = root.resolve(".mvn/maven.config");
        try {
            if (Files.exists(config)) {
                return;
            }

            run.drift.add("maven-config: create .mvn/maven.config with -T 1C");

            if (publish) {
                Files.createDirectories(config.getParent());
                Files.writeString(config, "-T 1C\n", StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not create .mvn/maven.config: " + e.getMessage());
        }
    }

    // ── 6. .gitattributes ─────────────────────────────────────────

    /**
     * Header comment block emitted when {@code .gitattributes} is
     * created from scratch.
     */
    static final String GITATTRIBUTES_HEADER = """
            # Line-ending policy — managed by ws:scaffold-publish
            # ──────────────────────────────────────────────────────
            #
            # Windows batch files MUST be CRLF or cmd.exe chokes — symptoms range
            # from "The system cannot find the path specified" to silent failure.
            # Without this file, mvnw.cmd checked out under core.autocrlf=false
            # (or input) is broken on Windows.
            #
            # Tracked in IKE-Network/ike-issues#189.
            """;

    /**
     * Required {@code .gitattributes} rules, in the order they should
     * appear when the file is created from scratch.
     */
    static final List<String> GITATTRIBUTES_RULES = List.of(
            "*.cmd  text eol=crlf",
            "*.bat  text eol=crlf",
            "*.sh   text eol=lf",
            "mvnw   text eol=lf",
            "* text=auto"
    );

    /**
     * Compute additions needed to bring an existing {@code .gitattributes}
     * up to spec. Detects each required rule by its leading whitespace-
     * separated pattern token. Returns the empty string when the file
     * is already current. Package-private for test access.
     *
     * @param content the current {@code .gitattributes} content (empty
     *                string when the file does not exist)
     * @return the text to append (empty when already current)
     */
    public static String computeGitattributesAdditions(String content) {
        Set<String> presentPatterns = new LinkedHashSet<>();
        for (String line : content.split("\n")) {
            String trimmed = line.trim();
            if (trimmed.isEmpty() || trimmed.startsWith("#")) {
                continue;
            }
            String[] tokens = trimmed.split("\\s+", 2);
            if (tokens.length > 0) {
                presentPatterns.add(tokens[0]);
            }
        }

        List<String> missing = new ArrayList<>();
        for (String rule : GITATTRIBUTES_RULES) {
            String pattern = rule.split("\\s+", 2)[0];
            if (!presentPatterns.contains(pattern)) {
                missing.add(rule);
            }
        }

        if (missing.isEmpty()) {
            return "";
        }

        StringBuilder additions = new StringBuilder();
        boolean creating = content.isEmpty();
        if (creating) {
            additions.append(GITATTRIBUTES_HEADER);
            additions.append('\n');
        }
        for (String rule : missing) {
            additions.append(rule).append('\n');
        }
        return additions.toString();
    }

    private static void upgradeGitattributes(Path root, Run run,
                                              boolean publish, Log log) {
        Path gitattributes = root.resolve(".gitattributes");
        try {
            String content = Files.exists(gitattributes)
                    ? Files.readString(gitattributes, StandardCharsets.UTF_8)
                    : "";

            String additions = computeGitattributesAdditions(content);
            if (additions.isEmpty()) {
                return;
            }

            boolean creating = content.isEmpty();
            run.drift.add("gitattributes: "
                    + (creating ? "create with canonical rules"
                                : "add missing rules"));

            if (publish) {
                String updated = creating
                        ? additions
                        : content + (content.endsWith("\n") ? "" : "\n")
                                + additions;
                Files.writeString(gitattributes, updated, StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update .gitattributes: " + e.getMessage());
        }
    }

    // ── 7. Maven wrapper ───────────────────────────────────────────

    private static void upgradeMvnw(WorkspaceContext ctx, Path root, Run run,
                                     boolean publish, Log log) {
        Path propsFile = root.resolve(".mvn").resolve("wrapper")
                .resolve("maven-wrapper.properties");
        Path mvnw = root.resolve("mvnw");
        Path mvnwCmd = root.resolve("mvnw.cmd");

        boolean propsMissing = !Files.exists(propsFile);
        boolean mvnwMissing = !Files.exists(mvnw);
        boolean mvnwCmdMissing = !Files.exists(mvnwCmd);

        boolean legacy;
        try {
            legacy = MavenWrapper.isLegacyWrapper(root);
        } catch (IOException e) {
            log.warn("  Could not inspect Maven wrapper: " + e.getMessage());
            return;
        }

        if (!propsMissing && !mvnwMissing && !mvnwCmdMissing && !legacy) {
            return;
        }

        String mavenVersion = resolveMavenVersionForUpgrade(ctx, root, log);
        if (mavenVersion == null) {
            // No version configured — skip cleanly.
            return;
        }

        if (legacy) {
            run.drift.add("mvnw: replace legacy custom wrapper with standard "
                    + "only-script wrapper (Maven " + mavenVersion + ")");
            if (publish) {
                try {
                    MavenWrapper.writeAll(root, mavenVersion);
                    run.applied++;
                } catch (IOException e) {
                    log.warn("  Could not replace Maven wrapper: " + e.getMessage());
                }
            }
            return;
        }

        List<String> created = new ArrayList<>();
        if (propsMissing) created.add(".mvn/wrapper/maven-wrapper.properties");
        if (mvnwMissing) created.add("mvnw");
        if (mvnwCmdMissing) created.add("mvnw.cmd");

        run.drift.add("mvnw: create " + String.join(", ", created)
                + " (Maven " + mavenVersion + ")");

        if (publish) {
            try {
                MavenWrapper.writeMissingFiles(root, mavenVersion);
                run.applied++;
            } catch (IOException e) {
                log.warn("  Could not install Maven wrapper: " + e.getMessage());
            }
        }
    }

    /**
     * Resolve the Maven version to write into a regenerated wrapper.
     * Prefers an existing {@code maven.version} in the wrapper
     * properties file; falls back to {@code defaults.maven-version}
     * from {@code workspace.yaml}.
     */
    private static String resolveMavenVersionForUpgrade(WorkspaceContext ctx,
                                                         Path root, Log log) {
        try {
            String pinned = MavenWrapper.readPinnedVersion(root);
            if (pinned != null && !pinned.isBlank()) {
                return pinned;
            }
        } catch (IOException e) {
            log.warn("  Could not read maven-wrapper.properties: "
                    + e.getMessage());
        }
        try {
            Manifest m = ManifestReader.read(ctx.manifestPath());
            if (m.defaults() != null && m.defaults().mavenVersion() != null
                    && !m.defaults().mavenVersion().isBlank()) {
                return m.defaults().mavenVersion();
            }
        } catch (ManifestException e) {
            log.debug("Could not read workspace.yaml for maven-version: "
                    + e.getMessage());
        }
        return null;
    }

    // ── 8. IntelliJ language level ────────────────────────────────

    private static void upgradeIdeLanguageLevel(WorkspaceContext ctx, Path root,
                                                 Run run, boolean publish,
                                                 Log log) {
        Path misc = root.resolve(".idea").resolve("misc.xml");
        if (!Files.exists(misc)) {
            return;
        }

        IdeSettings ide;
        try {
            Manifest m = ManifestReader.read(ctx.manifestPath());
            ide = m.ide();
        } catch (ManifestException e) {
            log.warn("  IDE language level: could not read workspace.yaml: "
                    + e.getMessage());
            return;
        }

        if (!ide.hasAnyValue()) {
            return;
        }

        try {
            String content = Files.readString(misc, StandardCharsets.UTF_8);
            String updated = applyIdeSettings(content, ide);
            if (updated.equals(content)) {
                return;
            }

            run.drift.add("ide-language-level: set to "
                    + (ide.languageLevel() != null
                            ? ide.languageLevel() : "(jdk-name only)"));

            if (publish) {
                Files.writeString(misc, updated, StandardCharsets.UTF_8);
                run.applied++;
            }
        } catch (IOException e) {
            log.warn("  Could not update .idea/misc.xml: " + e.getMessage());
        }
    }

    /**
     * Apply {@code ide.languageLevel} and {@code ide.jdkName} to the
     * {@code ProjectRootManager} component in {@code .idea/misc.xml}
     * content. Returns the updated content, or the original if no
     * change is needed. Package-private for test access.
     *
     * @param content the current {@code misc.xml} content
     * @param ide     the settings to enforce (null fields are no-ops)
     * @return the updated content
     */
    public static String applyIdeSettings(String content, IdeSettings ide) {
        String updated = content;
        if (ide.languageLevel() != null) {
            updated = replaceProjectRootAttr(updated, "languageLevel",
                    ide.languageLevel());
        }
        if (ide.jdkName() != null) {
            updated = replaceProjectRootAttr(updated, "project-jdk-name",
                    ide.jdkName());
        }
        return updated;
    }

    private static String replaceProjectRootAttr(String content, String attr,
                                                  String value) {
        Pattern p = Pattern.compile(
                "(<component\\s+name=\"ProjectRootManager\"[^>]*\\b"
                        + Pattern.quote(attr) + "=\")([^\"]*)(\")");
        Matcher m = p.matcher(content);
        if (!m.find()) {
            return content;
        }
        if (m.group(2).equals(value)) {
            return content;
        }
        return m.replaceFirst(Matcher.quoteReplacement(m.group(1))
                + Matcher.quoteReplacement(value)
                + Matcher.quoteReplacement(m.group(3)));
    }

    // ── 9. Plugin version ─────────────────────────────────────────

    private static void upgradePluginVersion(Path root, String pluginVersion,
                                              Run run, boolean publish,
                                              Log log) {
        Path pom = root.resolve("pom.xml");
        try {
            if (!Files.exists(pom)) {
                return;
            }

            String content = Files.readString(pom, StandardCharsets.UTF_8);
            boolean changed = false;
            List<String> notes = new ArrayList<>();

            // Migration: rename ike-maven-plugin.version → ike-tooling.version.
            if (content.contains("<ike-maven-plugin.version>")) {
                content = content.replace(
                        "<ike-maven-plugin.version>", "<ike-tooling.version>");
                content = content.replace(
                        "</ike-maven-plugin.version>", "</ike-tooling.version>");
                content = content.replace(
                        "${ike-maven-plugin.version}", "${ike-tooling.version}");
                changed = true;
                notes.add("rename ike-maven-plugin.version → ike-tooling.version");
            }

            Pattern versionProp = Pattern.compile(
                    "(<ike-tooling\\.version>)(.*?)(</ike-tooling\\.version>)");
            Matcher m = versionProp.matcher(content);

            if (!m.find()) {
                if (changed) {
                    finalizePluginVersion(pom, content, publish, run, notes);
                }
                return;
            }

            String currentVersion = m.group(2);
            if (!currentVersion.equals(pluginVersion)) {
                content = m.replaceFirst("$1" + pluginVersion + "$3");
                changed = true;
                notes.add("ike-tooling.version " + currentVersion
                        + " → " + pluginVersion);
            }

            if (!changed) {
                return;
            }
            finalizePluginVersion(pom, content, publish, run, notes);
        } catch (IOException e) {
            log.warn("  Could not update plugin version: " + e.getMessage());
        }
    }

    private static void finalizePluginVersion(Path pom, String content,
                                                boolean publish, Run run,
                                                List<String> notes)
            throws IOException {
        run.drift.add("plugin-version: " + String.join("; ", notes));
        if (publish) {
            Files.writeString(pom, content, StandardCharsets.UTF_8);
            run.applied++;
        }
    }

}