/* sporel_git.c — lib-core.git v0.1.0
 *
 * Native libgit2 wrapper exposed to Lua. Synchronous + asynchronous API.
 * All async operations dispatch through the engine's worker-thread pool
 * via the sporel_engine_api struct passed to sporel_lib_init (Slice 4
 * ADR-0046).
 *
 * Transport: HTTPS only (WinHTTP on Windows, OpenSSL/mbedTLS on Linux
 * depending on libgit2's autodetection). No SSH support — keeps the
 * static link surface small and avoids libssh2 + dependent crypto.
 *
 * Threading model: libgit2 is fully thread-safe AFTER git_libgit2_init.
 * We init once on sporel_lib_init. The async worker that runs ls-remote
 * is the only place we touch libgit2 from a non-Lua thread.
 */

#include "lua.h"
#include "lauxlib.h"
#include "git2.h"
#include "sporel/engine_api.h"

#include <string.h>
#include <stdio.h>
#include <stdlib.h>

#ifdef _WIN32
#  define SPOREL_EXPORT __declspec(dllexport)
#else
#  define SPOREL_EXPORT __attribute__((visibility("default")))
#endif

/* Pointer to the engine-provided API struct. Populated by
 * sporel_lib_init; used by the async wrappers below. */
static const sporel_engine_api *g_api = NULL;

/* Optional Gitea / HTTP-Basic token. Populated at sporel_lib_init from
 * SPOREL_GITEA_TOKEN. When set, every libgit2 transport request that
 * asks for credentials gets username="token", password=g_token via the
 * cred-acquire callback. Empty string = anonymous (callback declines).
 *
 * Read once at init; no per-call override yet. A future engine.config
 * binding can refresh it (rebind via a setter) when settings UI lands. */
static char g_token[512] = "";

/* Call once per process; thread-safe via libgit2's own internal flag. */
static void ensure_libgit2_init(void) {
    static int initialized = 0;
    if (!initialized) {
        git_libgit2_init();
        initialized = 1;
    }
}

/* HTTP-Basic credential callback. libgit2 invokes this when the remote
 * demands auth. We service USERPASS_PLAINTEXT (Gitea token via
 * Basic-Auth, username literally "token") and decline everything else
 * (SSH-key types are unsupported since we ship no SSH transport). */
static int sporel_cred_acquire_cb(git_credential **out,
                                  const char *url,
                                  const char *username_from_url,
                                  unsigned int allowed_types,
                                  void *payload) {
    (void)url; (void)username_from_url; (void)payload;
    if (!(allowed_types & GIT_CREDENTIAL_USERPASS_PLAINTEXT)) {
        return GIT_PASSTHROUGH;
    }
    if (g_token[0] == '\0') {
        /* No token configured — fall through so libgit2 surfaces the
         * raw "authentication required" error to the caller. */
        return GIT_PASSTHROUGH;
    }
    return git_credential_userpass_plaintext_new(out, "token", g_token);
}

/* Map a libgit2 error to a stable "kind" string the Lua side surfaces
 * in result.kind. Lets the launcher distinguish local-only repos
 * (404 with creds) from need-auth (401 without creds) from genuine
 * network failures. Pattern-matches the error message because
 * libgit2's error-class taxonomy varies across HTTP backends. */
static const char *classify_git_error(int rc, const git_error *e) {
    const char *msg = (e && e->message) ? e->message : "";
    if (rc == GIT_EAUTH) return "auth-required";
    if (strstr(msg, "401") || strstr(msg, "authentication required")
        || strstr(msg, "Authentication")) return "auth-required";
    if (strstr(msg, "404") || strstr(msg, "Not Found")
        || strstr(msg, "not found")) return "not-found";
    if (e && (e->klass == GIT_ERROR_NET || e->klass == GIT_ERROR_HTTP
              || e->klass == GIT_ERROR_SSL))
        return "network";
    return "other";
}

/* ---- Synchronous bindings ------------------------------------------------ */

/* git.clone(url, dest_dir, ref) -> ok, err
 * ref is optional — when set, libgit2 checks out that branch after clone. */
static int l_clone(lua_State *L) {
    const char *url  = luaL_checkstring(L, 1);
    const char *dest = luaL_checkstring(L, 2);
    const char *ref  = luaL_optstring(L, 3, NULL);

    git_repository *repo = NULL;
    git_clone_options opts = GIT_CLONE_OPTIONS_INIT;
    if (ref) opts.checkout_branch = ref;
    opts.fetch_opts.callbacks.credentials = sporel_cred_acquire_cb;

    int rc = git_clone(&repo, url, dest, &opts);
    if (rc != 0) {
        lua_pushboolean(L, 0);
        const git_error *e = git_error_last();
        lua_pushstring(L, e ? e->message : "git_clone failed");
        return 2;
    }
    git_repository_free(repo);
    lua_pushboolean(L, 1);
    lua_pushnil(L);
    return 2;
}

