InjectSiteStylesMojo.java

package com.guinetik.terminaljavadocs.plugin;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;

/**
 * Injects Terminal Javadocs styling (CSS + JS) into all HTML files
 * generated by the Maven site.
 *
 * <p>
 * This Mojo runs after site generation and:
 * <ul>
 * <li>Copies page-type-specific CSS files to the site directory</li>
 * <li>Recursively scans all HTML files in the site directory</li>
 * <li>Detects page type (coverage, jxr, javadoc, site)</li>
 * <li>Injects the appropriate CSS and JS for each page type</li>
 * <li>Supports nested sites (mono-repo style)</li>
 * </ul>
 *
 * <p>
 * The CSS/JS files are built from the css-zen-garden design system
 * using npm and output to this plugin's resources.
 */
@Mojo(name = "inject-styles", defaultPhase = org.apache.maven.plugins.annotations.LifecyclePhase.POST_SITE)
public class InjectSiteStylesMojo extends AbstractMojo {

    /**
     * Page types with their corresponding CSS files.
     *
     * <p>
     * Each page type maps to a specific minified CSS file that provides
     * Terminal Javadocs styling for that category of generated documentation.
     */
    public enum PageType {
        /** Landing pages (coverage.html, source-xref.html). */
        LANDING("landing", "terminaljavadocs-landing.min.css"),
        /** JaCoCo coverage report pages. */
        COVERAGE("coverage", "terminaljavadocs-coverage.min.css"),
        /** JXR source cross-reference pages. */
        JXR("jxr", "terminaljavadocs-jxr.min.css"),
        /** Javadoc API documentation pages. */
        JAVADOC("javadoc", "terminaljavadocs-javadoc.min.css"),
        /** General Maven site pages. */
        SITE("site", "terminaljavadocs-site.min.css");

        /** The short name used in injection markers. */
        private final String name;

        /** The minified CSS filename for this page type. */
        private final String cssFile;

        /**
         * Creates a page type with its associated CSS file.
         *
         * @param name    the short name for logging and markers
         * @param cssFile the minified CSS filename
         */
        PageType(String name, String cssFile) {
            this.name = name;
            this.cssFile = cssFile;
        }

        /**
         * Returns the short name of this page type.
         *
         * @return the name used in injection markers (e.g., "javadoc")
         */
        public String getName() {
            return name;
        }

        /**
         * Returns the CSS filename for this page type.
         *
         * @return the minified CSS filename (e.g., "terminaljavadocs-javadoc.min.css")
         */
        public String getCssFile() {
            return cssFile;
        }
    }

    /** Resource path prefix for styles within the plugin JAR. */
    private static final String STYLES_RESOURCE_PATH = "styles/";

    /** Filename of the bundled JavaScript file. */
    private static final String JS_FILE = "terminaljavadocs.min.js";

    /** Resource path prefix for themed JaCoCo images within the plugin JAR. */
    private static final String JACOCO_RESOURCES_PATH = "jacoco-resources/";

    /**
     * JaCoCo resource files to copy (themed coverage bar images).
     * These replace JaCoCo's default images with Terminal Javadocs styled versions.
     */
    private static final String[] JACOCO_RESOURCE_FILES = {
        "branchfc.gif", "branchnc.gif", "branchpc.gif",
        "greenbar.gif", "redbar.gif",
        "down.gif", "up.gif", "sort.gif"
    };

    /**
     * HTML comment marker to detect already-injected pages.
     * Prevents duplicate style injection on re-runs.
     */
    private static final String INJECTION_MARKER = "<!-- terminal-javadocs-injected";

    /**
     * The current Maven session, providing access to reactor projects.
     */
    @Parameter(defaultValue = "${session}", readonly = true, required = true)
    private MavenSession session;

    /**
     * The current Maven project being built.
     */
    @Parameter(defaultValue = "${project}", readonly = true, required = true)
    private MavenProject project;

