/**
A small library to help write D shell-like test scripts.
*/
module dshell_prebuilt;

public import core.stdc.stdlib : exit;

public import core.time;
public import core.thread;
public import std.meta;
public import std.exception;
public import std.array;
public import std.string;
public import std.format;
public import std.path;
public import std.file;
public import std.regex;
public import std.stdio;
public import std.process;

/**
Emulates bash environment variables. Variables set here will be availble for BASH-like expansion.
*/
struct Vars
{
    private static __gshared string[string] map;
    static void set(string name, string value)
    in { assert(value !is null); } do
    {
        const expanded = shellExpand(value);
        assert(expanded !is null, "codebug");
        map[name] = expanded;
    }
    static string get(const(char)[] name)
    {
        auto result = map.get(cast(string)name, null);
        if (result is null)
            assert(0, "Unknown variable '" ~ name ~ "'");
        return result;
    }
    static string opDispatch(string name)() { return get(name); }
    static void opDispatch(string name)(string value) { set(name, value); }
}

private alias requiredEnvVars = AliasSeq!(
    "MODEL", "RESULTS_DIR",
    "EXE", "OBJ",
    "DMD", "DFLAGS",
    "OS", "SEP", "DSEP",
    "BUILD"
);
private alias optionalEnvVars = AliasSeq!(
    "CC", "PIC_FLAG"
);
private alias allVars = AliasSeq!(
    requiredEnvVars,
    optionalEnvVars,
    "TEST_DIR", "TEST_NAME",
    "RESULTS_TEST_DIR",
    "OUTPUT_BASE", "EXTRA_FILES",
    "LIBEXT", "SOEXT"
);

static foreach (var; allVars)
{
    mixin(`string ` ~ var ~ `() { return Vars.` ~ var ~ `; }`);
}

/// called from the dshell module to initialize environment
void dshellPrebuiltInit(string testDir, string testName)
{
    foreach (var; requiredEnvVars)
    {
        Vars.set(var, requireEnv(var));
    }

    foreach (var; optionalEnvVars)
    {
        Vars.set(var, environment.get(var, ""));
    }

    Vars.set("TEST_DIR", testDir);
    Vars.set("TEST_NAME", testName);
    // reference to the resulting test_dir folder, e.g .test_results/runnable
    Vars.set("RESULTS_TEST_DIR", buildPath(RESULTS_DIR, TEST_DIR));
    // reference to the resulting files without a suffix, e.g. test_results/runnable/test123import test);
    Vars.set("OUTPUT_BASE", buildPath(RESULTS_TEST_DIR, TEST_NAME));
    // reference to the extra files directory
    Vars.set("EXTRA_FILES", buildPath(TEST_DIR, "extra-files"));
    // reference to the imports directory
    Vars.set("IMPORT_FILES", buildPath(TEST_DIR, "imports"));
    version (Windows)
    {
        Vars.set("LIBEXT", ".lib");
        Vars.set("SOEXT", ".dll");
    }
    else version (OSX)
    {
        Vars.set("LIBEXT", ".a");
        Vars.set(`SOEXT`, `.dylib`);
    }
    else
    {
        Vars.set("LIBEXT", ".a");
        Vars.set("SOEXT", ".so");
    }
}

private string requireEnv(string name)
{
    const result = environment.get(name, null);
    if (result is null)
    {
        writefln("Error: missing required environment variable '%s'", name);
        exit(1);
    }
    return result;
}

/// Exit code to return if the test is disabled for the current platform
enum DISABLED = 125;

/// Remove one or more files
void rm(scope const(char[])[] args...)
{
    foreach (arg; args)
    {
        auto expanded = shellExpand(arg);
        if (exists(expanded))
        {
            writeln("rm '", expanded, "'");
            // Use loop to workaround issue in windows with removing
            // executables after running then
            for (int sleepMsecs = 10; ; sleepMsecs *= 2)
            {
                try {
                    std.file.remove(expanded);
                    break;
                } catch (Exception e) {
                    if (sleepMsecs >= 3000)
                        throw e;
                    Thread.sleep(dur!"msecs"(sleepMsecs));
                }
            }
        }
    }
}

/// Make all parent directories needed to create the given `filename`
void mkdirFor(string filename)
{
    auto dir = dirName(filename);
    if (!exists(dir))
    {
        writefln("[INFO] mkdir -p '%s'", dir);
        mkdirRecurse(dir);
    }
}