/* git.checkout(dir, ref) -> ok, err
 * Resolves the ref (tag, branch or sha) and detaches HEAD onto its commit. */
static int l_checkout(lua_State *L) {
    const char *dir = luaL_checkstring(L, 1);
    const char *ref = luaL_checkstring(L, 2);

    git_repository *repo = NULL;
    git_object *target = NULL;

    int rc = git_repository_open(&repo, dir);
    if (rc != 0) goto err;

    rc = git_revparse_single(&target, repo, ref);
    if (rc != 0) goto err;

    git_checkout_options copts = GIT_CHECKOUT_OPTIONS_INIT;
    copts.checkout_strategy = GIT_CHECKOUT_SAFE;
    rc = git_checkout_tree(repo, target, &copts);
    if (rc != 0) goto err;

    rc = git_repository_set_head_detached(repo, git_object_id(target));
    if (rc != 0) goto err;

    git_object_free(target);
    git_repository_free(repo);
    lua_pushboolean(L, 1);
    lua_pushnil(L);
    return 2;

err:;
    const git_error *e = git_error_last();
    lua_pushboolean(L, 0);
    lua_pushstring(L, e ? e->message : "checkout failed");
    if (target) git_object_free(target);
    if (repo)   git_repository_free(repo);
    return 2;
}

/* git.describe(dir) -> desc_str or nil, err */
static int l_describe(lua_State *L) {
    const char *dir = luaL_checkstring(L, 1);

    git_repository *repo = NULL;
    git_describe_result *result = NULL;
    git_buf out = { 0 };

    int rc = git_repository_open(&repo, dir);
    if (rc != 0) goto err;

    git_describe_options dopts = GIT_DESCRIBE_OPTIONS_INIT;
    dopts.describe_strategy = GIT_DESCRIBE_TAGS;
    rc = git_describe_workdir(&result, repo, &dopts);
    if (rc != 0) goto err;

    git_describe_format_options fopts = GIT_DESCRIBE_FORMAT_OPTIONS_INIT;
    fopts.dirty_suffix = "-dirty";
    rc = git_describe_format(&out, result, &fopts);
    if (rc != 0) goto err;

    lua_pushstring(L, out.ptr);
    lua_pushnil(L);
    git_buf_dispose(&out);
    git_describe_result_free(result);
    git_repository_free(repo);
    return 2;

err:;
    const git_error *e = git_error_last();
    lua_pushnil(L);
    lua_pushstring(L, e ? e->message : "describe failed");
    if (result) git_describe_result_free(result);
    if (repo)   git_repository_free(repo);
    return 2;
}

/* git.has_ref(dir, ref) -> bool */
static int l_has_ref(lua_State *L) {
    const char *dir = luaL_checkstring(L, 1);
    const char *ref = luaL_checkstring(L, 2);
    git_repository *repo = NULL;
    if (git_repository_open(&repo, dir) != 0) {
        lua_pushboolean(L, 0);
        return 1;
    }
    git_object *o = NULL;
    int rc = git_revparse_single(&o, repo, ref);
    lua_pushboolean(L, rc == 0);
    if (o) git_object_free(o);
    git_repository_free(repo);
    return 1;
}

/* git.is_repo(dir) -> bool */
static int l_is_repo(lua_State *L) {
    const char *dir = luaL_checkstring(L, 1);
    git_repository *repo = NULL;
    int rc = git_repository_open(&repo, dir);
    if (rc == 0) {
        git_repository_free(repo);
        lua_pushboolean(L, 1);
    } else {
        lua_pushboolean(L, 0);
    }
    return 1;
}

/* git.tags(dir) -> {tag1, tag2, ...}, err */
static int l_tags(lua_State *L) {
    const char *dir = luaL_checkstring(L, 1);
    git_repository *repo = NULL;
    if (git_repository_open(&repo, dir) != 0) {
        lua_pushnil(L);
        lua_pushstring(L, "not a repo");
        return 2;
    }
    git_strarray names = { 0 };
    int rc = git_tag_list(&names, repo);
    if (rc != 0) {
        const git_error *e = git_error_last();
        lua_pushnil(L);
        lua_pushstring(L, e ? e->message : "git_tag_list failed");
        git_repository_free(repo);
        return 2;
    }
    lua_createtable(L, (int)names.count, 0);
    for (size_t i = 0; i < names.count; i++) {
        lua_pushstring(L, names.strings[i]);
        lua_rawseti(L, -2, (int)(i + 1));
    }
    git_strarray_dispose(&names);
    git_repository_free(repo);
    lua_pushnil(L);
    return 2;
}

