// SPDX-License-Identifier: GPL-2.0-only
/*
 *  ppm/src/core/build.c
 *
 *  Source-from-build pipeline. See include/ppm/build.h for the design.
 *
 *  Isolation in this first cut is intentionally lightweight: we run the
 *  build script in a fresh directory under <store>/tmp/build.<rand>/
 *  with a curated environment (PATH, HOME=$tmp, $src, $out). Real
 *  sandboxing via bwrap/namespaces lives on the roadmap; until then,
 *  trust in the registry's signed manifests is the security boundary.
 *
 *  Copyright (C) 2026 Project Tick
 */

#include <ppm/build.h>
#include <ppm/archive.h>

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <unistd.h>

#include <sodium.h>

#include "../util/http.h"
#include "../util/log.h"
#include "../util/path.h"

struct ppm_store { char *root; };

/* ----------------------------------------------------- helpers ----------- */

static const char *target_system(const ppm_manifest_t *m) {
    if (m->build.system && *m->build.system) return m->build.system;
    /* utsname fields can be up to ~65 bytes each on Linux; budget generously. */
    static char detected[160];
    static int  cached = 0;
    if (!cached) {
        struct utsname u;
        if (uname(&u) == 0) {
            snprintf(detected, sizeof(detected), "%s-%s", u.machine, u.sysname);
            for (char *p = detected; *p; p++) {
                if (*p >= 'A' && *p <= 'Z') *p += 32;
            }
        } else {
            snprintf(detected, sizeof(detected), "unknown");
        }
        cached = 1;
    }
    return detected;
}

/* Stable composite hash over every input that affects the build output. */
ppm_error_t ppm_build_output_hash(const ppm_manifest_t *m,
                                  const char *const   *dep_names,
                                  const ppm_sha512_t  *dep_hashes,
                                  size_t                dep_count,
                                  ppm_sha512_t        *out) {
    if (!m || !out) return PPM_ERR_INVALID;
    if (dep_count && (!dep_names || !dep_hashes)) return PPM_ERR_INVALID;

    ppm_sha512_ctx_t *ctx;
    ppm_error_t err = ppm_sha512_init(&ctx);
    if (err) return err;

    /* Use NUL-separated tagged fields so adjacent strings cannot be
     * confused with each other across format changes. */
    #define FEED(tag, data, len) do {                                       \
        const void *_d = (data);                                             \
        size_t      _l = (len);                                              \
        ppm_sha512_update(ctx, (tag), strlen(tag));                          \
        ppm_sha512_update(ctx, "\0", 1);                                     \
        if (_d && _l) ppm_sha512_update(ctx, _d, _l);                        \
        ppm_sha512_update(ctx, "\0", 1);                                     \
    } while (0)

    FEED("ppm-derivation-v1", "", 0);
    FEED("name",    m->name, strlen(m->name));
    char ver[64];
    int vn = snprintf(ver, sizeof(ver), "%u.%u.%u",
                      m->version.major, m->version.minor, m->version.patch);
    FEED("version", ver, (size_t)vn);

    if (m->source.url) FEED("src_url", m->source.url, strlen(m->source.url));
    FEED("src_sha512", m->source.hash.bytes, PPM_SHA512_BYTES);

    const char *sys = target_system(m);
    FEED("system", sys, strlen(sys));

    if (m->build.script) FEED("script", m->build.script, strlen(m->build.script));

    /* Sort dep indices by name so order does not bleed into the hash. */
    size_t *idx = NULL;
    if (dep_count) {
        idx = malloc(dep_count * sizeof(*idx));
        if (!idx) { ppm_sha512_free(ctx); return PPM_ERR_NOMEM; }
        for (size_t i = 0; i < dep_count; i++) idx[i] = i;
        /* Insertion sort, small N. */
        for (size_t i = 1; i < dep_count; i++) {
            size_t t = idx[i];
            ssize_t j = (ssize_t)i - 1;
            while (j >= 0 && strcmp(dep_names[idx[j]], dep_names[t]) > 0) {
                idx[j + 1] = idx[j];
                j--;
            }
            idx[j + 1] = t;
        }
    }
    for (size_t i = 0; i < dep_count; i++) {
        size_t k = idx[i];
        FEED("dep_name", dep_names[k], strlen(dep_names[k]));
        FEED("dep_hash", dep_hashes[k].bytes, PPM_SHA512_BYTES);
    }
    free(idx);

    err = ppm_sha512_final(ctx, out);
    ppm_sha512_free(ctx);

    #undef FEED
    return err;
}

/* --------------------------------------------------- build runtime -------- */

