// SPDX-License-Identifier: GPL-2.0-only
/*
 *  ppm/src/core/registry.c
 *
 *  Git-backed registry, Nix-channel style. The registry is a git
 *  repository whose working tree follows:
 *
 *      packages/<aa>/<name>/
 *          versions.toml         # list of published versions
 *          <version>/
 *              manifest.toml     # the derivation: source URL + sha512
 *
 *  We use libgit2 for clone, fetch, and HEAD lookup; reads of versions
 *  and manifests go through the checked-out working tree, which keeps
 *  the parsing code free of git plumbing.
 *
 *  Copyright (C) 2026 Project Tick
 */

#include <ppm/registry.h>

#include <dirent.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

#include <git2.h>

/*
 * libgit2 renamed GIT_OID_HEXSZ to GIT_OID_SHA1_HEXSIZE when SHA-256 oid
 * support landed (the new name became standard around 1.7/1.8). Define the
 * new name in terms of the old one so we build against libgit2 >= 1.5.
 */
#ifndef GIT_OID_SHA1_HEXSIZE
#define GIT_OID_SHA1_HEXSIZE GIT_OID_HEXSZ
#endif

#include <ppm/crypto.h>

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

struct ppm_registry {
    char           *local_mirror;
    git_repository *repo;
};

/* ---------------------------------------------------------------- libgit2 */

static int g_initialised = 0;

static ppm_error_t ensure_libgit2(void) {
    if (g_initialised) return PPM_OK;
    if (git_libgit2_init() < 0) return PPM_ERR_INTERNAL;
    g_initialised = 1;
    return PPM_OK;
}

static ppm_error_t map_git_error(int rc) {
    if (rc >= 0) return PPM_OK;
    const git_error *e = git_error_last();
    if (e) ppm_error("registry: %s", e->message);
    switch (rc) {
    case GIT_ENOTFOUND:    return PPM_ERR_NOT_FOUND;
    case GIT_EEXISTS:      return PPM_ERR_EXISTS;
    case GIT_EAUTH:        return PPM_ERR_PERMISSION;
    case GIT_ECERTIFICATE: return PPM_ERR_PERMISSION;
    case GIT_EUSER:        return PPM_ERR_INVALID;
    default:               return PPM_ERR_NETWORK;
    }
}

/* ------------------------------------------------------------ open / clone */

ppm_error_t ppm_registry_open(const char *local_mirror, ppm_registry_t **out) {
    if (!local_mirror || !out) return PPM_ERR_INVALID;
    ppm_error_t err = ensure_libgit2();
    if (err) return err;

    git_repository *repo = NULL;
    int rc = git_repository_open(&repo, local_mirror);
    if (rc < 0) return map_git_error(rc);

    ppm_registry_t *r = calloc(1, sizeof(*r));
    if (!r) { git_repository_free(repo); return PPM_ERR_NOMEM; }
    r->local_mirror = strdup(local_mirror);
    if (!r->local_mirror) {
        git_repository_free(repo);
        free(r);
        return PPM_ERR_NOMEM;
    }
    r->repo = repo;
    *out = r;
    return PPM_OK;
}

ppm_error_t ppm_registry_clone(const char *url, const char *local_mirror,
                               ppm_registry_t **out) {
    if (!url || !local_mirror || !out) return PPM_ERR_INVALID;
    ppm_error_t err = ensure_libgit2();
    if (err) return err;

    if (ppm_path_is_dir(local_mirror)) {
        ppm_error("registry: '%s' already exists; use fetch instead",
                  local_mirror);
        return PPM_ERR_EXISTS;
    }

    git_clone_options opts = GIT_CLONE_OPTIONS_INIT;
    git_repository *repo = NULL;
    ppm_info("registry: cloning %s into %s", url, local_mirror);
    int rc = git_clone(&repo, url, local_mirror, &opts);
    if (rc < 0) return map_git_error(rc);

    ppm_registry_t *r = calloc(1, sizeof(*r));
    if (!r) { git_repository_free(repo); return PPM_ERR_NOMEM; }
    r->local_mirror = strdup(local_mirror);
    if (!r->local_mirror) {
        git_repository_free(repo);
        free(r);
        return PPM_ERR_NOMEM;
    }
    r->repo = repo;
    *out = r;
    return PPM_OK;
}

/*
 * Fetch from origin and fast-forward the current branch. Refuses to
 * merge a divergent history; the user must rebase the mirror manually
 * in that case (it should never happen for a well-behaved registry).
 */
