module ldcBuildRuntime;

import core.stdc.stdlib : exit;
import std.algorithm;
import std.array;
import std.file;
import std.path;
import std.stdio;

struct Config {
    string ldcExecutable;
    string buildDir;
    bool resetBuildDir;
    bool resetOnly;
    string ldcSourceDir;
    bool ninja;
    bool buildTestrunners;
    string[] targetSystem;
    string[] dFlags;
    string[] cFlags;
    string[] linkerFlags;
    uint numBuildJobs;
    string[string] cmakeVars;
}

version (Windows) enum exeSuffix = ".exe";
else              enum exeSuffix = "";

string defaultLdcExecutable;
Config config;

int main(string[] args) {
    enum exeName = "ldc2" ~ exeSuffix;
    defaultLdcExecutable = buildPath(thisExePath.dirName, exeName);

    parseCommandLine(args);

    findLdcExecutable();
    prepareBuildDir();

    if (config.resetOnly) {
        writefln(".: Build directory successfully reset (%s)", config.buildDir);
        return 0;
    }

    prepareLdcSource();
    runCMake();
    build();
    generateTestRunnerXcodeProjects();

    writefln(".: Runtime libraries built successfully into: %s", config.buildDir);
    return 0;
}

void findLdcExecutable() {
    if (config.ldcExecutable !is null) {
        if (!config.ldcExecutable.exists) {
            writefln(".: Error: LDC executable not found: %s", config.ldcExecutable);
            exit(1);
        }
        config.ldcExecutable = config.ldcExecutable.absolutePath;
        return;
    }

    if (defaultLdcExecutable.exists) {
        config.ldcExecutable = defaultLdcExecutable;
        return;
    }

    writefln(".: Please specify LDC executable via '--ldc=<path/to/ldc2%s>'. Aborting.", exeSuffix);
    exit(1);
}

void prepareBuildDir() {
    if (config.buildDir is null)
        config.buildDir = "ldc-build-runtime.tmp";

    if (config.buildDir.exists) {
        if (!config.resetBuildDir) {
            writefln(".: Warning: build directory already exists: %s", config.buildDir);
        } else {
            writefln(".: Resetting build directory: %s", config.buildDir);
            auto items = dirEntries(config.buildDir, SpanMode.shallow, false).array;
            const ldcSrc = buildPath(config.buildDir, "ldc-src");
            foreach (i; items) {
                if (i.isFile) {
                    remove(i.name);
                } else if (i.isDir && i.name != ldcSrc) {
                    rmdirRecurse(i.name);
                }
            }
        }
    } else {
        writefln(".: Creating build directory: %s", config.buildDir);
        mkdirRecurse(config.buildDir);
    }

    config.buildDir = config.buildDir.absolutePath;
}

void prepareLdcSource() {
    if (config.ldcSourceDir !is null) {
        if (!config.ldcSourceDir.exists) {
            writefln(".: Error: LDC source directory not found: %s", config.ldcSourceDir);
            exit(1);
        }
        config.ldcSourceDir = config.ldcSourceDir.absolutePath;
        return;
    }

    const ldcSrc = "ldc-src";
    config.ldcSourceDir = buildPath(config.buildDir, ldcSrc);
    if (buildPath(config.ldcSourceDir, "runtime").exists)
        return;

    // Download & extract LDC source archive if <buildDir>/ldc-src/runtime doesn't exist yet.

    const wd = WorkingDirScope(config.buildDir);

    auto ldcVersion = "@LDC_VERSION@";
    void removeVersionSuffix(string beginning) {
        const suffixIndex = ldcVersion.countUntil(beginning);
        if (suffixIndex > 0)
            ldcVersion = ldcVersion[0 .. suffixIndex];
    }
    removeVersionSuffix("git-");
    removeVersionSuffix("-dirty");

    import std.format : format;
    const localArchiveFile = "ldc-src.zip";
    if (!localArchiveFile.exists) {
        const url = "https://github.com/ldc-developers/ldc/releases/download/v%1$s/ldc-%1$s-src.zip".format(ldcVersion);
        writefln(".: Downloading LDC source archive: %s", url);
        import std.net.curl : download;
        download(url, localArchiveFile);
        if (getSize(localArchiveFile) < 1_048_576) {
            writefln(".: Error: downloaded file is corrupt; has LDC v%s been released?", ldcVersion);
            writefln("   You can work around this by manually downloading a src package and moving it to: %s",
                     buildPath(config.buildDir, localArchiveFile));
            localArchiveFile.remove;
            exit(1);
        }
    }

    extractZipArchive(localArchiveFile, ".");
    rename("ldc-%1$s-src".format(ldcVersion), ldcSrc);
}

