// SPDX-License-Identifier: GPL-2.0-only
/*
 *  ppm/src/core/transaction.c
 *
 *  Atomic profile updates backed by a write-ahead journal.
 *
 *  Generation layout:
 *      <store>/profiles/<name>/gen-<n>/manifest.toml
 *      <store>/profiles/<name>/current  -> gen-<n>
 *
 *  Journal layout (one record per line, tab-separated, ASCII only):
 *      v1
 *      profile <name>
 *      gen     <absolute-path-to-new-gen-dir>
 *      prev    <relative-old-target-or-->
 *      stage   <sha512-hex>  <pkg-name>
 *      ...
 *      ready
 *
 *  The "ready" record is the single bit that distinguishes a transaction
 *  that needs to be replayed (forward) from one that should be rolled
 *  back (backward). Everything before "ready" is best-effort cleanup;
 *  everything after is durable.
 *
 *  Copyright (C) 2026 Project Tick
 */

#include <ppm/transaction.h>
#include <ppm/lockfile.h>
#include <ppm/manifest.h>

#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>

#include <sodium.h>

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

/* The store struct lives in store.c; we only need the root path here. */
struct ppm_store { char *root; };

struct ppm_txn {
    ppm_store_t *store;
    char        *profile;       /* profile name, e.g. "default" */
    char        *profile_dir;   /* <root>/profiles/<name>         */
    char        *gen_dir;       /* <root>/profiles/<name>/gen-<n> */
    char        *gen_name;      /* "gen-<n>" — what the symlink will point to */
    char        *prev_target;   /* old symlink target, or NULL if none */
    char        *journal_path;  /* <root>/tmp/txn-<id>.journal     */
    FILE        *journal;       /* append-mode, owned by txn        */
    char        *txn_id;        /* hex id used in journal filename  */
    int          lock_fd;       /* flock on profile_dir            */
    int          committed;     /* 1 once commit succeeds          */

    /* In-memory manifest of the pending generation. */
    struct {
        char         *name;
        char         *version;  /* textual, copied verbatim from lock entry */
        ppm_sha512_t  hash;
    } *entries;
    size_t entries_len;
    size_t entries_cap;
};

/* ------------------------------------------------------------- utilities */

static ppm_error_t profile_dir_path(ppm_store_t *s, const char *profile, char **out) {
    char *base;
    ppm_error_t err = ppm_path_join(s->root, "profiles", &base);
    if (err) return err;
    err = ppm_path_join(base, profile, out);
    free(base);
    return err;
}