ppm_error_t ppm_registry_fetch(ppm_registry_t *reg) {
    if (!reg) return PPM_ERR_INVALID;

    git_remote *remote = NULL;
    int rc = git_remote_lookup(&remote, reg->repo, "origin");
    if (rc < 0) return map_git_error(rc);

    git_fetch_options fopts = GIT_FETCH_OPTIONS_INIT;
    rc = git_remote_fetch(remote, NULL, &fopts, "ppm registry fetch");
    git_remote_free(remote);
    if (rc < 0) return map_git_error(rc);

    git_reference *head = NULL;
    rc = git_repository_head(&head, reg->repo);
    if (rc < 0) return map_git_error(rc);

    const char *branch = git_reference_shorthand(head);
    char refname[256];
    snprintf(refname, sizeof(refname), "refs/remotes/origin/%s", branch);

    git_reference *upstream = NULL;
    rc = git_reference_lookup(&upstream, reg->repo, refname);
    if (rc < 0) {
        git_reference_free(head);
        return map_git_error(rc);
    }

    const git_oid *up_oid = git_reference_target(upstream);
    git_annotated_commit *up_ann = NULL;
    rc = git_annotated_commit_lookup(&up_ann, reg->repo, up_oid);
    if (rc < 0) {
        git_reference_free(head);
        git_reference_free(upstream);
        return map_git_error(rc);
    }

    git_merge_analysis_t analysis;
    git_merge_preference_t pref;
    const git_annotated_commit *heads[] = { up_ann };
    rc = git_merge_analysis(&analysis, &pref, reg->repo, heads, 1);
    if (rc < 0) {
        git_annotated_commit_free(up_ann);
        git_reference_free(head);
        git_reference_free(upstream);
        return map_git_error(rc);
    }

    ppm_error_t err = PPM_OK;
    if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) {
        ppm_info("registry: already up to date");
    } else if (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD) {
        git_reference *new_ref = NULL;
        rc = git_reference_set_target(&new_ref, head, up_oid,
                                      "ppm: fast-forward registry");
        if (rc < 0) {
            err = map_git_error(rc);
        } else {
            git_checkout_options copts = GIT_CHECKOUT_OPTIONS_INIT;
            copts.checkout_strategy = GIT_CHECKOUT_SAFE;
            rc = git_checkout_head(reg->repo, &copts);
            if (rc < 0) err = map_git_error(rc);
            git_reference_free(new_ref);
        }
    } else {
        ppm_error("registry: cannot fast-forward — local history has diverged");
        err = PPM_ERR_CONFLICT;
    }

    git_annotated_commit_free(up_ann);
    git_reference_free(head);
    git_reference_free(upstream);
    return err;
}

void ppm_registry_close(ppm_registry_t *reg) {
    if (!reg) return;
    if (reg->repo) git_repository_free(reg->repo);
    free(reg->local_mirror);
    free(reg);
}

ppm_error_t ppm_registry_head_commit(const ppm_registry_t *reg, char **out_hex) {
    if (!reg || !out_hex) return PPM_ERR_INVALID;
    git_oid oid;
    int rc = git_reference_name_to_id(&oid, reg->repo, "HEAD");
    if (rc < 0) return map_git_error(rc);
    char *hex = malloc(GIT_OID_SHA1_HEXSIZE + 1);
    if (!hex) return PPM_ERR_NOMEM;
    git_oid_tostr(hex, GIT_OID_SHA1_HEXSIZE + 1, &oid);
    *out_hex = hex;
    return PPM_OK;
}

/* ------------------------------------------------------ working-tree reads */

/* packages/<aa>/<name>/. <aa> is the first two chars (padded with '_'). */
static ppm_error_t pkg_dir(const ppm_registry_t *r, const char *name,
                           char **out) {
    if (!name || !*name) return PPM_ERR_INVALID;
    char shard[3] = { name[0], name[1] ? name[1] : '_', '\0' };
    char *p = NULL;
    if (asprintf(&p, "%s/packages/%s/%s", r->local_mirror, shard, name) < 0)
        return PPM_ERR_NOMEM;
    *out = p;
    return PPM_OK;
}

static ppm_error_t read_file_to_string(const char *path, char **out,
                                       size_t *out_len) {
    FILE *f = fopen(path, "rb");
    if (!f) return (errno == ENOENT) ? PPM_ERR_NOT_FOUND : PPM_ERR_IO;
    if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return PPM_ERR_IO; }
    long sz = ftell(f);
    if (sz < 0) { fclose(f); return PPM_ERR_IO; }
    if (fseek(f, 0, SEEK_SET) != 0) { fclose(f); return PPM_ERR_IO; }
    char *buf = malloc((size_t)sz + 1);
    if (!buf) { fclose(f); return PPM_ERR_NOMEM; }
    size_t n = fread(buf, 1, (size_t)sz, f);
    fclose(f);
    if (n != (size_t)sz) { free(buf); return PPM_ERR_IO; }
    buf[n] = '\0';
    *out = buf;
    if (out_len) *out_len = n;
    return PPM_OK;
}

