// AGI v2 resource loader.
//
// Parses LOGDIR/PICDIR/VIEWDIR/SNDDIR into in-RAM index tables, keeps
// every VOL.x file open for the session, and exposes a single
// resource-by-id load primitive that hands back a freshly-allocated
// payload buffer.
//
// AGI v2 directory entry format (3 bytes, big-endian within the byte
// stream): byte0 high nibble is the volume number, the 20-bit value
// formed from (byte0 & 0x0F):byte1:byte2 is the file offset. All-FF
// means the slot is empty (no resource with that ID).
//
// VOL.x record header (5 bytes at the directory's offset): 0x12 0x34
// signature, then the volume number, then a little-endian 16-bit
// payload length. v2 payloads are stored raw; v3 sets bit 7 of the
// volume byte to flag LZW compression (not handled here).

#include "agi.h"

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

#include "joey/file.h"


// AGI directory files are flat arrays of 3-byte entries. The maximum
// directory length the loader will accept is bounded by AGI_MAX_-
// RESOURCES; larger files are reported as bad rather than truncated.
#define AGI_DIR_ENTRY_BYTES   3

// VOL.x resource records start with a 5-byte header.
#define AGI_VOL_HEADER_BYTES  5
#define AGI_VOL_SIG_0         0x12
#define AGI_VOL_SIG_1         0x34
#define AGI_VOL_COMPRESSED    0x80
#define AGI_VOL_VOLUME_MASK   0x7F

// Resource file names are opened relative to DATA/ via jlDataOpen (see
// joey/file.h); the longest name AGI forms is "VOL.<n>". 16 is ample.
#define AGI_VOL_NAME_MAX      16

// Empty-slot sentinel: must be >= AGI_MAX_VOLUMES so agiResLoad
// rejects empty entries. The on-disk empty marker is 0xFFFFFF (high
// nibble 0xF = volume 15); 15 is a legitimate volume number, so we
// don't reuse it as the sentinel.
#define AGI_DIR_EMPTY_VOLUME  0xFF


// IIgs 64 KB code-bank rule: park this TU in its own load segment
// so it doesn't pile onto _ROOT (which still holds main() and the
// runtime startup). Cross-platform builds ignore this pragma.
#ifdef JOEYLIB_PLATFORM_IIGS
#endif


// ----- Prototypes -----

static bool        buildVolName(char *out, size_t outSize, uint8_t volNum);
static bool        loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type);
static bool        loadWords(AgiGameT *game);
static bool        openVolumes(AgiGameT *game);
static const char *resTypeFileName(AgiResTypeE type);
static bool        wordEqCI(const char *a, const char *b);


// ----- Internal helpers (alphabetical) -----

static bool buildVolName(char *out, size_t outSize, uint8_t volNum) {
    char  *p;

    // "VOL." + up to 2 digits + NUL = 7 chars max.
    if (outSize < 8u) {
        return false;
    }
    p = out;
    *p++ = 'V';
    *p++ = 'O';
    *p++ = 'L';
    *p++ = '.';
    if (volNum >= 10u) {
        *p++ = (char)('0' + (volNum / 10u));
        *p++ = (char)('0' + (volNum % 10u));
    } else {
        *p++ = (char)('0' + volNum);
    }
    *p = '\0';
    return true;
}


static bool loadDirectory(AgiGameT *game, const char *fileName, AgiResTypeE type) {
    // One directory is resident at a time; a file-scope buffer keeps this
    // ~768-byte read off the shallow IIgs soft stack.
    static uint8_t  dirBytes[AGI_MAX_RESOURCES * AGI_DIR_ENTRY_BYTES];
    FILE           *fp;
    uint16_t        entryCount;
    uint16_t        i;
    uint8_t         volNibble;
    uint32_t        offset;
    size_t          got;

    // fileName is a bare name relative to DATA/ (e.g. "LOGDIR").
    fp = jlDataOpen(fileName, "rb");
    if (fp == NULL) {
        return false;
    }

    // Read the whole flat 3-byte-entry array in one call rather than one
    // read per entry: on the IIgs each fread is a GS/OS round trip through
    // SmartPort firmware, and a directory never exceeds AGI_MAX_RESOURCES
    // entries, so the fixed buffer holds any valid file. A non-multiple-of-3
    // length means the file is malformed.
    got = fread(dirBytes, 1, sizeof(dirBytes), fp);
    fclose(fp);
    if (got == 0u || (got % AGI_DIR_ENTRY_BYTES) != 0u) {
        return false;
    }

    entryCount = (uint16_t)(got / AGI_DIR_ENTRY_BYTES);
    for (i = 0; i < entryCount; i++) {
        const uint8_t  *e = &dirBytes[(uint32_t)i * AGI_DIR_ENTRY_BYTES];

        volNibble = (uint8_t)(e[0] >> 4);
        // 20-bit offset, cast to uint32_t before shift so a
        // 16-bit int doesn't truncate the (& 0x0F) << 16 term.
        offset = ((uint32_t)(e[0] & 0x0F) << 16) | ((uint32_t)e[1] << 8) | (uint32_t)e[2];

        if (e[0] == 0xFF && e[1] == 0xFF && e[2] == 0xFF) {
            game->resDir[type][i].volume = AGI_DIR_EMPTY_VOLUME;
            game->resDir[type][i].offset = 0u;
        } else {
            game->resDir[type][i].volume = volNibble;
            game->resDir[type][i].offset = offset;
        }
    }

    game->resCount[type] = entryCount;
    return true;
}