void runCMake() {
    const wd = WorkingDirScope(config.buildDir);

    string[] args = [
        "cmake",
        "-DLDC_EXE_FULL=" ~ config.ldcExecutable,
        "-DDMDFE_MINOR_VERSION=@DMDFE_MINOR_VERSION@",
        "-DDMDFE_PATCH_VERSION=@DMDFE_PATCH_VERSION@",
    ];

    if (config.targetSystem.length) args ~= "-DTARGET_SYSTEM=" ~ config.targetSystem.join(";");
    if (config.dFlags.length)       args ~= "-DD_EXTRA_FLAGS=" ~ config.dFlags.join(";");
    if (config.cFlags.length)       args ~= "-DRT_CFLAGS=" ~ config.cFlags.join(" ");
    if (config.linkerFlags.length)  args ~= "-DLD_FLAGS=" ~ config.linkerFlags.join(" ");

    foreach (pair; config.cmakeVars.byPair)
        args ~= "-D" ~ pair[0] ~ '=' ~ pair[1];

    if (config.ninja)
        args ~= [ "-G", "Ninja" ];

    args ~= buildPath(config.ldcSourceDir, "runtime");

    exec(args);
}

void build() {
    const wd = WorkingDirScope(config.buildDir);

    string[] args = [ config.ninja ? "ninja" : "make" ];
    if (config.numBuildJobs != 0) {
        import std.conv : to;
        args ~= "-j" ~ config.numBuildJobs.to!string;
    }
    args ~= "all";
    if (config.buildTestrunners)
        args ~= "all-test-runners";

    exec(args);
}

/**
 * Generates Xcode projects for running the unit test for druntime and Phobos
 * on an iOS device.
 *
 * This works by coping the `runtime/TestRunnerTemplate` directory to the build
 * directory. Inside the `runtime/TestRunnerTemplate` directory is an Xcode
 * project located. This project acts as a template. The `project.pbxproj` file
 * inside the Xcode project contains variables, denoted by `{{ var }}`, which
 * are replaced with the actual values when the template is rendered.
 *
 * The Xcode project has been created using Xcode 11.3.1 (11C504) and then the
 * `project.pbxproj` file has been manually edited to replace the original
 * values with variables.
 */
void generateTestRunnerXcodeProjects() {
    import std.uni : icmp;

    const struct ProjectContext {
        string druntimeArchive;
        string phobosArchive;
        string deploymentTarget;
        string objectPath;
        string libraryPath;
    }

    static string renderTemplate(string temp, ProjectContext context) {
        import std.conv : text;

        string result = temp;

        foreach (i, _; typeof(ProjectContext.tupleof)) {
            enum name = __traits(identifier, ProjectContext.tupleof[i]);
            result = result.replace("{{ " ~ name ~ " }}", context.tupleof[i].text);
        }

        return result;
    }

    static void copyRecurse(string source, string destination) {
        mkdirRecurse(destination);

        foreach (e; dirEntries(source, SpanMode.breadth)) {
            const newPath = destination.buildPath(e[source.length + 1.. $]);

            if (e.isDir)
                mkdirRecurse(newPath);
            else
                copy(e.name, newPath);
        }
    }

    const systemName = config.cmakeVars.get("CMAKE_SYSTEM_NAME", null);

    if (systemName.icmp("iOS") != 0)
        return;

    const templatePath = config.ldcSourceDir.buildPath("runtime", "TestRunnerTemplate");
    const targetPath = config.buildDir.buildPath("TestRunner").absolutePath;

    copyRecurse(templatePath, targetPath);

    ProjectContext context = {
        druntimeArchive: "libdruntime-ldc-unittest-debug.a",
        phobosArchive: "libphobos2-ldc-unittest-debug.a",
        deploymentTarget: config.cmakeVars["CMAKE_OSX_DEPLOYMENT_TARGET"],
        objectPath: config.buildDir.buildPath("objects-unittest-debug"),
        libraryPath: config.buildDir.buildPath("lib")
    };

    const pbxprojPath = targetPath.buildPath("TestRunner.xcodeproj", "project.pbxproj");
    const projectContent = readText(pbxprojPath);
    const result = renderTemplate(projectContent, context);
    std.file.write(pbxprojPath, result);
}

