/**
 * Unit tests for the Architect language lexer.
 *
 * Acceptance criteria (from AST_ARCHITECTURE.md Phase 1):
 * 1. Tokenises a variety of TXT fixtures without throwing.
 * 2. Every character in every fixture is accounted for in the token stream.
 * 3. Hash-on / hash-off mode transitions are correctly detected.
 */

import * as fs from "fs";
import * as path from "path";
import { tokenize } from "../../core/src/lexer/lexer.js";
import { TokenType } from "../../core/src/lexer/tokenTypes.js";

import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Load a TXT fixture by path relative to the syntax-tests directory.
 */
function loadFixture(relativePath) {
    const fullPath = path.join(
        __dirname,
        "fixtures",
        relativePath
    );
    return fs.readFileSync(fullPath, "utf8");
}

/**
 * Reconstruct the original text from a token stream.
 * Every character must be accounted for (no silent drops).
 */
function reconstructFromTokens(tokens) {
    return tokens
        .filter(tok => tok.type !== TokenType.EOF)
        .map(tok => tok.value)
        .join("");
}

/**
 * Return all tokens of a given type from a stream.
 */
function tokensOfType(tokens, type) {
    return tokens.filter(tok => tok.type === type);
}

/**
 * Return all keyword tokens whose value (uppercased) matches the given string.
 */
function keywordTokens(tokens, upperValue) {
    return tokens.filter(
        tok => tok.type === TokenType.KEYWORD && tok.value.toUpperCase() === upperValue
    );
}

// ---------------------------------------------------------------------------
// Acceptance criterion 2: full character coverage
// ---------------------------------------------------------------------------

describe("lexer — character coverage", () => {
    const fixtures = [
        "hash-mode/hash-mode-explicit-off.TXT",
        "hash-mode/hash-mode-on-unprefixed-output.TXT",
        "hash-mode/hash-mode-on-note-style.TXT",
        "edge-cases/edge-case-hash-comment.TXT",
        "edge-cases/edge-case-alt-var-syntax.TXT",
        "core/builtin-get-str.TXT",
        "core/forward-function-call-error.TXT",
        "core/type-mismatch-string-to-short.TXT",
        "validators/keyword-capitalization-errors.TXT",
        "validators/keyword-capitalization-valid.TXT",
        "validators/string-literals-closed.TXT",
        "control-flow/control-flow-exit.TXT",
        "function-capitalisation-mismatch/func-case-mismatch-fix-call.TXT",
        "macro/macro-redeclare-case.TXT",
    ];

    for (const fixture of fixtures) {
        test(`reconstructs original text — ${fixture}`, () => {
            const text = loadFixture(fixture);
            const tokens = tokenize(text);
            expect(tokens[tokens.length - 1].type).toBe(TokenType.EOF);
            // Reconstructing from all non-EOF tokens must equal the source exactly.
            expect(reconstructFromTokens(tokens)).toBe(text);
        });
    }
});

// ---------------------------------------------------------------------------
// Acceptance criterion 3: hash mode transitions
// ---------------------------------------------------------------------------

describe("lexer — hash mode: HASH-ON (default)", () => {
    test("lines without # are OUTPUT_TEXT in HASH-ON mode", () => {
        // hash-mode-on-unprefixed-output.TXT has unprefixed lines that are output.
        const text = loadFixture("hash-mode/hash-mode-on-unprefixed-output.TXT");
        const tokens = tokenize(text);
        const outputTokens = tokensOfType(tokens, TokenType.OUTPUT_TEXT);
        // At least one output token must be present (the unprefixed line).
        expect(outputTokens.length).toBeGreaterThan(0);
        // The comment line (// #SET...) starts with // not # — also output in HASH-ON.
        const outputTexts = outputTokens.map(tok => tok.value);
        expect(outputTexts.some(v => v.includes("output text in HASH-ON"))).toBe(true);
    });

    test("lines starting with # are code in HASH-ON mode (emits HASH_PREFIX)", () => {
        const text = loadFixture("hash-mode/hash-mode-on-unprefixed-output.TXT");
        const tokens = tokenize(text);
        const hashPrefixTokens = tokensOfType(tokens, TokenType.HASH_PREFIX);
        expect(hashPrefixTokens.length).toBeGreaterThan(0);
    });

    test("HASH-ON is the default — first line code without #HASH-OFF", () => {
        // hash-mode-on-unprefixed-output.TXT starts with #VAR — this is a HASH-ON code line.
        const text = loadFixture("hash-mode/hash-mode-on-unprefixed-output.TXT");
        const tokens = tokenize(text);
        // First meaningful token on line 0 should be HASH_PREFIX.
        const firstLine0 = tokens.find(tok => tok.line === 0 && tok.type !== TokenType.WHITESPACE);
        expect(firstLine0.type).toBe(TokenType.HASH_PREFIX);
    });
});

