#include "capture_paths.h"

#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring>  // std::memcmp
#include <filesystem>
#include <vector>

namespace reasampler {

std::string hashBytes(const std::uint8_t* data, std::size_t len) {
    // FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
    // Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
    constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
    constexpr std::uint64_t kPrime       = 1099511628211ULL;
    std::uint64_t h = kOffsetBasis;
    for (std::size_t i = 0; i < len; ++i) {
        h ^= static_cast<std::uint64_t>(data[i]);
        h *= kPrime;
    }
    // Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
    char buf[17];
    std::snprintf(buf, sizeof(buf), "%016llx",
                  static_cast<unsigned long long>(h));
    return std::string(buf);
}

std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
    // Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
    // through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
    // Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
    // If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
    // to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
    //
    // The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
    // FNV state instead of recording geometry — no second parser, same logic.

    // FNV-1a 64-bit constants (same as hashBytes).
    constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
    constexpr std::uint64_t kPrime       = 1099511628211ULL;

    // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
    auto tagEq = [&](std::size_t off, const char* tag) -> bool {
        return off + 4 <= bytes.size() &&
               std::memcmp(bytes.data() + off, tag, 4) == 0;
    };
    auto readU32LE = [&](std::size_t off) -> std::uint32_t {
        return static_cast<std::uint32_t>(bytes[off]) |
               (static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
               (static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
               (static_cast<std::uint32_t>(bytes[off + 3]) << 24);
    };

    bool isWav = bytes.size() >= 12 &&
                 tagEq(0, "RIFF") &&
                 tagEq(8, "WAVE");

    if (isWav) {
        // Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
        std::uint64_t h = kOffsetBasis;
        auto feedByte = [&](std::uint8_t b) {
            h ^= static_cast<std::uint64_t>(b);
            h *= kPrime;
        };

        bool haveFmt  = false;
        bool haveData = false;

        // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
        // whole-file hash of different bytes that happen to be the same length.
        feedByte(static_cast<std::uint8_t>('W'));

        std::size_t pos = 12;
        while (pos + 8 <= bytes.size()) {
            const std::size_t bodyOffset = pos + 8;
            const std::uint32_t bodySize = readU32LE(pos + 4);

            if (tagEq(pos, "fmt ")) {
                // Feed the entire fmt body (all fields, including format tag, channels,
                // sample rate, bits-per-sample — everything that defines the audio format).
                if (bodyOffset + bodySize <= bytes.size()) {
                    for (std::uint32_t i = 0; i < bodySize; ++i)
                        feedByte(bytes[bodyOffset + i]);
                    haveFmt = true;
                }
            } else if (tagEq(pos, "data")) {
                // Feed the entire PCM payload.
                if (bodyOffset + bodySize <= bytes.size()) {
                    for (std::uint32_t i = 0; i < bodySize; ++i)
                        feedByte(bytes[bodyOffset + i]);
                    haveData = true;
                }
            }
            // All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.

            // Advance past this chunk's body, honoring RIFF even-byte padding.
            std::size_t advance = bodySize;
            if (advance & 1u) ++advance;  // RIFF pad byte
            if (advance > bytes.size() - bodyOffset) break;  // overrun guard
            pos = bodyOffset + advance;
        }

        if (haveFmt && haveData) {
            char buf[17];
            std::snprintf(buf, sizeof(buf), "%016llx",
                          static_cast<unsigned long long>(h));
            return std::string(buf);
        }
        // Falls through to whole-file fallback if chunks were missing/malformed.
    }

    // Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
    // per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
    return hashBytes(bytes.data(), bytes.size());
}

std::string normalizeSlashes(const std::string& path) {
    std::string out = path;
    for (char& c : out) {
        if (c == '\\') c = '/';
    }
    // Strip a single trailing slash so joins do not double up. Preserve a lone
    // "/" (root) — stripping it would turn root into empty.
    if (out.size() > 1 && out.back() == '/') {
        out.pop_back();
    }
#ifdef _WIN32
    // Windows paths are case-insensitive. Fold to lowercase so that two paths
    // that differ only in drive-letter or component casing compare equal (e.g.
    // "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
    // preserved (the filesystem is case-sensitive; folding would be wrong).
    for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif
    return out;
}

std::string sanitizeStem(const std::string& baseName) {
    std::string out;
    out.reserve(baseName.size());
    for (unsigned char c : baseName) {
        const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
                          (c >= '0' && c <= '9') || c == '.' || c == '_' ||
                          c == '-';
        out.push_back(keep ? static_cast<char>(c) : '_');
    }
    // Collapse to a stable default if nothing usable survived (e.g. all spaces).
    // A stem of only separators ('.', '_', '-') is also unhelpful as a name.
    bool hasAlnum = false;
    for (unsigned char c : out) {
        if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
            (c >= '0' && c <= '9')) {
            hasAlnum = true;
            break;
        }
    }
    if (out.empty() || !hasAlnum) {
        return "capture";
    }
    return out;
}

BankPaths deriveBankPaths(const std::string& projectDir,
                          const std::string& baseName,
                          const std::string& uniqueTag) {
    const std::string dir = normalizeSlashes(projectDir);

    std::string stem = sanitizeStem(baseName);
    if (!uniqueTag.empty()) {
        stem += "_" + sanitizeStem(uniqueTag);
    }
    const std::string fileName = stem + ".wav";

    // Precondition: the capture shell must resolve a non-empty project directory
    // before calling this function. An empty projectDir would produce a bare
    // relative "reasampler_bank" path — the silent default-location fallback this
    // tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
    // so any caller that ignores the precondition fails loudly at the render/stat
    // step rather than silently writing to CWD.
    assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");

    BankPaths p;
    p.fileStem     = stem;                                    // stem only — REAPER appends extension
    p.fileName     = fileName;
    p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
    // absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
    // directory itself; RENDER_PATTERN supplies the file name separately).
    // Empty when precondition is violated (dir empty) — caller must not proceed.
    p.absoluteDir  = dir.empty() ? std::string{}
                                 : dir + "/" + kBankSubfolder;
    return p;
}

std::string bankRelativeForName(const std::string& fileName) {
    if (fileName.empty()) return {};
    // The SAME expression deriveBankPaths uses for relativePath, kept in one place so
    // the two spellings can never drift (Phase R spelling-consistency invariant).
    return std::string(kBankSubfolder) + "/" + fileName;
}

std::string resolveBankFile(const std::string& projectDir,
                            const std::string& relativePath) {
    // No default-location fallback (CLAUDE.md invariant): an empty project dir or
    // relative path yields empty, not a bare relative path resolved against CWD.
    if (projectDir.empty() || relativePath.empty()) {
        return {};
    }
    const std::string dir = normalizeSlashes(projectDir);
    const std::string rel = normalizeSlashes(relativePath);
    if (dir.empty() || rel.empty()) {
        return {};
    }
    return dir + "/" + rel;
}

std::string projectDirOfRpp(const std::string& rppPath) {
    // An unsaved project reports an empty .rpp path; keep it empty so downstream
    // resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
    // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
    if (rppPath.empty()) return {};
    std::string dir = std::filesystem::path(rppPath).parent_path().string();
    return normalizeSlashes(dir);
}

BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
                                    const std::string& newProjectDir) {
    BankRelocation r;
    if (oldProjectDir.empty() || newProjectDir.empty()) {
        return r;  // needed=false, empty dirs — nothing to relocate
    }
    const std::string oldDir = normalizeSlashes(oldProjectDir);
    const std::string newDir = normalizeSlashes(newProjectDir);

    r.oldBankDir = oldDir + "/" + kBankSubfolder;
    r.newBankDir = newDir + "/" + kBankSubfolder;
    // A Save (in place) leaves the project dir unchanged — nothing to relocate.
    // Only a Save-As to a different directory needs the bank moved.
    r.needed = (oldDir != newDir);
    return r;
}