/**
Run the given command. The `tryRun` variants return the exit code, whereas the `run` variants
will assert on a non-zero exit code.
*/
int tryRun(scope const(char[])[] args, File stdout = std.stdio.stdout,
            File stderr = std.stdio.stderr, string[string] env = null)
{
    std.stdio.stdout.write("[RUN]");
    if (env)
    {
       foreach (pair; env.byKeyValue)
       {
           std.stdio.stdout.write(" ", pair.key, "=", pair.value);
       }
    }
    std.stdio.write(" ", escapeShellCommand(args));
    if (stdout != std.stdio.stdout)
    {
        std.stdio.stdout.write(" > ", stdout.name);
    }
    // "Commit" all output from the tester
    std.stdio.stdout.writeln();
    std.stdio.stdout.flush();
    std.stdio.stderr.writeln();
    std.stdio.stderr.flush();
    auto proc = spawnProcess(args, stdin, stdout, stderr, env);
    return wait(proc);
}

/// ditto
int tryRun(string cmd, File stdout = std.stdio.stdout,
            File stderr = std.stdio.stderr, string[string] env = null)
{
    return tryRun(parseCommand(cmd), stdout, stderr, env);
}

/// ditto
void run(scope const(char[])[] args, File stdout = std.stdio.stdout,
         File stderr = std.stdio.stderr, string[string] env = null)
{
    const exitCode = tryRun(args, stdout, stderr, env);
    if (exitCode != 0)
    {
        writefln("Error: last command exited with code %s", exitCode);
        assert(0, "last command failed");
    }
}
/// ditto
void run(string cmd, File stdout = std.stdio.stdout,
         File stderr = std.stdio.stderr, string[string] env = null)
{
    // TODO: option to disable this?
    if (SEP != "/")
        cmd = cmd.replace("/", SEP);
    run(parseCommand(cmd), stdout, stderr, env);
}

/**
Parse the given string `s` as a command.  Performs BASH-like variable expansion.
*/
string[] parseCommand(string s)
{
    auto rawArgs = s.split();
    auto args = appender!(string[])();
    foreach (rawArg; rawArgs)
    {
        const exp = shellExpand(rawArg);
        if (exp.length)
            args.put(exp);
    }
    return args.data;
}

/// Expand the given string using BASH-like variable expansion.
string shellExpand(const(char)[] s)
{
    auto expanded = appender!(char[])();
    for (size_t i = 0; i < s.length;)
    {
        if (s[i] != '$')
        {
            expanded.put(s[i]);
            i++;
        }
        else
        {
            i++;
            assert(i < s.length, "lone '$' at end of string");
            auto start = i;
            if (s[i] == '{')
            {
                start++;
                for (;;)
                {
                    i++;
                    assert(i < s.length, "unterminated ${...");
                    if (s[i] == '}') break;
                }
                expanded.put(Vars.get(s[start .. i]));
                i++;
            }
            else
            {
                assert(validVarChar(s[i]), "invalid sequence $'" ~ s[i]);
                for (;;)
                {
                    i++;
                    if (i >= s.length || !validVarChar(s[i]))
                        break;
                }
                expanded.put(Vars.get(s[start .. i]));
            }
        }
    }
    auto result = expanded.data;
    return (result is null) ? "" : result.assumeUnique;
}

// [a-zA-Z0-9_]
private bool validVarChar(const char c)
{
    import std.ascii : isAlphaNum;
    return c.isAlphaNum || c == '_';
}

struct GrepResult
{
    string[] matches;
    void enforceMatches(string message)
    {
        if (matches.length == 0)
        {
            assert(0, message);
        }
    }
}

/**
grep the given `file` for the given `pattern`.
*/
GrepResult grep(string file, string pattern)
{
    const patternExpanded = shellExpand(pattern);
    const fileExpanded = shellExpand(file);
    writefln("[GREP] file='%s' pattern='%s'", fileExpanded, patternExpanded);
    return grepLines(File(fileExpanded, "r").byLine, patternExpanded);
}
/// ditto
GrepResult grep(GrepResult lastResult, string pattern)
{
    auto patternExpanded = shellExpand(pattern);
    writefln("[GREP] (%s lines from last grep) pattern='%s'", lastResult.matches.length, patternExpanded);
    return grepLines(lastResult.matches, patternExpanded);
}

private GrepResult grepLines(T)(T lineRange, string finalPattern)
{
    auto matches = appender!(string[])();
    foreach(line; lineRange)
    {
        if (matchFirst(line, finalPattern))
        {
            static if (is(typeof(lineRange.front()) == string))
                matches.put(line);
            else
                matches.put(line.idup);
        }
    }
    writefln("[GREP] matched %s lines", matches.data.length);
    return GrepResult(matches.data);
}

/**
remove \r and the compiler debug header from the given string.
*/
string filterCompilerOutput(string output)
{
    output = std.string.replace(output, "\r", "");
    output = std.regex.replaceAll(output, regex(`^DMD v2\.[0-9]+.*\n? DEBUG\n`, "m"), "");
    return output;
}