static bool loadWords(AgiGameT *game) {
    FILE     *fp;
    uint8_t  *buf;
    size_t    got;

    game->words    = NULL;
    game->wordsLen = 0u;

    // The dictionary is optional: a game with no WORDS.TOK simply can't
    // match said()/parse (no crash), so a missing file is not an error.
    fp = jlDataOpen("WORDS.TOK", "rb");
    if (fp == NULL) {
        return true;
    }
    buf = (uint8_t *)malloc(AGI_WORDS_MAX);
    if (buf == NULL) {
        fclose(fp);
        return false;
    }
    got = fread(buf, 1, AGI_WORDS_MAX, fp);
    fclose(fp);
    // Anything shorter than the letter-index header can't hold a word.
    if (got <= AGI_WORDS_HEADER_BYTES) {
        free(buf);
        return true;
    }
    game->words    = buf;
    game->wordsLen = (uint32_t)got;
    return true;
}


static bool openVolumes(AgiGameT *game) {
    char     name[AGI_VOL_NAME_MAX];
    uint8_t  v;
    bool     anyOpened;

    anyOpened = false;
    for (v = 0; v < AGI_MAX_VOLUMES; v++) {
        if (!buildVolName(name, sizeof(name), v)) {
            return false;
        }
        game->volFiles[v] = jlDataOpen(name, "rb");
        if (game->volFiles[v] != NULL) {
            anyOpened = true;
        }
    }
    return anyOpened;
}


static const char *resTypeFileName(AgiResTypeE type) {
    switch (type) {
        case AGI_RES_LOGIC: return "LOGDIR";
        case AGI_RES_PIC:   return "PICDIR";
        case AGI_RES_VIEW:  return "VIEWDIR";
        case AGI_RES_SOUND: return "SNDDIR";
        default:            return NULL;
    }
}


// Case-insensitive ASCII equality. Both dictionary words and (lowercased)
// typed words are ASCII; fold A-Z so a stray uppercase still matches.
static bool wordEqCI(const char *a, const char *b) {
    while (*a != '\0' && *b != '\0') {
        char  ca;
        char  cb;

        ca = *a;
        cb = *b;
        if (ca >= 'A' && ca <= 'Z') {
            ca = (char)(ca + ('a' - 'A'));
        }
        if (cb >= 'A' && cb <= 'Z') {
            cb = (char)(cb + ('a' - 'A'));
        }
        if (ca != cb) {
            return false;
        }
        a++;
        b++;
    }
    return *a == '\0' && *b == '\0';
}


// ----- Public API (alphabetical) -----

void agiResClose(AgiGameT *game) {
    uint8_t  v;
    uint8_t  t;

    for (v = 0; v < AGI_MAX_VOLUMES; v++) {
        if (game->volFiles[v] != NULL) {
            fclose(game->volFiles[v]);
            game->volFiles[v] = NULL;
        }
    }
    for (t = 0; t < AGI_RES_COUNT; t++) {
        game->resCount[t] = 0u;
    }
    if (game->words != NULL) {
        free(game->words);
        game->words    = NULL;
        game->wordsLen = 0u;
    }
}