ProjectTransition classifyProjectTransition(bool sameProjectObject,
                                            const std::string& lastGuid,
                                            const std::string& lastPath,
                                            const std::string& currentGuid,
                                            const std::string& currentPath) {
    // 1. The GUID is the identity of record and is checked FIRST. A different
    //    stored GUID means a genuinely different project is active — Load ITS index.
    //    This catches the regression that pointer-primary classification missed:
    //    REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
    //    new project can reuse the previous project's address (sameProjectObject ==
    //    true) while carrying a different stored GUID. Deciding on the pointer alone
    //    then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
    //    immune to address recycling, so it leads. Also covers new/unsaved<->saved
    //    transitions (one GUID empty, the other not) and switching between two
    //    distinct saved projects.
    if (currentGuid != lastGuid) {
        return ProjectTransition::Load;
    }

    // From here currentGuid == lastGuid (they are equal; both may be empty for
    // unsaved projects). The pointer now disambiguates the same-GUID case.

    // 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
    //    GUID onto a distinct project object. Load its (own) index; never relocate.
    //    Two unsaved projects (both GUIDs empty, distinct objects) also land here —
    //    Load, so switching between them installs the right in-memory state.
    if (!sameProjectObject) {
        return ProjectTransition::Load;
    }

    // 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
    //    identity is proven and the record identity is unchanged — only the .rpp
    //    moved). Also the first save of an unsaved project (both GUIDs empty, old
    //    path empty): SaveAsRelocate is safe there because deriveRelocationPlan
    //    no-ops on the empty old dir (empty-GUID safety preserved) while poll()
    //    mints a GUID.
    if (currentPath != lastPath) {
        return ProjectTransition::SaveAsRelocate;
    }

    // 4. Same object, same GUID, same path — Save in place / idle tick.
    return ProjectTransition::NoOp;
}

} // namespace reasampler