describe("lexer — hash mode: HASH-OFF transition", () => {
    test("HASH-OFF directive switches mode so subsequent lines without >> are code", () => {
        // hash-mode-explicit-off.TXT: first line #HASH-OFF, then bare code lines.
        const text = loadFixture("hash-mode/hash-mode-explicit-off.TXT");
        const tokens = tokenize(text);

        // Line 0 is #HASH-OFF — code line (HASH_PREFIX + KEYWORD).
        const line0Tokens = tokens.filter(tok => tok.line === 0);
        expect(line0Tokens.some(tok => tok.type === TokenType.HASH_PREFIX)).toBe(true);
        expect(keywordTokens(line0Tokens, "HASH-OFF").length).toBe(1);

        // Lines after HASH-OFF that don't start with >> must NOT be OUTPUT_TEXT.
        // Line 13 is "VAR zMessage : STRING" — should produce KEYWORD, not OUTPUT_TEXT.
        const line2Tokens = tokens.filter(tok => tok.line === 13);
        expect(line2Tokens.some(tok => tok.type === TokenType.OUTPUT_TEXT)).toBe(false);
        expect(line2Tokens.some(tok => tok.type === TokenType.KEYWORD && tok.value.toUpperCase() === "VAR")).toBe(true);
    });

    test("HASH-OFF mode: no HASH_PREFIX tokens emitted for code lines", () => {
        const text = loadFixture("hash-mode/hash-mode-explicit-off.TXT");
        const tokens = tokenize(text);

        // Only line 0 (#HASH-OFF) should have a HASH_PREFIX.
        const hashPrefixes = tokensOfType(tokens, TokenType.HASH_PREFIX);
        expect(hashPrefixes.every(tok => tok.line === 0)).toBe(true);
    });

    test("HASH-ON can be re-activated after HASH-OFF", () => {
        // Build a synthetic document that switches modes.
        const text = [
            "#HASH-OFF",      // line 0 — code, triggers HASH-OFF
            "VAR x : SHORT",  // line 1 — code (HASH-OFF)
            "#HASH-ON",       // line 2 — code (still HASH-OFF, starts with #)
            "output text",    // line 3 — output (HASH-ON restored)
            "#VAR y : SHORT", // line 4 — code (HASH-ON)
        ].join("\n");

        const tokens = tokenize(text);

        // Line 3 should be OUTPUT_TEXT (HASH-ON restored by line 2).
        const line3 = tokens.filter(tok => tok.line === 3);
        expect(line3.some(tok => tok.type === TokenType.OUTPUT_TEXT)).toBe(true);

        // Line 4 should have HASH_PREFIX (HASH-ON mode).
        const line4 = tokens.filter(tok => tok.line === 4);
        expect(line4.some(tok => tok.type === TokenType.HASH_PREFIX)).toBe(true);
    });
});

// ---------------------------------------------------------------------------
// Token type correctness
// ---------------------------------------------------------------------------