/*
 * versions.toml schema (kept simple to match our TOML subset):
 *
 *   [versions]
 *   "0.1.0" = "ok"
 *   "1.0.0" = "ok"
 *
 * Returned array is newest-first.
 */
ppm_error_t ppm_registry_versions(const ppm_registry_t *reg,
                                  const char *name,
                                  ppm_version_t **out, size_t *out_len) {
    if (!reg || !name || !out || !out_len) return PPM_ERR_INVALID;
    *out = NULL;
    *out_len = 0;

    char *pdir;
    ppm_error_t err = pkg_dir(reg, name, &pdir);
    if (err) return err;

    char *vpath;
    if (asprintf(&vpath, "%s/versions.toml", pdir) < 0) {
        free(pdir);
        return PPM_ERR_NOMEM;
    }
    free(pdir);

    char *buf;
    err = read_file_to_string(vpath, &buf, NULL);
    free(vpath);
    if (err) return err;

    ppm_toml_node_t *root = NULL;
    char *terr = NULL;
    err = ppm_toml_parse(buf, &root, &terr);
    free(buf);
    if (err) {
        ppm_error("registry: %s/versions.toml: %s",
                  name, terr ? terr : ppm_strerror(err));
        free(terr);
        return err;
    }

    const ppm_toml_node_t *vt = ppm_toml_get_table(root, "versions");
    if (!vt || vt->u.tbl.len == 0) {
        ppm_toml_free(root);
        return PPM_OK;
    }

    ppm_version_t *arr = calloc(vt->u.tbl.len, sizeof(*arr));
    if (!arr) { ppm_toml_free(root); return PPM_ERR_NOMEM; }

    size_t out_i = 0;
    for (size_t i = 0; i < vt->u.tbl.len; i++) {
        const char *vs = vt->u.tbl.pairs[i].key;
        ppm_version_t v;
        if (ppm_version_parse(vs, &v) != PPM_OK) {
            ppm_warn("registry: skipping invalid version '%s' for '%s'", vs, name);
            continue;
        }
        arr[out_i++] = v;
    }

    /* Insertion sort, descending (small N). */
    for (size_t i = 1; i < out_i; i++) {
        for (size_t j = i; j > 0 && ppm_version_cmp(&arr[j - 1], &arr[j]) < 0; j--) {
            ppm_version_t tmp = arr[j - 1];
            arr[j - 1] = arr[j];
            arr[j] = tmp;
        }
    }

    ppm_toml_free(root);
    *out = arr;
    *out_len = out_i;
    return PPM_OK;
}

/* ---------------------------------------------------------- signing trust */

/*
 * HARD ENFORCEMENT of design invariant #6 ("signed everything").
 *
 * A published manifest is accepted iff its detached signature
 * (`manifest.toml.sig`, hex-encoded 64-byte Ed25519) verifies over the
 * EXACT raw bytes of `manifest.toml` against AT LEAST ONE trusted public
 * key found in `<local_mirror>/signers/<id>.pub`. There is no override flag.
 *
 * Returns PPM_OK on the first key that verifies; PPM_ERR_SIG_INVALID if
 * the signature is missing, malformed, there are no signer keys, or no
 * trusted key validates it.
 *
 * Residual gap (see README/AGENTS roadmap): trust is currently "any key in
 * the registry's signers/ dir". A wholesale malicious mirror that also
 * swaps the signer set is NOT yet defended against — that needs the
 * user-configured trust anchor plus signed-git-commit verification.
 */