static ppm_error_t lock_profile(const char *profile_dir, int *out_fd) {
    int fd = open(profile_dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
    if (fd < 0) return PPM_ERR_IO;
    if (flock(fd, LOCK_EX) != 0) {
        close(fd);
        return PPM_ERR_IO;
    }
    *out_fd = fd;
    return PPM_OK;
}

static void unlock_profile(int fd) {
    if (fd >= 0) {
        flock(fd, LOCK_UN);
        close(fd);
    }
}

/*
 * Pick the next generation number by scanning the profile directory for
 * gen-<n> entries. Returns 0 if none exist.
 */
static ppm_error_t next_gen_number(const char *profile_dir, unsigned *out) {
    DIR *d = opendir(profile_dir);
    if (!d) return PPM_ERR_IO;
    unsigned max = 0;
    struct dirent *de;
    while ((de = readdir(d)) != NULL) {
        if (strncmp(de->d_name, "gen-", 4) != 0) continue;
        char *end = NULL;
        unsigned long n = strtoul(de->d_name + 4, &end, 10);
        if (end && *end == '\0' && n > max) max = (unsigned)n;
    }
    closedir(d);
    *out = max + 1;
    return PPM_OK;
}

static ppm_error_t read_current_target(const char *profile_dir, char **out) {
    char *link;
    ppm_error_t err = ppm_path_join(profile_dir, "current", &link);
    if (err) return err;

    char buf[4096];
    ssize_t n = readlink(link, buf, sizeof(buf) - 1);
    free(link);
    if (n < 0) {
        *out = NULL;
        return (errno == ENOENT) ? PPM_OK : PPM_ERR_IO;
    }
    buf[n] = '\0';
    *out = strdup(buf);
    return *out ? PPM_OK : PPM_ERR_NOMEM;
}

static int hex_buf(const ppm_sha512_t *h, char *dst) {
    ppm_sha512_to_hex(h, dst);
    return 0;
}

/* -------------------------------------------------------------- journal */

static ppm_error_t journal_write(FILE *f, const char *fmt, ...)
    __attribute__((format(printf, 2, 3)));

static ppm_error_t journal_write(FILE *f, const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    int n = vfprintf(f, fmt, ap);
    va_end(ap);
    if (n < 0) return PPM_ERR_IO;
    if (fputc('\n', f) == EOF) return PPM_ERR_IO;
    if (fflush(f) != 0) return PPM_ERR_IO;
    int fd = fileno(f);
    if (fd >= 0 && ppm_path_fdatasync(fd) != PPM_OK) return PPM_ERR_IO;
    return PPM_OK;
}

static ppm_error_t journal_open(const char *path, FILE **out) {
    int fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_APPEND | O_CLOEXEC, 0644);
    if (fd < 0) return PPM_ERR_IO;
    FILE *f = fdopen(fd, "a");
    if (!f) { close(fd); return PPM_ERR_IO; }
    *out = f;
    return PPM_OK;
}

/* ---------------------------------------------- pending-manifest helpers */

static ppm_error_t entries_reserve(ppm_txn_t *t, size_t need) {
    if (t->entries_cap >= need) return PPM_OK;
    size_t cap = t->entries_cap ? t->entries_cap * 2 : 4;
    while (cap < need) cap *= 2;
    void *p = realloc(t->entries, cap * sizeof(*t->entries));
    if (!p) return PPM_ERR_NOMEM;
    t->entries = p;
    t->entries_cap = cap;
    return PPM_OK;
}

static ppm_error_t write_pending_manifest(ppm_txn_t *t) {
    char *mpath;
    ppm_error_t err = ppm_path_join(t->gen_dir, "manifest.toml", &mpath);
    if (err) return err;

    /*
     * Write to manifest.toml.tmp first, fsync, then rename to
     * manifest.toml. That way a crash mid-write cannot leave the
     * generation directory containing a truncated manifest.
     */
    char *tmp;
    if ((err = ppm_path_join(t->gen_dir, "manifest.toml.tmp", &tmp))) {
        free(mpath);
        return err;
    }

    FILE *f = fopen(tmp, "w");
    if (!f) { free(mpath); free(tmp); return PPM_ERR_IO; }
    fputs("# ppm generation manifest — generated, do not edit.\n", f);
    fputs("schema = \"1\"\n\n", f);
    /* Use [package.<i>] sub-tables instead of [[package]] arrays of tables
     * to stay within our minimal TOML parser subset (lockfile.c uses the
     * same convention). */
    for (size_t i = 0; i < t->entries_len; i++) {
        char hex[PPM_SHA512_HEX_BUFSZ];
        hex_buf(&t->entries[i].hash, hex);
        fprintf(f, "[package.%zu]\n", i);
        fprintf(f, "name    = \"%s\"\n", t->entries[i].name);
        fprintf(f, "version = \"%s\"\n", t->entries[i].version);
        fprintf(f, "hash    = \"sha512:%s\"\n\n", hex);
    }
    if (fflush(f) != 0) { fclose(f); free(mpath); free(tmp); return PPM_ERR_IO; }
    int fd = fileno(f);
    if (fd >= 0) (void)ppm_path_fdatasync(fd);
    fclose(f);

    if (rename(tmp, mpath) != 0) {
        unlink(tmp);
        free(mpath); free(tmp);
        return PPM_ERR_IO;
    }
    free(tmp);
    ppm_path_fsync_dir(t->gen_dir);
    free(mpath);
    return PPM_OK;
}

