// Standalone tests for reasampler::capture_name — no REAPER, no framework. Covers the
// name SHAPE (label vs file-stem base, multi-source marker, batch ordinal, discriminator)
// and the awkward source names: empty, all-punctuation, non-ASCII, over-long, duplicate.
//
// The stem base is asserted through sanitizeStem here as well as raw, because the stem's
// real contract is "survives the sanitizer as something filesystem-legal", not "equals
// this string" — sanitizeStem is the function that has to hold, and it is capture_paths'.

#include "../src/core/capture/capture_name.h"
#include "../src/core/capture/capture_paths.h"

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

using namespace reasampler;
using namespace reasampler::capture;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

// A fixed stamp so every expectation below is a literal, not a re-derivation.
static CaptureStamp stamp() { return CaptureStamp{8, 1, 14, 32}; }

static CaptureNameInputs inputsFor(std::vector<std::string> names,
                                   int ordinal = 0,
                                   const std::string& fallback = "item") {
    CaptureNameInputs in;
    in.sourceNames = std::move(names);
    in.stamp       = stamp();
    in.ordinal     = ordinal;
    in.fallback    = fallback;
    return in;
}

// --- the discriminator -------------------------------------------------------

static void testStampIsZeroPaddedMonthDayHourMinute() {
    CHECK(formatCaptureStamp(CaptureStamp{8, 1, 14, 32}) == "08-01 1432");
    CHECK(formatCaptureStamp(CaptureStamp{12, 25, 0, 5}) == "12-25 0005");
}

static void testUnsetStampProducesNoDiscriminator() {
    // A failed clock read leaves the stamp zeroed; the label must degrade to the bare
    // name rather than render "00-00 0000".
    CHECK(formatCaptureStamp(CaptureStamp{}) == "");
    const CaptureNameInputs in{{"Bass"}, CaptureStamp{}, 0, "item"};
    CHECK(composeCaptureName(in).label == "Bass");
}

// --- ordinary derivation ------------------------------------------------------

static void testOrdinaryNameLabelsAndFilesAfterTheTrack() {
    const CaptureName n = composeCaptureName(inputsFor({"Bass"}));
    CHECK(n.label == "Bass 08-01 1432");
    CHECK(n.stemBase == "Bass");
    CHECK(sanitizeStem(n.stemBase) == "Bass");
}

static void testTwoCapturesMinutesApartAreDistinguishable() {
    CaptureNameInputs a = inputsFor({"Bass"});
    CaptureNameInputs b = inputsFor({"Bass"});
    b.stamp.minute = 47;
    CHECK(composeCaptureName(a).label != composeCaptureName(b).label);
    CHECK(composeCaptureName(a).label == "Bass 08-01 1432");
    CHECK(composeCaptureName(b).label == "Bass 08-01 1447");
}