/* git.fetch(url, dir) -> ok, err
 * Anonymous-remote fetch — does not mutate origin's refspecs. */
static int l_fetch(lua_State *L) {
    const char *url = luaL_checkstring(L, 1);
    const char *dir = luaL_checkstring(L, 2);
    git_repository *repo = NULL;
    git_remote *remote = NULL;

    if (git_repository_open(&repo, dir) != 0) goto err;
    if (git_remote_create_anonymous(&remote, repo, url) != 0) goto err;

    git_fetch_options fopts = GIT_FETCH_OPTIONS_INIT;
    fopts.callbacks.credentials = sporel_cred_acquire_cb;
    int rc = git_remote_fetch(remote, NULL, &fopts, NULL);
    git_remote_free(remote);
    git_repository_free(repo);
    if (rc != 0) {
        const git_error *e = git_error_last();
        lua_pushboolean(L, 0);
        lua_pushstring(L, e ? e->message : "fetch failed");
        return 2;
    }
    lua_pushboolean(L, 1);
    lua_pushnil(L);
    return 2;

err:;
    const git_error *e = git_error_last();
    lua_pushboolean(L, 0);
    lua_pushstring(L, e ? e->message : "fetch open failed");
    if (remote) git_remote_free(remote);
    if (repo)   git_repository_free(repo);
    return 2;
}

/* ---- Asynchronous bindings ----------------------------------------------- */

#define GIT_ASYNC_MAX_TAGS 256
#define GIT_ASYNC_TAG_LEN  128

typedef struct {
    char url[1024];
    /* outputs: */
    int  ok;
    char err[512];
    char kind[32];  /* "auth-required" | "not-found" | "network" | "other" */
    int   tag_count;
    char  tags[GIT_ASYNC_MAX_TAGS][GIT_ASYNC_TAG_LEN];
} git_async_ctx;

/* Worker body — runs on an engine_async pool thread. Must NOT touch the
 * Lua state (single-threaded). All result-data is written into ctx. */
static void do_ls_remote_tags(void *vctx) {
    git_async_ctx *c = (git_async_ctx *)vctx;
    git_remote *remote = NULL;

    int rc = git_remote_create_detached(&remote, c->url);
    if (rc != 0) goto fail;

    git_remote_callbacks cbs = GIT_REMOTE_CALLBACKS_INIT;
    cbs.credentials = sporel_cred_acquire_cb;
    rc = git_remote_connect(remote, GIT_DIRECTION_FETCH, &cbs, NULL, NULL);
    if (rc != 0) goto fail;

    const git_remote_head **heads = NULL;
    size_t n = 0;
    rc = git_remote_ls(&heads, &n, remote);
    if (rc != 0) goto fail;

    c->tag_count = 0;
    for (size_t i = 0; i < n && c->tag_count < GIT_ASYNC_MAX_TAGS; i++) {
        if (strncmp(heads[i]->name, "refs/tags/", 10) == 0) {
            /* Skip the peeled-tag duplicates (^{}) that ls-remote can emit. */
            const char *name = heads[i]->name + 10;
            size_t nlen = strlen(name);
            if (nlen >= 3 && strcmp(name + nlen - 3, "^{}") == 0) continue;
            snprintf(c->tags[c->tag_count], GIT_ASYNC_TAG_LEN, "%s", name);
            c->tag_count++;
        }
    }
    c->ok = 1;
    git_remote_disconnect(remote);
    git_remote_free(remote);
    return;

fail:;
    const git_error *e = git_error_last();
    snprintf(c->err, sizeof c->err, "%s",
             e && e->message ? e->message : "ls_remote failed");
    snprintf(c->kind, sizeof c->kind, "%s", classify_git_error(rc, e));
    c->ok = 0;
    if (remote) git_remote_free(remote);
}

/* Lua-owned handle for an in-flight async op. We allocate it via
 * lua_newuserdata so the GC owns its memory; the engine-pool handle is
 * released in take_result. */
typedef struct {
    sporel_async_handle *h;       /* engine-owned; freed by take_result */
    git_async_ctx        ctx;     /* Lua-owned via userdata */
} git_handle_userdata;