/* ----------------------------------------------- symlink swap (atomic) */

static ppm_error_t swap_current_symlink(const char *profile_dir,
                                         const char *new_target_name) {
    char *final_link, *tmp_link;
    ppm_error_t err = ppm_path_join(profile_dir, "current", &final_link);
    if (err) return err;
    if ((err = ppm_path_join(profile_dir, "current.new", &tmp_link))) {
        free(final_link);
        return err;
    }

    /* Remove any leftover .new from a previous crashed run. */
    unlink(tmp_link);

    if (symlink(new_target_name, tmp_link) != 0) {
        free(final_link); free(tmp_link);
        return PPM_ERR_IO;
    }
    /* POSIX rename(2) over an existing symlink is atomic. */
    if (rename(tmp_link, final_link) != 0) {
        unlink(tmp_link);
        free(final_link); free(tmp_link);
        return PPM_ERR_IO;
    }
    ppm_path_fsync_dir(profile_dir);
    free(final_link);
    free(tmp_link);
    return PPM_OK;
}

/* ----------------------------------------------------------- recovery */

/*
 * Parse a single journal and act on it. Returns PPM_OK whether we
 * replayed or rolled back; only structural errors propagate.
 */
static ppm_error_t replay_one(const char *journal_path) {
    FILE *f = fopen(journal_path, "r");
    if (!f) return (errno == ENOENT) ? PPM_OK : PPM_ERR_IO;

    char *profile_dir = NULL, *gen_name = NULL, *gen_dir = NULL;
    int ready = 0, schema_ok = 0;

    char line[8192];
    while (fgets(line, sizeof(line), f)) {
        size_t l = strlen(line);
        if (l && line[l - 1] == '\n') line[--l] = '\0';
        if (l == 0) continue;

        if (strcmp(line, "v1") == 0) { schema_ok = 1; continue; }
        if (strcmp(line, "ready") == 0) { ready = 1; continue; }

        if (strncmp(line, "profile\t", 8) == 0) {
            /* Stored as a directory path so we don't need to know the root. */
            free(profile_dir);
            profile_dir = strdup(line + 8);
            continue;
        }
        if (strncmp(line, "gen\t", 4) == 0) {
            free(gen_dir);
            gen_dir = strdup(line + 4);
            const char *base = strrchr(gen_dir, '/');
            free(gen_name);
            gen_name = strdup(base ? base + 1 : gen_dir);
            continue;
        }
        /* Other records (prev, stage) are informational for now. */
    }
    fclose(f);

    if (!schema_ok || !profile_dir || !gen_dir || !gen_name) {
        /* Corrupt journal — leave it for a human to inspect. */
        ppm_warn("transaction: journal '%s' looks corrupt, leaving in place",
                 journal_path);
        free(profile_dir); free(gen_dir); free(gen_name);
        return PPM_OK;
    }

    ppm_error_t err = PPM_OK;
    if (ready) {
        /* Replay forward: ensure current -> gen_name. */
        ppm_info("transaction: replaying journal '%s' (forward)", journal_path);
        err = swap_current_symlink(profile_dir, gen_name);
    } else {
        /* Roll back: nuke the partial generation directory. */
        ppm_info("transaction: rolling back partial generation '%s'", gen_dir);
        ppm_path_rmrf(gen_dir);
    }

    if (err == PPM_OK) unlink(journal_path);

    free(profile_dir); free(gen_dir); free(gen_name);
    return err;
}