    /**
     * Skip style injection when set to {@code true}.
     * Can be set via {@code -Dterminaljavadocs.skip=true}.
     */
    @Parameter(property = "terminaljavadocs.skip", defaultValue = "false")
    private boolean skip;

    /**
     * The project's build output directory (typically {@code target/}).
     */
    @Parameter(defaultValue = "${project.build.directory}", readonly = true)
    private File buildDirectory;

    /**
     * Directory name where styles will be copied within the site.
     */
    @Parameter(property = "terminaljavadocs.stylesDir", defaultValue = "terminal-styles")
    private String stylesDir;

    /**
     * Whether to process nested module sites.
     */
    @Parameter(property = "terminaljavadocs.processNestedSites", defaultValue = "true")
    private boolean processNestedSites;

    /**
     * Project name for branding in the navigation header.
     * Replaces the %%PROJECT_NAME%% token in JavaScript.
     */
    @Parameter(property = "terminaljavadocs.project.name", defaultValue = "${project.name}")
    private String projectName;

    /**
     * Project logo URL for branding in the navigation header.
     * Replaces the %%PROJECT_LOGO%% token in JavaScript.
     * Can be an absolute URL or a path relative to the site root.
     */
    @Parameter(property = "terminaljavadocs.project.logo", defaultValue = "")
    private String projectLogo;

    /** Counter for total HTML files processed. */
    private int processedFiles = 0;

    /** Counter for landing pages processed. */
    private int landingFiles = 0;

    /** Counter for coverage report pages processed. */
    private int coverageFiles = 0;

    /** Counter for JXR source pages processed. */
    private int jxrFiles = 0;

    /** Counter for Javadoc pages processed. */
    private int javadocFiles = 0;

    /** Counter for general site pages processed. */
    private int siteFiles = 0;

    /**
     * Executes the style injection goal.
     *
     * <p>
     * This method:
     * <ol>
     * <li>Locates the site output directory (staging or site)</li>
     * <li>Copies CSS and JS resources to the styles directory</li>
     * <li>Replaces JaCoCo's default images with themed versions</li>
     * <li>Recursively processes all HTML files, injecting appropriate styles</li>
     * <li>Processes nested module sites if enabled</li>
     * </ol>
     *
     * @throws MojoExecutionException if file operations fail
     */
    @Override
    public void execute() throws MojoExecutionException {
        if (skip) {
            getLog().info("Skipping style injection");
            return;
        }

        try {
            // Determine the site directory - prefer staging if it exists
            File siteDir = new File(buildDirectory, "staging");
            if (!siteDir.exists()) {
                siteDir = new File(buildDirectory, "site");
            }

            if (!siteDir.exists()) {
                getLog().info("No site directory found, skipping style injection");
                return;
            }

            getLog().info("Injecting Terminal Javadocs styles into site: " + siteDir.getAbsolutePath());

            // Copy style resources to the site directory
            File stylesTargetDir = new File(siteDir, stylesDir);
            copyStyleResources(stylesTargetDir);

            // Replace JaCoCo's default resources with themed versions
            themeJacocoResources(siteDir);

            // Process the main site
            processHtmlFiles(siteDir, siteDir);

            // Process nested module sites if enabled
            if (processNestedSites) {
                List<MavenProject> projects = session.getProjects();

                // First, process staged module subdirectories (for site:stage)
                // When using site:stage, modules are aggregated as subdirectories within
                // target/staging
                if (siteDir.getName().equals("staging")) {
                    for (MavenProject reactorProject : projects) {
                        if (reactorProject.equals(project)) {
                            continue; // Skip parent
                        }

                        String artifactId = reactorProject.getArtifactId();
                        File moduleStagedDir = new File(siteDir, artifactId);

                        if (moduleStagedDir.exists() && moduleStagedDir.isDirectory()) {
                            // Copy styles to staged module subdirectory
                            File moduleStylesDir = new File(moduleStagedDir, stylesDir);
                            copyStyleResources(moduleStylesDir);

                            // Replace JaCoCo resources in this module
                            themeJacocoResources(moduleStagedDir);

                            getLog().info("Processing staged module site: " + artifactId);
                            processHtmlFiles(moduleStagedDir, moduleStagedDir);
                        }
                    }
                }

                // Fallback: process individual module site directories (for mvn site without
                // staging)
                for (MavenProject reactorProject : projects) {
                    if (reactorProject.equals(project)) {
                        continue; // Skip parent, already processed
                    }

                    String buildDir = reactorProject.getBuild().getDirectory();

                    // Check staging first, then site
                    File moduleSiteDir = new File(buildDir, "staging");
                    if (!moduleSiteDir.exists()) {
                        moduleSiteDir = new File(buildDir, "site");
                    }

                    if (moduleSiteDir.exists()) {
                        String artifactId = reactorProject.getArtifactId();

                        // Copy styles to module site
                        File moduleStylesDir = new File(moduleSiteDir, stylesDir);
                        copyStyleResources(moduleStylesDir);

                        // Replace JaCoCo resources in this module
                        themeJacocoResources(moduleSiteDir);

                        getLog().info("Processing individual module site: " + artifactId);
                        processHtmlFiles(moduleSiteDir, moduleSiteDir);
                    }
                }
            }

            // Log statistics
            getLog().info("Style injection complete:");
            getLog().info("  Total HTML files processed: " + processedFiles);
            getLog().info("  Landing pages: " + landingFiles);
            getLog().info("  Coverage pages: " + coverageFiles);
            getLog().info("  JXR pages: " + jxrFiles);
            getLog().info("  Javadoc pages: " + javadocFiles);
            getLog().info("  Site pages: " + siteFiles);

        } catch (IOException e) {
            throw new MojoExecutionException("Failed to inject styles", e);
        }
    }