describe("lexer — keyword classification", () => {
    test("keywords are classified as KEYWORD (case-insensitive)", () => {
        const tokens = tokenize("#IF x\n#endif\n#Else\n");
        const kwTokens = tokensOfType(tokens, TokenType.KEYWORD);
        const upperValues = kwTokens.map(t => t.value.toUpperCase());
        expect(upperValues).toContain("IF");
        expect(upperValues).toContain("ENDIF");
        expect(upperValues).toContain("ELSE");
    });

    test("keyword tokens preserve original casing in value", () => {
        const tokens = tokenize("#endif\n");
        const kw = tokensOfType(tokens, TokenType.KEYWORD).find(t => t.value.toUpperCase() === "ENDIF");
        expect(kw.value).toBe("endif"); // original casing preserved
    });

    test("type keywords are classified as TYPE_KEYWORD, not KEYWORD", () => {
        const tokens = tokenize("#VAR x : STRING\n");
        const typeKw = tokensOfType(tokens, TokenType.TYPE_KEYWORD);
        expect(typeKw.length).toBe(1);
        expect(typeKw[0].value.toUpperCase()).toBe("STRING");
    });

    test("hyphenated keywords HASH-ON and HASH-OFF are single KEYWORD tokens", () => {
        const tokens = tokenize("#HASH-OFF\n#HASH-ON\n");
        const kws = tokensOfType(tokens, TokenType.KEYWORD).map(t => t.value.toUpperCase());
        expect(kws).toContain("HASH-OFF");
        expect(kws).toContain("HASH-ON");
    });

    test("END-OF-CODE is a single KEYWORD token", () => {
        const tokens = tokenize("#END-OF-CODE\n");
        const kws = tokensOfType(tokens, TokenType.KEYWORD).map(t => t.value.toUpperCase());
        expect(kws).toContain("END-OF-CODE");
    });
});

describe("lexer — identifier classification", () => {
    test("unknown words are IDENTIFIER", () => {
        const tokens = tokenize("#myVar = 1\n");
        const ids = tokensOfType(tokens, TokenType.IDENTIFIER);
        expect(ids.some(t => t.value === "myVar")).toBe(true);
    });

    test("builtin function names are IDENTIFIER (registry lookup is not the lexer's job)", () => {
        const tokens = tokenize("#GET_TOKEN(x, \",\", 1)\n");
        const ids = tokensOfType(tokens, TokenType.IDENTIFIER);
        expect(ids.some(t => t.value === "GET_TOKEN")).toBe(true);
    });
});

describe("lexer — literals", () => {
    test("double-quoted string literal is STRING_LITERAL", () => {
        const tokens = tokenize("#SET x = \"hello world\"\n");
        const strs = tokensOfType(tokens, TokenType.STRING_LITERAL);
        expect(strs.length).toBe(1);
        expect(strs[0].value).toBe("\"hello world\"");
    });

    test("single-quoted string literal is STRING_LITERAL", () => {
        const tokens = tokenize("#SET x = 'hello'\n");
        const strs = tokensOfType(tokens, TokenType.STRING_LITERAL);
        expect(strs.length).toBe(1);
        expect(strs[0].value).toBe("'hello'");
    });

    test("integer is NUMBER_LITERAL", () => {
        const tokens = tokenize("#SET x = 42\n");
        const nums = tokensOfType(tokens, TokenType.NUMBER_LITERAL);
        expect(nums.length).toBe(1);
        expect(nums[0].value).toBe("42");
    });

    test("decimal is NUMBER_LITERAL", () => {
        const tokens = tokenize("#SET x = 3.14\n");
        const nums = tokensOfType(tokens, TokenType.NUMBER_LITERAL);
        expect(nums.length).toBe(1);
        expect(nums[0].value).toBe("3.14");
    });
});

describe("lexer — comments", () => {
    test("// comment is a single COMMENT token consuming rest of line", () => {
        const tokens = tokenize("#SET x = 1 // this is a comment\n");
        const comments = tokensOfType(tokens, TokenType.COMMENT);
        expect(comments.length).toBe(1);
        expect(comments[0].value).toBe("// this is a comment");
    });

    test("#// in HASH-ON mode: HASH_PREFIX then COMMENT", () => {
        // From edge-case-hash-comment.TXT
        const text = loadFixture("edge-cases/edge-case-hash-comment.TXT");
        const tokens = tokenize(text);
        // Line 3 is "#// This is a valid comment in HASH-ON mode"
        const line2 = tokens.filter(tok => tok.line === 3);
        expect(line2.some(tok => tok.type === TokenType.HASH_PREFIX)).toBe(true);
        expect(line2.some(tok => tok.type === TokenType.COMMENT)).toBe(true);
    });
});