static ppm_error_t make_stage_dirs(ppm_store_t *store, char **out_base,
                                   char **out_src, char **out_out) {
    unsigned char raw[8];
    randombytes_buf(raw, sizeof(raw));
    char tag[17];
    sodium_bin2hex(tag, sizeof(tag), raw, sizeof(raw));

    char *base = NULL;
    if (asprintf(&base, "%s/tmp/build.%s", store->root, tag) < 0)
        return PPM_ERR_NOMEM;
    if (mkdir(base, 0755) != 0) { free(base); return PPM_ERR_IO; }

    char *src = NULL, *outd = NULL;
    if (asprintf(&src,  "%s/src", base) < 0) goto oom;
    if (asprintf(&outd, "%s/out", base) < 0) goto oom;
    if (mkdir(src,  0755) != 0) { ppm_path_rmrf(base); goto io; }
    if (mkdir(outd, 0755) != 0) { ppm_path_rmrf(base); goto io; }

    *out_base = base;
    *out_src  = src;
    *out_out  = outd;
    return PPM_OK;
oom:
    free(base); free(src); free(outd);
    return PPM_ERR_NOMEM;
io:
    free(base); free(src); free(outd);
    return PPM_ERR_IO;
}

/*
 * Construct a PATH for the build environment.
 *
 * Hermetic builds: PATH contains ONLY the `bin/` directories of explicit
 * build dependencies. No /usr/bin, no /bin, no system Perl or system
 * cmake. If a build script needs `cc`, `make`, `tar`, or even `bash`,
 * the manifest must list whichever package provides them under
 * [build-dependencies].
 *
 * The single unavoidable exception is `/bin/sh`, which is execve'd to
 * launch the script (line 1 of bootstrap, until we have a packaged
 * shell). After that, a build script's first action can be
 *     exec "$dep_bash"/bin/bash "$0" "$@"
 * to escape onto the packaged shell.
 */
static char *build_path_env(const char *const *dep_paths, size_t dep_count) {
    size_t cap = 4096, len = 0;
    char *buf = malloc(cap);
    if (!buf) return NULL;
    buf[0] = '\0';
    for (size_t i = 0; i < dep_count; i++) {
        char piece[4096];
        int n = snprintf(piece, sizeof(piece), "%s/bin", dep_paths[i]);
        if (n < 0 || (size_t)n >= sizeof(piece)) continue;
        size_t need = (size_t)n + (len ? 1 : 0);
        while (len + need + 1 >= cap) {
            cap *= 2;
            char *nb = realloc(buf, cap);
            if (!nb) { free(buf); return NULL; }
            buf = nb;
        }
        if (len) buf[len++] = ':';
        memcpy(buf + len, piece, (size_t)n);
        len += (size_t)n;
        buf[len] = '\0';
    }
    /*
     * Intentionally NO system PATH baseline. Empty PATH is correct: a
     * derivation with no build-deps cannot run any external command,
     * which forces every dependency to be declared up front.
     */
    return buf;
}

/*
 * Sanitise a package name into something safe for a shell env-var name.
 * The CMake-friendly convention is lowercase + '-' → '_', everything else
 * outside [A-Za-z0-9_] becomes '_'.
 */
static void sanitise_env_name(const char *in, char *out, size_t outsz) {
    size_t i = 0;
    for (; in[i] && i + 1 < outsz; i++) {
        char c = in[i];
        if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
            (c >= '0' && c <= '9') || c == '_') {
            out[i] = c;
        } else {
            out[i] = '_';
        }
    }
    out[i] = '\0';
}

static char *find_packaged_shell(const char *const *dep_paths, size_t dep_count) {
    for (size_t i = 0; i < dep_count; i++) {
        char *p = NULL;
        if (asprintf(&p, "%s/bin/sh", dep_paths[i]) < 0) return NULL;
        if (access(p, X_OK) == 0) return p;
        free(p);
    }
    return NULL;
}