ppm_error_t ppm_txn_recover(ppm_store_t *store, const char *profile) {
    if (!store || !profile) return PPM_ERR_INVALID;

    char *tmpdir;
    ppm_error_t err = ppm_path_join(store->root, "tmp", &tmpdir);
    if (err) return err;

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

    struct dirent *de;
    while ((de = readdir(d)) != NULL) {
        if (strncmp(de->d_name, "txn-", 4) != 0) continue;
        const char *dot = strrchr(de->d_name, '.');
        if (!dot || strcmp(dot, ".journal") != 0) continue;

        char *jpath;
        if (ppm_path_join(tmpdir, de->d_name, &jpath) != PPM_OK) continue;
        replay_one(jpath); /* errors logged inside */
        free(jpath);
    }
    closedir(d);
    free(tmpdir);
    (void)profile; /* unused for now: we recover every journal we find */
    return PPM_OK;
}

/* ------------------------------------------------------------- public API */

ppm_error_t ppm_txn_begin(ppm_store_t *store, const char *profile, ppm_txn_t **out) {
    if (!store || !profile || !out) return PPM_ERR_INVALID;

    /* Recover any leftovers from previous crashed runs before we start. */
    (void)ppm_txn_recover(store, profile);

    ppm_txn_t *t = calloc(1, sizeof(*t));
    if (!t) return PPM_ERR_NOMEM;
    t->store    = store;
    t->lock_fd  = -1;
    t->profile  = strdup(profile);
    if (!t->profile) { free(t); return PPM_ERR_NOMEM; }

    ppm_error_t err;
    if ((err = profile_dir_path(store, profile, &t->profile_dir))) goto fail;
    if ((err = ppm_path_mkdirs(t->profile_dir, 0755))) goto fail;
    if ((err = lock_profile(t->profile_dir, &t->lock_fd))) goto fail;

    /* Carry forward the previous generation's contents into ours by
     * default. install/remove then mutate the in-memory list. */
    char *prev;
    if ((err = read_current_target(t->profile_dir, &prev))) goto fail;
    t->prev_target = prev;

    unsigned n;
    if ((err = next_gen_number(t->profile_dir, &n))) goto fail;
    if (asprintf(&t->gen_name, "gen-%u", n) < 0) { err = PPM_ERR_NOMEM; goto fail; }
    if ((err = ppm_path_join(t->profile_dir, t->gen_name, &t->gen_dir))) goto fail;
    if (mkdir(t->gen_dir, 0755) != 0) { err = PPM_ERR_IO; goto fail; }

    /* Build a unique journal id. */
    unsigned char raw[8];
    randombytes_buf(raw, sizeof(raw));
    char tag[17];
    sodium_bin2hex(tag, sizeof(tag), raw, sizeof(raw));
    if (asprintf(&t->txn_id, "txn-%s", tag) < 0) { err = PPM_ERR_NOMEM; goto fail; }

    char *jname;
    if (asprintf(&jname, "%s.journal", t->txn_id) < 0) { err = PPM_ERR_NOMEM; goto fail; }
    char *tmpdir;
    if ((err = ppm_path_join(store->root, "tmp", &tmpdir))) { free(jname); goto fail; }
    err = ppm_path_join(tmpdir, jname, &t->journal_path);
    free(tmpdir); free(jname);
    if (err) goto fail;

    if ((err = journal_open(t->journal_path, &t->journal))) goto fail;

    if ((err = journal_write(t->journal, "v1")))                                goto fail;
    if ((err = journal_write(t->journal, "profile\t%s", t->profile_dir)))       goto fail;
    if ((err = journal_write(t->journal, "gen\t%s", t->gen_dir)))               goto fail;
    if ((err = journal_write(t->journal, "prev\t%s",
                             t->prev_target ? t->prev_target : "-")))           goto fail;

    *out = t;
    return PPM_OK;

fail:
    ppm_txn_abort(t);
    return err;
}