uint8_t *agiResLoad(const AgiGameT *game, AgiResTypeE type, uint16_t index, uint16_t *outLength) {
    const AgiResEntryT  *entry;
    FILE                *fp;
    uint8_t              header[AGI_VOL_HEADER_BYTES];
    uint8_t             *payload;
    uint16_t             length;
    uint8_t              flagsAndVolume;

    if (outLength != NULL) {
        *outLength = 0u;
    }
    if (type >= AGI_RES_COUNT) {
        return NULL;
    }
    if (index >= game->resCount[type]) {
        return NULL;
    }

    entry = &game->resDir[type][index];
    if (entry->volume >= AGI_MAX_VOLUMES) {
        return NULL;
    }
    fp = game->volFiles[entry->volume];
    if (fp == NULL) {
        return NULL;
    }

    if (fseek(fp, (long)entry->offset, SEEK_SET) != 0) {
        return NULL;
    }
    if (fread(header, 1, AGI_VOL_HEADER_BYTES, fp) != AGI_VOL_HEADER_BYTES) {
        return NULL;
    }
    if (header[0] != AGI_VOL_SIG_0 || header[1] != AGI_VOL_SIG_1) {
        return NULL;
    }

    flagsAndVolume = header[2];
    if (flagsAndVolume & AGI_VOL_COMPRESSED) {
        // v3 LZW-compressed resource. Phase 2 work; not handled yet.
        return NULL;
    }
    if ((flagsAndVolume & AGI_VOL_VOLUME_MASK) != entry->volume) {
        return NULL;
    }

    length = (uint16_t)(((uint16_t)header[4] << 8) | (uint16_t)header[3]);
    if (length == 0u) {
        return NULL;
    }

    payload = (uint8_t *)malloc(length);
    if (payload == NULL) {
        return NULL;
    }
    if (fread(payload, 1, length, fp) != length) {
        free(payload);
        return NULL;
    }

    if (outLength != NULL) {
        *outLength = length;
    }
    return payload;
}


bool agiResOpen(AgiGameT *game) {
    uint8_t  t;

    memset(game, 0, sizeof(*game));

    // Resource files are opened by bare name relative to DATA/ (jlDataOpen);
    // the game's data is whatever the packager staged there, so there is no
    // runtime game-directory to thread through.
    for (t = 0; t < AGI_RES_COUNT; t++) {
        if (!loadDirectory(game, resTypeFileName((AgiResTypeE)t), (AgiResTypeE)t)) {
            agiResClose(game);
            return false;
        }
    }

    if (!openVolumes(game)) {
        agiResClose(game);
        return false;
    }

    // The dictionary is optional (loadWords only fails on OOM); a game
    // without WORDS.TOK still runs, just with no parser matches.
    if (!loadWords(game)) {
        agiResClose(game);
        return false;
    }

    return true;
}


uint16_t agiWordId(const AgiGameT *game, const char *word) {
    const uint8_t  *p;
    const uint8_t  *end;
    char            cur[AGI_WORD_MAX_LEN + 1u];
    uint8_t         len;

    if (game->words == NULL || word == NULL) {
        return AGI_WORD_UNKNOWN;
    }

    // Word entries follow the fixed 52-byte letter-index header. Each entry:
    //   1 byte  prefixLen  -- chars shared with the previous word
    //   n bytes suffix     -- each (c & 0x7F) ^ 0x7F; the char with bit 7
    //                         set is the last one of the word
    //   2 bytes groupId    -- big-endian
    // `cur` persists across entries so prefixLen chars carry forward.
    p   = game->words + AGI_WORDS_HEADER_BYTES;
    end = game->words + game->wordsLen;
    len = 0u;
    while (p < end) {
        uint8_t   prefixLen;
        uint16_t  id;

        prefixLen = *p++;
        if (prefixLen > AGI_WORD_MAX_LEN) {
            break;   // malformed dictionary
        }
        len = prefixLen;
        for (;;) {
            uint8_t  c;

            if (p >= end) {
                return AGI_WORD_UNKNOWN;
            }
            c = *p++;
            if (len < AGI_WORD_MAX_LEN) {
                cur[len] = (char)((c & 0x7Fu) ^ 0x7Fu);
                len++;
            }
            if ((c & 0x80u) != 0u) {
                break;
            }
        }
        cur[len] = '\0';

        if (p + 1 >= end) {
            return AGI_WORD_UNKNOWN;
        }
        id = (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
        p += 2;
        if (wordEqCI(cur, word)) {
            return id;
        }
    }
    return AGI_WORD_UNKNOWN;
}