    /**
     * Copies all style resources (CSS and JS) from the plugin JAR to the target directory.
     * JavaScript files are processed for token replacement.
     *
     * @param targetDir the directory to copy resources to (will be created if needed)
     * @throws IOException if file copying fails
     */
    private void copyStyleResources(File targetDir) throws IOException {
        targetDir.mkdirs();

        // Copy CSS for each page type
        for (PageType pageType : PageType.values()) {
            copyResource(
                    STYLES_RESOURCE_PATH + pageType.getCssFile(),
                    new File(targetDir, pageType.getCssFile()));
        }

        // Copy JS with token replacement
        copyJsWithTokenReplacement(STYLES_RESOURCE_PATH + JS_FILE, new File(targetDir, JS_FILE));
    }

    /**
     * Copies a JavaScript file with token replacement for project branding.
     *
     * <p>
     * Replaces the following tokens:
     * <ul>
     * <li>{@code %%PROJECT_NAME%%} - replaced with {@link #projectName}</li>
     * <li>{@code %%PROJECT_LOGO%%} - replaced with {@link #projectLogo}</li>
     * </ul>
     *
     * @param resourcePath the classpath resource path to copy from
     * @param targetFile   the target file to copy to
     * @throws IOException if file reading or writing fails
     */
    private void copyJsWithTokenReplacement(String resourcePath, File targetFile) throws IOException {
        try (InputStream is = getResourceStream(resourcePath)) {
            if (is != null) {
                // Java 8 compatible way to read all bytes from InputStream
                java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
                byte[] data = new byte[4096];
                int bytesRead;
                while ((bytesRead = is.read(data, 0, data.length)) != -1) {
                    buffer.write(data, 0, bytesRead);
                }
                String content = new String(buffer.toByteArray(), StandardCharsets.UTF_8);

                // Replace tokens
                content = content.replace("%%PROJECT_NAME%%", projectName != null ? projectName : "");
                content = content.replace("%%PROJECT_LOGO%%", projectLogo != null ? projectLogo : "");

                Files.write(targetFile.toPath(), content.getBytes(StandardCharsets.UTF_8));
                getLog().debug("Copied JS with token replacement: " + resourcePath + " -> " + targetFile);
                getLog().debug("  PROJECT_NAME: " + projectName);
                getLog().debug("  PROJECT_LOGO: " + projectLogo);
            } else {
                getLog().warn("Resource not found: " + resourcePath +
                        ". Run 'npm run build' in css-zen-garden to generate it.");
            }
        }
    }