static ppm_error_t verify_manifest_signature(const ppm_registry_t *reg,
                                             const char *name,
                                             const ppm_version_t *version,
                                             const char *sig_path,
                                             const void *manifest_bytes,
                                             size_t manifest_len) {
    /* 1. Load + decode the detached signature. */
    char *sig_hex = NULL;
    ppm_error_t err = read_file_to_string(sig_path, &sig_hex, NULL);
    if (err == PPM_ERR_NOT_FOUND) {
        ppm_error("registry: %s %u.%u.%u: missing manifest signature "
                  "(manifest.toml.sig) — refusing unsigned artifact",
                  name, version->major, version->minor, version->patch);
        return PPM_ERR_SIG_INVALID;
    }
    if (err) return err;

    uint8_t sig[PPM_ED25519_SIG_BYTES];
    err = ppm_ed25519_sig_from_hex(sig_hex, sig);
    free(sig_hex);
    if (err) {
        ppm_error("registry: %s %u.%u.%u: malformed manifest signature",
                  name, version->major, version->minor, version->patch);
        return PPM_ERR_SIG_INVALID;
    }

    /* 2. Walk the signers/ directory; accept on first key that verifies. */
    char *signers_dir = NULL;
    if (asprintf(&signers_dir, "%s/signers", reg->local_mirror) < 0)
        return PPM_ERR_NOMEM;

    DIR *d = opendir(signers_dir);
    if (!d) {
        ppm_error("registry: %s %u.%u.%u: no signer keys "
                  "(missing signers/ directory) — cannot verify",
                  name, version->major, version->minor, version->patch);
        free(signers_dir);
        return PPM_ERR_SIG_INVALID;
    }

    bool verified = false;
    struct dirent *de;
    while (!verified && (de = readdir(d)) != NULL) {
        size_t nlen = strlen(de->d_name);
        if (nlen < 4 || strcmp(de->d_name + nlen - 4, ".pub") != 0) continue;

        char *kpath = NULL;
        if (asprintf(&kpath, "%s/%s", signers_dir, de->d_name) < 0) break;

        char *pk_hex = NULL;
        if (read_file_to_string(kpath, &pk_hex, NULL) != PPM_OK) {
            free(kpath);
            continue;
        }
        free(kpath);

        uint8_t pk[PPM_ED25519_PK_BYTES];
        if (ppm_ed25519_pk_from_hex(pk_hex, pk) != PPM_OK) {
            ppm_warn("registry: skipping malformed signer key '%s'", de->d_name);
            free(pk_hex);
            continue;
        }
        free(pk_hex);

        if (ppm_ed25519_verify(pk, sig, manifest_bytes, manifest_len) == PPM_OK)
            verified = true;
    }
    closedir(d);
    free(signers_dir);

    if (!verified) {
        ppm_error("registry: %s %u.%u.%u: manifest signature does not match "
                  "any trusted signer", name, version->major,
                  version->minor, version->patch);
        return PPM_ERR_SIG_INVALID;
    }
    return PPM_OK;
}

ppm_error_t ppm_registry_manifest(const ppm_registry_t *reg, const char *name,
                                  const ppm_version_t *version,
                                  ppm_manifest_t **out) {
    if (!reg || !name || !version || !out) return PPM_ERR_INVALID;
    *out = NULL;

    char *pdir;
    ppm_error_t err = pkg_dir(reg, name, &pdir);
    if (err) return err;

    char *mpath = NULL, *sig_path = NULL;
    if (asprintf(&mpath, "%s/%u.%u.%u/manifest.toml",
                 pdir, version->major, version->minor, version->patch) < 0) {
        free(pdir);
        return PPM_ERR_NOMEM;
    }
    if (asprintf(&sig_path, "%s.sig", mpath) < 0) {
        free(pdir);
        free(mpath);
        return PPM_ERR_NOMEM;
    }
    free(pdir);

    /* Read the raw manifest bytes. The signature is computed over exactly
     * these bytes, so we verify the signature BEFORE parsing. */
    char  *raw = NULL;
    size_t raw_len = 0;
    err = read_file_to_string(mpath, &raw, &raw_len);
    if (err) {
        free(mpath);
        free(sig_path);
        return err;
    }

    err = verify_manifest_signature(reg, name, version, sig_path, raw, raw_len);
    free(sig_path);
    if (err) {
        free(raw);
        free(mpath);
        return err;
    }

    err = ppm_manifest_parse(raw, raw_len, out);
    free(raw);
    free(mpath);
    if (err) return err;

    err = ppm_manifest_validate(*out, PPM_MANIFEST_PUBLISHED);
    if (err) {
        ppm_manifest_free(*out);
        *out = NULL;
    }
    return err;
}

ppm_error_t ppm_registry_search(const ppm_registry_t *reg, const char *query,
                                char ***out_names, size_t *out_len) {
    if (!reg || !out_names || !out_len) return PPM_ERR_INVALID;
    *out_names = NULL;
    *out_len = 0;

    char *root;
    if (asprintf(&root, "%s/packages", reg->local_mirror) < 0)
        return PPM_ERR_NOMEM;

    DIR *d = opendir(root);
    if (!d) { free(root); return PPM_OK; }

    char  **names = NULL;
    size_t  cap = 0, len = 0;

    struct dirent *de;
    while ((de = readdir(d)) != NULL) {
        if (de->d_name[0] == '.') continue;
        char *shard;
        if (asprintf(&shard, "%s/%s", root, de->d_name) < 0) continue;
        DIR *sd = opendir(shard);
        free(shard);
        if (!sd) continue;
        struct dirent *se;
        while ((se = readdir(sd)) != NULL) {
            if (se->d_name[0] == '.') continue;
            if (query && *query && !strstr(se->d_name, query)) continue;
            if (len == cap) {
                size_t ncap = cap ? cap * 2 : 16;
                char **np = realloc(names, ncap * sizeof(*np));
                if (!np) break;
                names = np;
                cap = ncap;
            }
            names[len] = strdup(se->d_name);
            if (names[len]) len++;
        }
        closedir(sd);
    }
    closedir(d);
    free(root);

    *out_names = names;
    *out_len = len;
    return PPM_OK;
}