/* git.set_token(token | nil) -> nil
 * Replaces the in-memory Gitea HTTP-Basic token used by the credential
 * callback. Passing nil / empty string reverts to anonymous. Source of
 * truth at lib-init is SPOREL_GITEA_TOKEN env; the launcher (or any
 * consumer) calls this to override with engine.config("gitea_token")
 * once the Lua engine bindings are live. */
static int l_set_token(lua_State *L) {
    if (lua_isnoneornil(L, 1)) {
        g_token[0] = '\0';
        return 0;
    }
    const char *t = luaL_checkstring(L, 1);
    snprintf(g_token, sizeof g_token, "%s", t);
    return 0;
}

/* git.start_ls_remote_tags(url) -> handle (userdata) */
static int l_start_ls_remote_tags(lua_State *L) {
    if (!g_api) {
        return luaL_error(L,
            "sporel_git: engine_api not wired (sporel_lib_init bug)");
    }
    const char *url = luaL_checkstring(L, 1);
    git_handle_userdata *u =
        (git_handle_userdata *)lua_newuserdata(L, sizeof(*u));
    memset(u, 0, sizeof(*u));
    snprintf(u->ctx.url, sizeof u->ctx.url, "%s", url);
    u->h = g_api->async_submit(do_ls_remote_tags, &u->ctx, NULL, 0);
    return 1;
}

/* git.is_done(handle) -> bool */
static int l_is_done(lua_State *L) {
    git_handle_userdata *u = (git_handle_userdata *)lua_touserdata(L, 1);
    if (!u) return luaL_error(L, "git.is_done: expected userdata");
    if (!g_api) return luaL_error(L, "sporel_git: engine_api not wired");
    lua_pushboolean(L, g_api->async_is_done(u->h));
    return 1;
}

/* git.take_result(handle) -> {tags = {...}} | {error = "..."} | nil, err */
static int l_take_result(lua_State *L) {
    git_handle_userdata *u =
        (git_handle_userdata *)lua_touserdata(L, 1);
    if (!u) return luaL_error(L, "git.take_result: expected userdata");
    if (!g_api) return luaL_error(L, "sporel_git: engine_api not wired");
    if (!g_api->async_take_result(u->h)) {
        lua_pushnil(L);
        lua_pushstring(L, "not done");
        return 2;
    }
    u->h = NULL;  /* engine freed the handle */

    lua_newtable(L);
    if (u->ctx.ok) {
        lua_createtable(L, u->ctx.tag_count, 0);
        for (int i = 0; i < u->ctx.tag_count; i++) {
            lua_pushstring(L, u->ctx.tags[i]);
            lua_rawseti(L, -2, i + 1);
        }
        lua_setfield(L, -2, "tags");
    } else {
        lua_pushstring(L, u->ctx.err);
        lua_setfield(L, -2, "error");
        lua_pushstring(L, u->ctx.kind);
        lua_setfield(L, -2, "kind");
    }
    return 1;
}

/* ---- sporel_lib_init ----------------------------------------------------- */

SPOREL_EXPORT int sporel_lib_init(lua_State *L, const sporel_engine_api *api) {
    g_api = api;
    ensure_libgit2_init();

    /* Latch the Gitea token from env, if present. Empty/unset → anon. */
    const char *tok = getenv("SPOREL_GITEA_TOKEN");
    if (tok && *tok) {
        snprintf(g_token, sizeof g_token, "%s", tok);
    }

    lua_newtable(L);

    lua_pushcfunction(L, l_clone);                lua_setfield(L, -2, "clone");
    lua_pushcfunction(L, l_checkout);             lua_setfield(L, -2, "checkout");
    lua_pushcfunction(L, l_describe);             lua_setfield(L, -2, "describe");
    lua_pushcfunction(L, l_has_ref);              lua_setfield(L, -2, "has_ref");
    lua_pushcfunction(L, l_is_repo);              lua_setfield(L, -2, "is_repo");
    lua_pushcfunction(L, l_tags);                 lua_setfield(L, -2, "tags");
    lua_pushcfunction(L, l_fetch);                lua_setfield(L, -2, "fetch");

    lua_pushcfunction(L, l_start_ls_remote_tags); lua_setfield(L, -2, "start_ls_remote_tags");
    lua_pushcfunction(L, l_is_done);              lua_setfield(L, -2, "is_done");
    lua_pushcfunction(L, l_take_result);          lua_setfield(L, -2, "take_result");
    lua_pushcfunction(L, l_set_token);            lua_setfield(L, -2, "set_token");

    return 1;
}