describe("lexer — operators and punctuation", () => {
    test("?? is a single DOUBLE_QUESTION token", () => {
        const tokens = tokenize("#SET x = y ?? z\n");
        const dq = tokensOfType(tokens, TokenType.DOUBLE_QUESTION);
        expect(dq.length).toBe(1);
    });

    test("<> is a single NOT_EQ token", () => {
        const tokens = tokenize("#IF x <> y\n");
        const ne = tokensOfType(tokens, TokenType.NOT_EQ);
        expect(ne.length).toBe(1);
    });

    test("<= is LT_EQ, >= is GT_EQ", () => {
        const tokens = tokenize("#IF a <= b\n#IF c >= d\n");
        expect(tokensOfType(tokens, TokenType.LT_EQ).length).toBe(1);
        expect(tokensOfType(tokens, TokenType.GT_EQ).length).toBe(1);
    });

    test("( and ) are LPAREN and RPAREN", () => {
        const tokens = tokenize("#f(x)\n");
        expect(tokensOfType(tokens, TokenType.LPAREN).length).toBe(1);
        expect(tokensOfType(tokens, TokenType.RPAREN).length).toBe(1);
    });
});

// ---------------------------------------------------------------------------
// Token positions
// ---------------------------------------------------------------------------

describe("lexer — token positions", () => {
    test("token line numbers are 0-based", () => {
        const tokens = tokenize("#VAR x : SHORT\n#SET x = 1\n");
        const setKw = tokensOfType(tokens, TokenType.KEYWORD).find(t => t.value.toUpperCase() === "SET");
        expect(setKw.line).toBe(1);
    });

    test("token char positions are 0-based within the line", () => {
        // "#SET x = 1" — SET starts at char 1 (after the #)
        const tokens = tokenize("#SET x = 1\n");
        const setKw = tokensOfType(tokens, TokenType.KEYWORD).find(t => t.value.toUpperCase() === "SET");
        expect(setKw.char).toBe(1);
    });

    test("identifier position is correct when preceded by whitespace", () => {
        // "#SET zMessage = \"v\"" — zMessage starts at char 5
        const tokens = tokenize("#SET zMessage = \"v\"\n");
        const id = tokensOfType(tokens, TokenType.IDENTIFIER).find(t => t.value === "zMessage");
        expect(id.char).toBe(5);
    });

    test("EOF token is always the last token", () => {
        const tokens = tokenize("#VAR x : SHORT\n");
        const eof = tokens[tokens.length - 1];
        expect(eof.type).toBe(TokenType.EOF);
        // splitLines("#VAR x : SHORT\n") → [{content, newline:"\n"}, {content:"", newline:""}]
        // so lines.length = 2 and EOF.line = 2.
        expect(eof.line).toBe(2);
    });
});

// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------

describe("lexer — edge cases", () => {
    test("empty document: last token is EOF and reconstruction is empty string", () => {
        const tokens = tokenize("");
        expect(tokens[tokens.length - 1].type).toBe(TokenType.EOF);
        expect(reconstructFromTokens(tokens)).toBe("");
    });

    test("document with only whitespace lines", () => {
        const tokens = tokenize("   \n   \n");
        expect(reconstructFromTokens(tokens)).toBe("   \n   \n");
    });

    test("unclosed string literal does not throw", () => {
        expect(() => tokenize("#SET x = \"unclosed\n")).not.toThrow();
    });

    test("HASH_PREFIX is not emitted for output lines in HASH-ON mode", () => {
        // Line 0 has no #, so it is output — no HASH_PREFIX on that line.
        const tokens = tokenize("plain output\n#VAR x : SHORT\n");
        const line0 = tokens.filter(tok => tok.line === 0);
        expect(line0.some(tok => tok.type === TokenType.HASH_PREFIX)).toBe(false);
        expect(line0.some(tok => tok.type === TokenType.OUTPUT_TEXT)).toBe(true);
    });
});