/*** helpers ***/

struct WorkingDirScope {
    string originalPath;
    this(string path) { originalPath = getcwd(); chdir(path); }
    ~this() { chdir(originalPath); }
}

void exec(string[] command) {
    import std.process;

    static string quoteIfNeeded(string arg) {
        const r = arg.findAmong(" ;");
        return !r.length ? arg : "'" ~ arg ~ "'";
    }
    string flattened = command.map!quoteIfNeeded.join(" ");
    writefln(".: Invoking: %s", flattened);
    stdout.flush();

    auto pid = spawnProcess(command);
    const exitStatus = wait(pid);

    if (exitStatus != 0) {
        writeln(".: Error: command failed with status ", exitStatus);
        exit(1);
    }
}

void extractZipArchive(string archivePath, string destination) {
    import std.zip;

    auto archive = new ZipArchive(std.file.read(archivePath));
    foreach (name, am; archive.directory) {
        const destPath = buildNormalizedPath(destination, name);

        const isDir = name.endsWith("/");
        const destDir = isDir ? destPath : destPath.dirName;
        mkdirRecurse(destDir);

        if (!isDir)
            std.file.write(destPath, archive.expand(am));
    }
}

void parseCommandLine(string[] args) {
    import std.getopt : arraySep, getopt, defaultGetoptPrinter;

    try {
        arraySep = ";";
        auto helpInformation = getopt(
            args,
            "ldc",         "Path to LDC executable (default: '" ~ defaultLdcExecutable ~ "')", &config.ldcExecutable,
            "buildDir",    "Path to build directory (default: './ldc-build-runtime.tmp')", &config.buildDir,
            "reset",       "If build directory exists, start with removing everything but the ldc-src subdirectory", &config.resetBuildDir,
            "resetOnly",   "Like --reset, but only resets the build directory. No other actions are taken.", &config.resetOnly,
            "ldcSrcDir",   "Path to LDC source directory (if not specified: downloads & extracts source archive into '<buildDir>/ldc-src')", &config.ldcSourceDir,
            "ninja",       "Use Ninja as CMake build system", &config.ninja,
            "testrunners", "Build the testrunner executables too", &config.buildTestrunners,
            "targetSystem","Target OS/platform definitions (separated by ';'), e.g., 'Windows;MSVC' or 'Android;Linux;UNIX'. Defaults to the host OS/platform", &config.targetSystem,
            "dFlags",      "Extra LDC flags for the D modules (separated by ';')", &config.dFlags,
            "cFlags",      "Extra C/ASM compiler flags for the handful of C/ASM files (separated by ';')", &config.cFlags,
            "linkerFlags", "Extra C linker flags for shared libraries and testrunner executables (separated by ';')", &config.linkerFlags,
            "j",           "Number of parallel build jobs", &config.numBuildJobs
        );

        // getopt() has removed all consumed args from `args`
        foreach (arg; args[1 .. $]) {
            const r = arg.findSplit("=");
            if (r[1].length == 0) {
                helpInformation.helpWanted = true;
                break;
            }
            config.cmakeVars[r[0]] = r[2];
        }

        if (helpInformation.helpWanted) {
            defaultGetoptPrinter(
                "Builds the LDC runtime libraries.\n\n" ~
                "Programs required to be found in your PATH:\n" ~
                "  * CMake\n" ~
                "  * either Make or Ninja (recommended, enable with '--ninja')\n" ~
                "  * C toolchain (compiler and linker)\n\n" ~
                "All arguments are optional.\n" ~
                "CMake variables (see runtime/CMakeLists.txt in LDC source) can be specified via arguments like 'VAR=value'.\n",
                helpInformation.options
            );
            exit(1);
        }

        if (config.resetOnly) config.resetBuildDir = true;
    }
    catch (Exception e) {
        writefln("Error processing command line arguments: %s", e.msg);
        writeln("Use '--help' for help.");
        exit(1);
    }
}