    /**
     * Copies a single resource file from the plugin JAR to the filesystem.
     *
     * @param resourcePath the classpath resource path to copy from
     * @param targetFile   the target file to copy to
     * @throws IOException if file copying fails
     */
    private void copyResource(String resourcePath, File targetFile) throws IOException {
        try (InputStream is = getResourceStream(resourcePath)) {
            if (is != null) {
                Files.copy(is, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
                getLog().debug("Copied resource: " + resourcePath + " -> " + targetFile);
            } else {
                getLog().warn("Resource not found: " + resourcePath +
                        ". Run 'npm run build' in css-zen-garden to generate it.");
            }
        }
    }

    /**
     * Copies themed JaCoCo resources (coverage bar images) to a jacoco-resources directory.
     *
     * @param jacocoResourcesDir the JaCoCo resources directory to update
     * @throws IOException if file copying fails
     */
    private void copyJacocoResources(File jacocoResourcesDir) throws IOException {
        if (!jacocoResourcesDir.exists()) {
            return;
        }

        for (String fileName : JACOCO_RESOURCE_FILES) {
            copyResource(JACOCO_RESOURCES_PATH + fileName, new File(jacocoResourcesDir, fileName));
        }
        getLog().debug("Copied themed JaCoCo resources to: " + jacocoResourcesDir);
    }

    /**
     * Finds and themes all JaCoCo resource directories in the given site directory.
     * Recursively walks the directory tree looking for {@code jacoco-resources} folders.
     *
     * @param siteDir the site directory to search
     * @throws IOException if directory traversal or file copying fails
     */
    private void themeJacocoResources(File siteDir) throws IOException {
        Files.walkFileTree(siteDir.toPath(), new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                if (dir.getFileName() != null && dir.getFileName().toString().equals("jacoco-resources")) {
                    copyJacocoResources(dir.toFile());
                }
                return FileVisitResult.CONTINUE;
            }
        });
    }

    /**
     * Gets an input stream for a classpath resource, trying multiple classloaders.
     *
     * <p>
     * Attempts loading in this order:
     * <ol>
     * <li>Thread context classloader</li>
     * <li>Class classloader</li>
     * <li>Direct resource stream with leading slash</li>
     * </ol>
     *
     * @param resourcePath the resource path to load
     * @return the input stream, or {@code null} if not found
     */
    private InputStream getResourceStream(String resourcePath) {
        InputStream is = Thread.currentThread()
                .getContextClassLoader()
                .getResourceAsStream(resourcePath);

        if (is == null) {
            is = InjectSiteStylesMojo.class.getClassLoader().getResourceAsStream(resourcePath);
        }

        if (is == null) {
            is = InjectSiteStylesMojo.class.getResourceAsStream("/" + resourcePath);
        }

        return is;
    }

    /**
     * Processes all HTML files in the given directory recursively.
     *
     * @param directory the directory to scan for HTML files
     * @param siteRoot  the root of the site (for relative path calculations)
     * @throws IOException if file traversal or processing fails
     */
    private void processHtmlFiles(File directory, File siteRoot) throws IOException {
        Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                if (file.toString().endsWith(".html")) {
                    processHtmlFile(file.toFile(), siteRoot);
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
                getLog().warn("Failed to visit file: " + file + " - " + exc.getMessage());
                return FileVisitResult.CONTINUE;
            }
        });
    }

    /**
     * Processes a single HTML file by detecting its type and injecting appropriate styles.
     *
     * @param htmlFile the HTML file to process
     * @param siteRoot the root of the site (for relative path calculations)
     * @throws IOException if file reading or writing fails
     */
    private void processHtmlFile(File htmlFile, File siteRoot) throws IOException {
        String content = new String(Files.readAllBytes(htmlFile.toPath()), StandardCharsets.UTF_8);

        // Check if styles are already injected (avoid duplicate injection)
        if (content.contains(INJECTION_MARKER)) {
            getLog().debug("Skipping already injected file: " + htmlFile);
            return;
        }

        // Detect page type
        PageType pageType = detectPageType(htmlFile, content);

        // Calculate relative path to styles directory
        String relativePath = calculateRelativePath(htmlFile, siteRoot);

        // Generate the style injection snippet
        String styleSnippet = generateStyleSnippet(pageType, relativePath);

        // Inject styles before </head>
        String modifiedContent = injectStyles(content, styleSnippet);

        if (modifiedContent != null) {
            Files.write(htmlFile.toPath(), modifiedContent.getBytes(StandardCharsets.UTF_8));
            processedFiles++;

            // Update statistics
            switch (pageType) {
                case LANDING:
                    landingFiles++;
                    break;
                case COVERAGE:
                    coverageFiles++;
                    break;
                case JXR:
                    jxrFiles++;
                    break;
                case JAVADOC:
                    javadocFiles++;
                    break;
                case SITE:
                    siteFiles++;
                    break;
            }

            getLog().debug("Injected " + pageType.getName() + " styles into: " + htmlFile);
        }
    }

    /**
     * Detects the page type based on file path and content.
     *
     * <p>
     * Detection priority:
     * <ol>
     * <li>Filename match (coverage.html, source-xref.html)</li>
     * <li>Path-based detection (/jacoco/, /xref/, /apidocs/)</li>
     * <li>Content-based detection (HTML markers)</li>
     * <li>Default to SITE type</li>
     * </ol>
     *
     * @param htmlFile the HTML file being processed
     * @param content  the file's content
     * @return the detected page type
     */
    private PageType detectPageType(File htmlFile, String content) {
        String path = htmlFile.getAbsolutePath().replace('\\', '/').toLowerCase();
        String fileName = htmlFile.getName().toLowerCase();

        // Landing pages (generated by generate-landing-pages goal)
        if (fileName.equals("coverage.html") || fileName.equals("source-xref.html")) {
            return PageType.LANDING;
        }

        // Path-based detection (most reliable)
        if (path.contains("/jacoco/") || path.contains("/coverage/")) {
            return PageType.COVERAGE;
        }
        if (path.contains("/xref/") || path.contains("/xref-test/")) {
            return PageType.JXR;
        }
        if (path.contains("/apidocs/") || path.contains("/testapidocs/") || path.contains("/javadoc/")) {
            return PageType.JAVADOC;
        }

        // Content-based detection (fallback)
        if (isLandingContent(content)) {
            return PageType.LANDING;
        }
        if (isJacocoContent(content)) {
            return PageType.COVERAGE;
        }
        if (isJxrContent(content)) {
            return PageType.JXR;
        }
        if (isJavadocContent(content)) {
            return PageType.JAVADOC;
        }

        // Default to site
        return PageType.SITE;
    }

    /**
     * Checks if content is from a landing page (coverage.html or source-xref.html).
     *
     * @param content the HTML content to check
     * @return {@code true} if landing page markers are found
     */
    private boolean isLandingContent(String content) {
        return content.contains("class=\"terminal-header\"") ||
                content.contains("class=\"terminal-brand\"") ||
                content.contains("class=\"module-list\"");
    }

    /**
     * Checks if content is from a JaCoCo coverage report.
     *
     * @param content the HTML content to check
     * @return {@code true} if JaCoCo markers are found
     */
    private boolean isJacocoContent(String content) {
        return content.contains("jacoco") ||
                content.contains("Coverage Report") ||
                content.contains("class=\"el_package\"") ||
                content.contains("class=\"el_class\"") ||
                content.contains("class=\"ctr2\"");
    }

    /**
     * Checks if content is from a JXR source cross-reference page.
     *
     * @param content the HTML content to check
     * @return {@code true} if JXR markers are found
     */
    private boolean isJxrContent(String content) {
        return content.contains("jxr") ||
                content.contains("Cross-Reference") ||
                content.contains("class=\"jxr_") ||
                content.contains("id=\"jxr_");
    }

    /**
     * Checks if content is from Javadoc API documentation.
     *
     * @param content the HTML content to check
     * @return {@code true} if Javadoc markers are found
     */
    private boolean isJavadocContent(String content) {
        return content.contains("Generated by javadoc") ||
                content.contains("<!-- Generated by javadoc") ||
                content.contains("class=\"summary-table\"") ||
                content.contains("class=\"member-signature\"") ||
                content.contains("class=\"description\"");
    }

    /**
     * Calculates the relative path from an HTML file's directory to the site root.
     *
     * @param htmlFile the HTML file being processed
     * @param siteRoot the root directory of the site
     * @return the relative path with trailing slash (e.g., "../../../")
     */
    private String calculateRelativePath(File htmlFile, File siteRoot) {
        Path htmlPath = htmlFile.toPath().getParent();
        Path rootPath = siteRoot.toPath();

        try {
            Path relativePath = htmlPath.relativize(rootPath);
            String result = relativePath.toString().replace('\\', '/');
            if (result.isEmpty()) {
                return "./";
            }
            if (!result.endsWith("/")) {
                result += "/";
            }
            return result;
        } catch (IllegalArgumentException e) {
            // Paths are on different roots, use absolute
            return "./";
        }
    }

    /**
     * Generates the HTML snippet to inject for a given page type.
     *
     * <p>
     * The snippet includes:
     * <ul>
     * <li>An HTML comment marker for detection</li>
     * <li>A CSS link tag for the page-specific stylesheet</li>
     * <li>A deferred script tag for the JavaScript bundle</li>
     * </ul>
     *
     * @param pageType     the type of page being processed
     * @param relativePath the relative path to the site root
     * @return the HTML snippet to inject
     */
    private String generateStyleSnippet(PageType pageType, String relativePath) {
        String stylesPath = relativePath + stylesDir + "/";

        return "\n<!-- " + INJECTION_MARKER + " [" + pageType.getName() + "] -->\n" +
                "<link rel=\"stylesheet\" href=\"" + stylesPath + pageType.getCssFile() + "\">\n" +
                "<script src=\"" + stylesPath + JS_FILE + "\" defer></script>\n";
    }

    /**
     * Injects the style snippet into HTML content before the closing head tag.
     *
     * <p>
     * Injection strategy:
     * <ol>
     * <li>Try to inject before {@code </head>} (preferred)</li>
     * <li>Fall back to after {@code <head>} if no closing tag</li>
     * <li>Return {@code null} if no head section found</li>
     * </ol>
     *
     * @param content      the HTML content to modify
     * @param styleSnippet the style snippet to inject
     * @return the modified content, or {@code null} if injection failed
     */
    private String injectStyles(String content, String styleSnippet) {
        // Try to inject before </head>
        Pattern headPattern = Pattern.compile("(</head>)", Pattern.CASE_INSENSITIVE);
        Matcher matcher = headPattern.matcher(content);

        if (matcher.find()) {
            return content.substring(0, matcher.start()) +
                    styleSnippet +
                    content.substring(matcher.start());
        }

        // If no </head>, try after <head>
        Pattern headOpenPattern = Pattern.compile("(<head[^>]*>)", Pattern.CASE_INSENSITIVE);
        Matcher openMatcher = headOpenPattern.matcher(content);

        if (openMatcher.find()) {
            return content.substring(0, openMatcher.end()) +
                    styleSnippet +
                    content.substring(openMatcher.end());
        }

        // No head section found
        getLog().warn("No <head> section found in HTML, cannot inject styles");
        return null;
    }
}