static ppm_error_t run_script(const char *script,
                              const char *src,
                              const char *outd,
                              const char *path_env,
                              const char *const *dep_names,
                              const char *const *dep_paths,
                              size_t dep_count) {
    char *shell = find_packaged_shell(dep_paths, dep_count);
    if (!shell) {
        ppm_error("build: no packaged shell found in build dependencies; "
                  "add a package providing bin/sh (bootstrap-tools or corebinutils)");
        return PPM_ERR_NOT_FOUND;
    }

    pid_t pid = fork();
    if (pid < 0) { free(shell); return PPM_ERR_IO; }
    if (pid == 0) {
        /* Child: clean-ish environment, then exec packaged sh -e. */
        setenv("src",  src,      1);
        setenv("out",  outd,     1);
        setenv("HOME", outd,     1); /* avoid leaking the user's HOME */
        setenv("PATH", path_env, 1);
        setenv("LC_ALL", "C",    1);
        setenv("TZ",     "UTC",  1);

        /*
         * Each dependency is exposed twice:
         *   $dep_<name>   — convenient single-path form
         *   $deps         — semicolon-separated list, suitable for
         *                   passing straight into -DCMAKE_PREFIX_PATH.
         * Names with characters outside [A-Za-z0-9_] are sanitised.
         */
        size_t deps_buf_cap = 4096, deps_buf_len = 0;
        char *deps_buf = malloc(deps_buf_cap);
        if (deps_buf) deps_buf[0] = '\0';

        for (size_t i = 0; i < dep_count; i++) {
            char ename[256];
            sanitise_env_name(dep_names[i], ename, sizeof(ename));

            char var[300];
            snprintf(var, sizeof(var), "dep_%s", ename);
            setenv(var, dep_paths[i], 1);

            if (deps_buf) {
                size_t need = strlen(dep_paths[i]) + 2;
                while (deps_buf_len + need >= deps_buf_cap) {
                    deps_buf_cap *= 2;
                    char *nb = realloc(deps_buf, deps_buf_cap);
                    if (!nb) { free(deps_buf); deps_buf = NULL; break; }
                    deps_buf = nb;
                }
                if (deps_buf) {
                    if (deps_buf_len) deps_buf[deps_buf_len++] = ';';
                    memcpy(deps_buf + deps_buf_len, dep_paths[i],
                           strlen(dep_paths[i]));
                    deps_buf_len += strlen(dep_paths[i]);
                    deps_buf[deps_buf_len] = '\0';
                }
            }
        }
        if (deps_buf) {
            setenv("deps", deps_buf, 1);
            free(deps_buf);
        }

        if (chdir(src) != 0) _exit(127);
        execl(shell, shell, "-e", "-c", script, (char *)NULL);
        _exit(127);
    }
    free(shell);
    int status = 0;
    if (waitpid(pid, &status, 0) < 0) return PPM_ERR_IO;
    if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
        ppm_error("build: script failed (exit %d)", WEXITSTATUS(status));
        return PPM_ERR_IO;
    }
    return PPM_OK;
}

ppm_error_t ppm_build_run(ppm_store_t          *store,
                          const ppm_manifest_t *m,
                          const char *const    *dep_names,
                          const ppm_sha512_t   *dep_hashes,
                          const char *const    *dep_paths,
                          size_t                 dep_count,
                          ppm_sha512_t          *out_hash) {
    if (!store || !m || !out_hash) return PPM_ERR_INVALID;
    if (!m->build.has_build) return PPM_ERR_INVALID;
    if (!m->source.url || !*m->source.url) {
        ppm_error("build: [build] manifest needs [source].url for the source tarball");
        return PPM_ERR_INVALID;
    }

    /* 1. Output hash. */
    ppm_error_t err = ppm_build_output_hash(m, dep_names, dep_hashes,
                                            dep_count, out_hash);
    if (err) return err;

    /* 2. Cache hit? */
    if (ppm_store_has(store, out_hash)) {
        ppm_info("build: %s %u.%u.%u — cache hit", m->name,
                 m->version.major, m->version.minor, m->version.patch);
        return PPM_OK;
    }

    /* 3. Stage. */
    char *base = NULL, *srcdir = NULL, *outdir = NULL;
    if ((err = make_stage_dirs(store, &base, &srcdir, &outdir))) return err;

    /* 4. Download source. */
    char *tarball;
    if (asprintf(&tarball, "%s/source.tar", base) < 0) {
        ppm_path_rmrf(base);
        free(base); free(srcdir); free(outdir);
        return PPM_ERR_NOMEM;
    }
    ppm_info("build: %s %u.%u.%u — fetching %s", m->name,
             m->version.major, m->version.minor, m->version.patch,
             m->source.url);
    if ((err = ppm_http_download(m->source.url, tarball))) goto cleanup;

    /* 5. Verify source hash. */
    ppm_sha512_t got;
    if ((err = ppm_sha512_file(tarball, &got))) goto cleanup;
    if (!ppm_sha512_equal(&got, &m->source.hash)) {
        ppm_error("build: source hash mismatch for '%s'", m->name);
        err = PPM_ERR_HASH_MISMATCH;
        goto cleanup;
    }

    /* 6. Extract source. */
    if ((err = ppm_archive_extract(tarball, srcdir))) goto cleanup;

    /* 7. Run the build script. */
    char *path_env = build_path_env(dep_paths, dep_count);
    if (!path_env) { err = PPM_ERR_NOMEM; goto cleanup; }
    ppm_info("build: %s %u.%u.%u — running script", m->name,
             m->version.major, m->version.minor, m->version.patch);
    err = run_script(m->build.script, srcdir, outdir, path_env,
                     dep_names, dep_paths, dep_count);
    free(path_env);
    if (err) goto cleanup;

    /* 8. Move $out into the store under the deterministic hash. */
    err = ppm_store_ingest_dir(store, outdir, out_hash);
    if (err) goto cleanup;

    ppm_info("build: %s %u.%u.%u — installed in store", m->name,
             m->version.major, m->version.minor, m->version.patch);

cleanup:
    /* Whatever happens, do not leave the staging dir behind. */
    ppm_path_rmrf(base);
    free(base); free(srcdir); free(outdir); free(tarball);
    return err;
}