ppm_error_t ppm_txn_stage(ppm_txn_t *t, const ppm_lock_entry_t *entry) {
    if (!t || !entry || t->committed) return PPM_ERR_INVALID;

    /* Enforce the one-version rule: same name twice = conflict. */
    for (size_t i = 0; i < t->entries_len; i++) {
        if (strcmp(t->entries[i].name, entry->name) == 0) {
            ppm_error("transaction: package '%s' staged twice (one-version rule)",
                      entry->name);
            return PPM_ERR_CONFLICT;
        }
    }

    /* The store MUST already contain this hash. */
    if (!ppm_store_has(t->store, &entry->hash)) {
        ppm_error("transaction: '%s' not in store; ingest the tarball first",
                  entry->name);
        return PPM_ERR_NOT_FOUND;
    }

    ppm_error_t err = entries_reserve(t, t->entries_len + 1);
    if (err) return err;

    /* Build a textual version for the manifest line. We do not own the
     * lock entry, so we serialise just enough to round-trip. */
    char ver[96];
    int n = snprintf(ver, sizeof(ver), "%u.%u.%u",
                     entry->version.major, entry->version.minor,
                     entry->version.patch);
    if (n < 0 || (size_t)n >= sizeof(ver)) return PPM_ERR_INVALID;

    t->entries[t->entries_len].name    = strdup(entry->name);
    t->entries[t->entries_len].version = strdup(ver);
    t->entries[t->entries_len].hash    = entry->hash;
    if (!t->entries[t->entries_len].name || !t->entries[t->entries_len].version) {
        free(t->entries[t->entries_len].name);
        free(t->entries[t->entries_len].version);
        return PPM_ERR_NOMEM;
    }
    t->entries_len++;

    char hex[PPM_SHA512_HEX_BUFSZ];
    hex_buf(&entry->hash, hex);
    return journal_write(t->journal, "stage\t%s\t%s", hex, entry->name);
}

ppm_error_t ppm_txn_commit(ppm_txn_t *t) {
    if (!t || t->committed) return PPM_ERR_INVALID;

    /* 1. Materialise the pending manifest on disk. */
    ppm_error_t err = write_pending_manifest(t);
    if (err) return err;

    /* 2. Mark the journal "ready" — this is the durable cut-over point. */
    if ((err = journal_write(t->journal, "ready"))) return err;

    /* 3. Flip the current symlink atomically. */
    if ((err = swap_current_symlink(t->profile_dir, t->gen_name))) return err;

    /* 4. Done. Close and remove the journal. */
    fclose(t->journal);
    t->journal = NULL;
    unlink(t->journal_path);
    ppm_path_fsync_dir(t->profile_dir);

    t->committed = 1;
    return PPM_OK;
}

void ppm_txn_abort(ppm_txn_t *t) {
    if (!t) return;

    /* If we never reached commit, blow away the pending generation. */
    if (!t->committed && t->gen_dir) {
        ppm_path_rmrf(t->gen_dir);
    }
    if (t->journal) fclose(t->journal);
    if (t->journal_path) unlink(t->journal_path);

    unlock_profile(t->lock_fd);

    for (size_t i = 0; i < t->entries_len; i++) {
        free(t->entries[i].name);
        free(t->entries[i].version);
    }
    free(t->entries);
    free(t->profile);
    free(t->profile_dir);
    free(t->gen_dir);
    free(t->gen_name);
    free(t->prev_target);
    free(t->journal_path);
    free(t->txn_id);
    free(t);
}

ppm_error_t ppm_txn_current(ppm_store_t *store, const char *profile,
                            char **out_path) {
    if (!store || !profile || !out_path) return PPM_ERR_INVALID;
    *out_path = NULL;

    char *pdir;
    ppm_error_t err = profile_dir_path(store, profile, &pdir);
    if (err) return err;

    char *target;
    err = read_current_target(pdir, &target);
    if (err || !target) { free(pdir); return err; }

    err = ppm_path_join(pdir, target, out_path);
    free(target);
    free(pdir);
    return err;
}