static void testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem() {
    const CaptureName n = composeCaptureName(inputsFor({"Lead Vox"}));
    CHECK(n.label == "Lead Vox 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "Lead_Vox");
}

static void testSurroundingWhitespaceIsTrimmed() {
    // Untrimmed, this would file as "__Bass__" and read ragged on the card.
    const CaptureName n = composeCaptureName(inputsFor({"  Bass  "}));
    CHECK(n.label == "Bass 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "Bass");
}

// --- awkward names ------------------------------------------------------------

static void testEmptyNameFallsBackToTheScopeLiteral() {
    // Unreachable in the DAW (GetTrackName answers "Track N" for an unnamed track), so
    // this pins the defensive path: the scope literal, never an empty label.
    const CaptureName n = composeCaptureName(inputsFor({""}, 0, "item"));
    CHECK(n.label == "item 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "item");
}

static void testNoSourceAtAllFallsBackToTheScopeLiteral() {
    const CaptureName n = composeCaptureName(inputsFor({}, 0, "track"));
    CHECK(n.label == "track 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "track");
}

static void testEmptyNameAndEmptyFallbackStillYieldALegalStem() {
    const CaptureName n = composeCaptureName(inputsFor({""}, 0, ""));
    CHECK(n.label == "capture 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "capture");
}

static void testAllWhitespaceNameFallsBackToTheScopeLiteral() {
    // Distinct path from an empty name: trimmed() is what empties it, before truncation
    // ever runs.
    const CaptureName n = composeCaptureName(inputsFor({"   "}, 0, "item"));
    CHECK(n.label == "item 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "item");
}

static void testNameThatTruncatesToNothingFallsBackToCapture() {
    // Over-length and made entirely of a UTF-8 continuation byte (0x80-0xBF): not empty
    // pre-truncation, so it survives trimmed()/the fallback chain as a real name — but
    // truncateUtf8 backs off over continuation bytes and walks all the way to 0 (every byte
    // in the first kMaxSourceNameBytes is a continuation byte), which used to leave an
    // empty label.
    const std::string allContinuation(kMaxSourceNameBytes + 10, '\x80');
    const CaptureName n = composeCaptureName(inputsFor({allContinuation}));
    CHECK(n.label == "capture 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "capture");
}

static void testUnnamedTrackUsesReaperTrackNConvention() {
    // What GetTrackName actually hands back for an unnamed track — the deterministic
    // fallback rides in as an ordinary name, no special case in the composer.
    const CaptureName n = composeCaptureName(inputsFor({"Track 3"}));
    CHECK(n.label == "Track 3 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "Track_3");
}

static void testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem() {
    const CaptureName n = composeCaptureName(inputsFor({"***"}));
    CHECK(n.label == "*** 08-01 1432");   // the label is display-only; punctuation is fine
    CHECK(n.stemBase == "***");
    CHECK(sanitizeStem(n.stemBase) == "capture");  // nothing alnum survives
}

static void testNonAsciiNameKeepsTheLabelAndCollapsesTheStem() {
    const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9";  // UTF-8 "ベース"
    const CaptureName n = composeCaptureName(inputsFor({kana}));
    CHECK(n.label == kana + " 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "capture");
}

static void testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem() {
    const std::string mixed = "Bass\xC3\xA9";  // "Bassé"
    const CaptureName n = composeCaptureName(inputsFor({mixed}));
    const std::string stem = sanitizeStem(n.stemBase);
    CHECK(stem.rfind("Bass", 0) == 0);   // recognizable
    CHECK(stem != "capture");            // did not collapse
}

static void testOverLongNameIsBoundedInBothLabelAndStem() {
    const std::string huge(400, 'x');
    const CaptureName n = composeCaptureName(inputsFor({huge}));
    CHECK(n.stemBase.size() == kMaxSourceNameBytes);
    CHECK(sanitizeStem(n.stemBase).size() == kMaxSourceNameBytes);
    // Label = bounded name + " MM-DD HHMM".
    CHECK(n.label.size() == kMaxSourceNameBytes + 11);
}

static void testOverLongNonAsciiNameIsNotCutMidCharacter() {
    // 3-byte characters do not tile the 64-byte bound evenly, so a naive cut would leave
    // a truncated sequence in a label that goes on to be persisted as JSON.
    std::string kana;
    for (int i = 0; i < 60; ++i) kana += "\xE3\x83\x99";  // 180 bytes of "ベ"
    const CaptureName n = composeCaptureName(inputsFor({kana}));
    CHECK(n.stemBase.size() % 3 == 0);
    CHECK(n.stemBase.size() <= kMaxSourceNameBytes);
    CHECK(n.stemBase.size() > kMaxSourceNameBytes - 3);  // took as much as fits
}

static void testTwoTracksWithTheSameNameComposeIdentically() {
    // Deliberate: displayName is explicitly NOT unique, and stem uniqueness is
    // makeUniqueTag's job, not the composer's.
    const CaptureName a = composeCaptureName(inputsFor({"Bass"}));
    const CaptureName b = composeCaptureName(inputsFor({"Bass"}));
    CHECK(a.label == b.label);
    CHECK(a.stemBase == b.stemBase);
}

// --- multi-source -------------------------------------------------------------

static void testMultiTrackSourceMarksTheExtraCount() {
    const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums", "Keys"}));
    CHECK(n.label == "Bass +2 08-01 1432");
    CHECK(n.stemBase == "Bass+2");
    CHECK(sanitizeStem(n.stemBase) == "Bass_2");
}

static void testMultiTrackSourceIgnoresUnnamedEntriesInTheCount() {
    const CaptureName n = composeCaptureName(inputsFor({"Bass", ""}));
    CHECK(n.label == "Bass 08-01 1432");  // one real source, no marker
}

static void testMultiTrackSourceNamesAfterTheFirstNamedTrack() {
    const CaptureName n = composeCaptureName(inputsFor({"", "Drums", "Keys"}));
    CHECK(n.label == "Drums +1 08-01 1432");
}

// --- batch ordinals -----------------------------------------------------------

static void testBatchOrdinalDistinguishesUnitsFromOneTrack() {
    const CaptureName a = composeCaptureName(inputsFor({"Bass"}, 1));
    const CaptureName b = composeCaptureName(inputsFor({"Bass"}, 2));
    CHECK(a.label == "Bass #1 08-01 1432");
    CHECK(b.label == "Bass #2 08-01 1432");
    CHECK(sanitizeStem(a.stemBase) == "Bass-1");
    CHECK(sanitizeStem(b.stemBase) == "Bass-2");
}

static void testOrdinalZeroAddsNothing() {
    CHECK(composeCaptureName(inputsFor({"Bass"}, 0)).stemBase == "Bass");
}

static void testOrdinalAndMultiSourceCompose() {
    const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums"}, 3));
    CHECK(n.label == "Bass +1 #3 08-01 1432");
    CHECK(sanitizeStem(n.stemBase) == "Bass_1-3");
}

// --- stem legality across every awkward input ---------------------------------

static void testEveryAwkwardStemStaysFilesystemLegal() {
    const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9";
    const std::vector<std::string> names = {
        "Bass", "", "***", kana, std::string(400, 'x'), "Lead Vox", "Track 3",
        "a/b\\c:d*e?f\"g<h>i|j",
    };
    for (const std::string& raw : names) {
        const std::string stem = sanitizeStem(composeCaptureName(inputsFor({raw})).stemBase);
        CHECK(!stem.empty());
        for (unsigned char c : stem) {
            const bool legal = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
                               (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
            CHECK(legal);
        }
    }
}

// --- captureTrackName -------------------------------------------------------

static void testCaptureTrackNamePrefixesAPlainSourceName() {
    CHECK(captureTrackName("MONEY") == "Capture MONEY");
    CHECK(captureTrackName("bass di") == "Capture bass di");
}

static void testCaptureTrackNameIsIdempotent() {
    // The whole point: a second render over a result track must not stack the prefix.
    CHECK(captureTrackName("Capture MONEY") == "Capture MONEY");
    CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY");
    // A fixed point on its own output for EVERY input, degenerate ones included.
    for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) {
        const std::string once = captureTrackName(src);
        CHECK(captureTrackName(once) == once);
    }
}

static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() {
    // Unreachable from trackName (GetTrackName always answers "Track N"), so this is
    // the defensive case — a bare word rather than a name ending in a space.
    CHECK(captureTrackName("") == "Capture");
}

static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() {
    // trackName's GetTrackName fallback rides in as an ordinary name.
    CHECK(captureTrackName("Track 7") == "Capture Track 7");
}

static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() {
    // "Captured" begins with "Capture" but not with "Capture " — it is a different
    // name and must be prefixed like any other.
    CHECK(captureTrackName("Captured drums") == "Capture Captured drums");
}

int main() {
    testStampIsZeroPaddedMonthDayHourMinute();
    testUnsetStampProducesNoDiscriminator();
    testOrdinaryNameLabelsAndFilesAfterTheTrack();
    testTwoCapturesMinutesApartAreDistinguishable();
    testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem();
    testSurroundingWhitespaceIsTrimmed();
    testEmptyNameFallsBackToTheScopeLiteral();
    testNoSourceAtAllFallsBackToTheScopeLiteral();
    testEmptyNameAndEmptyFallbackStillYieldALegalStem();
    testAllWhitespaceNameFallsBackToTheScopeLiteral();
    testNameThatTruncatesToNothingFallsBackToCapture();
    testUnnamedTrackUsesReaperTrackNConvention();
    testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem();
    testNonAsciiNameKeepsTheLabelAndCollapsesTheStem();
    testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem();
    testOverLongNameIsBoundedInBothLabelAndStem();
    testOverLongNonAsciiNameIsNotCutMidCharacter();
    testTwoTracksWithTheSameNameComposeIdentically();
    testMultiTrackSourceMarksTheExtraCount();
    testMultiTrackSourceIgnoresUnnamedEntriesInTheCount();
    testMultiTrackSourceNamesAfterTheFirstNamedTrack();
    testBatchOrdinalDistinguishesUnitsFromOneTrack();
    testOrdinalZeroAddsNothing();
    testOrdinalAndMultiSourceCompose();
    testEveryAwkwardStemStaysFilesystemLegal();

    testCaptureTrackNamePrefixesAPlainSourceName();
    testCaptureTrackNameIsIdempotent();
    testCaptureTrackNameEmptySourceHasNoTrailingSpace();
    testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN();
    testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord();

    if (g_fail == 0) std::printf("capture_name: all tests passed\n");
    else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
    return g_fail ? 1 : 0;
}
