/* Standalone logic test for the document/editor/markdown-io layer, with no window or GPU
   context required. Exercises editor.c the same way keystrokes would, and checks the
   resulting Document state and markdown round-trip directly. */
#include "editor.h"
#include "markdown_io.h"
#include "inline_parse.h"
#include "undo.h"
#include "config.h"
#include "lang.h"
#include "theme.h"
#include "keymap.h"
#include "block_type_menu.h"
#include "image_cache.h"
#include <SDL.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <math.h>

static int g_failures = 0;

#define CHECK(cond, msg) do { \
    if (!(cond)) { g_failures++; printf("FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); } \
    else { printf("ok:   %s\n", msg); } \
} while (0)

static int text_eq(Block *b, const char *s) {
    int n = (int)strlen(s);
    return b->text.len == n && memcmp(b->text.data, s, (size_t)n) == 0;
}

static void type_str(EditorState *ed, const char *s) {
    for (const char *p = s; *p; p++) editor_insert_utf8(ed, p, 1);
}

static void test_heading_autoformat(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "# ");
    CHECK(ed.doc.blocks[0].type == BLOCK_H1, "typing '# ' converts block to H1");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "H1 marker text is stripped");
    type_str(&ed, "Titel");
    CHECK(text_eq(&ed.doc.blocks[0], "Titel"), "H1 block holds typed text");
    editor_free(&ed);
}

static void test_bullet_list_continuation(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "- Item1");
    CHECK(ed.doc.blocks[0].type == BLOCK_BULLET, "typing '- ' converts block to BULLET");
    CHECK(text_eq(&ed.doc.blocks[0], "Item1"), "bullet block holds typed text");

    editor_enter(&ed);
    CHECK(ed.doc.count == 2, "Enter in a bullet item adds a new block");
    CHECK(ed.doc.blocks[1].type == BLOCK_BULLET, "Enter continues the bullet list");
    CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 0, "cursor moves to the new empty bullet item");

    /* Enter again on the still-empty bullet item exits the list. */
    editor_enter(&ed);
    CHECK(ed.doc.count == 2, "Enter on an empty list item does not create a new block");
    CHECK(ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "Enter on an empty list item exits back to a paragraph");
    editor_free(&ed);
}

static void test_numbered_list_renumbers_on_save(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "1. First");
    editor_enter(&ed);
    type_str(&ed, "Second");
    editor_enter(&ed);
    type_str(&ed, "Third");
    CHECK(ed.doc.count == 3 && ed.doc.blocks[2].type == BLOCK_NUMBERED, "three numbered items exist");

    document_save_file(&ed.doc, "test_numbered.md");
    FILE *f = fopen("test_numbered.md", "rb");
    char buf[256] = {0};
    size_t n = fread(buf, 1, sizeof buf - 1, f);
    fclose(f);
    (void)n;
    CHECK(strstr(buf, "1. First") && strstr(buf, "2. Second") && strstr(buf, "3. Third"),
          "saved file renumbers the list sequentially");
    editor_free(&ed);
}

static void test_backspace_demotes_then_merges(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "## Heading");
    editor_move_home(&ed, false);
    CHECK(ed.doc.blocks[0].type == BLOCK_H2, "starts as H2");

    editor_backspace(&ed);
    CHECK(ed.doc.blocks[0].type == BLOCK_PARAGRAPH, "backspace at block start demotes H2 to paragraph first");
    CHECK(text_eq(&ed.doc.blocks[0], "Heading"), "demotion keeps the text intact");

    editor_move_end(&ed, false);
    editor_enter(&ed);
    type_str(&ed, "Second");
    CHECK(ed.doc.count == 2, "now there are two paragraphs");

    ed.cursorBlock = 1;
    ed.cursorOffset = 0;
    ed.selAnchorBlock = 1;
    ed.selAnchorOffset = 0;
    editor_backspace(&ed);
    CHECK(ed.doc.count == 1, "backspace at start of a paragraph merges into the previous block");
    CHECK(text_eq(&ed.doc.blocks[0], "HeadingSecond"), "merge concatenates the text");
    editor_free(&ed);
}

static void test_code_fence(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "```");
    editor_enter(&ed);
    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "typing ``` then Enter opens a code block");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "code block starts empty");

    type_str(&ed, "int main(void) {");
    editor_enter(&ed);
    type_str(&ed, "    return 0;");
    editor_enter(&ed);
    type_str(&ed, "}");
    editor_enter(&ed);
    type_str(&ed, "```");
    editor_enter(&ed);

    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "block stays CODE while inside the fence");
    CHECK(text_eq(&ed.doc.blocks[0], "int main(void) {\n    return 0;\n}"), "embedded newlines preserved, closing fence stripped");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "closing ``` exits to a new paragraph");
    editor_free(&ed);
}

static void test_code_fence_double_enter_exit(void) {
    /* A literal closing "```" isn't the only way out -- on some keyboard layouts (e.g. Swedish)
       backtick is a dead key, easy to get stuck on. Enter on an already-blank trailing line
       must also exit, mirroring the list-exit precedent (empty item + Enter -> demote). */
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "```");
    editor_enter(&ed);
    type_str(&ed, "line one");
    editor_enter(&ed); /* embeds '\n', stays in the code block */
    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "still in the code block after one Enter");
    editor_enter(&ed); /* second Enter, on the now-blank trailing line -- should exit */
    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "the code block itself is untouched");
    CHECK(text_eq(&ed.doc.blocks[0], "line one"), "the blank trailing line is stripped, not left dangling in the code");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "double Enter exits to a new paragraph, same as a real closing fence");
    CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 0, "cursor lands in the new paragraph");
    editor_free(&ed);

    /* A single Enter on a freshly-created, still-empty code block must NOT immediately exit --
       only a *second* Enter on an already-blank line (real double-Enter) should. */
    EditorState ed2;
    editor_init(&ed2);
    type_str(&ed2, "```");
    editor_enter(&ed2);
    CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "opened a fresh, empty code block");
    editor_enter(&ed2);
    CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "a single Enter on an empty code block does not exit it");
    CHECK(ed2.doc.count == 1, "no new block was created");
    editor_free(&ed2);
}

static void test_utf8_backspace(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "kaf\xc3\xa9"); /* "café" */
    CHECK(ed.doc.blocks[0].text.len == 5, "café is 5 bytes (é is 2 bytes in UTF-8)");
    editor_backspace(&ed);
    CHECK(text_eq(&ed.doc.blocks[0], "kaf"), "backspace removes the whole 'é' codepoint, not one byte");
    editor_free(&ed);
}

static void test_inline_parse(void) {
    const char *s = "a **b** c *d* e `f` g [h](i)";
    InlineRunList runs;
    inline_parse(s, (int)strlen(s), &runs);

    int haveBold = 0, haveItalic = 0, haveCode = 0, haveLink = 0;
    for (int i = 0; i < runs.count; i++) {
        InlineRun *r = &runs.runs[i];
        if (r->kind == RUN_BOLD && r->contentEnd - r->contentStart == 1 && s[r->contentStart] == 'b') haveBold = 1;
        if (r->kind == RUN_ITALIC && s[r->contentStart] == 'd') haveItalic = 1;
        if (r->kind == RUN_CODE && s[r->contentStart] == 'f') haveCode = 1;
        if (r->kind == RUN_LINK && s[r->contentStart] == 'h') haveLink = 1;
    }
    CHECK(haveBold, "inline_parse finds **bold**");
    CHECK(haveItalic, "inline_parse finds *italic*");
    CHECK(haveCode, "inline_parse finds `code`");
    CHECK(haveLink, "inline_parse finds [link](url)");
    inline_runs_free(&runs);
}

static void test_save_load_roundtrip(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "# Titel");
    editor_enter(&ed);
    type_str(&ed, "En **fet** rad med \xc3\xa5\xc3\xa4\xc3\xb6.");
    editor_enter(&ed);
    type_str(&ed, "> Citat");
    editor_enter(&ed);  /* continues the blockquote with a new, empty quote block */
    editor_enter(&ed);  /* Enter on an empty quote item exits back to a paragraph */
    type_str(&ed, "- Punkt");

    CHECK(document_save_file(&ed.doc, "test_roundtrip.md"), "save succeeds");

    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_roundtrip.md"), "load succeeds");
    CHECK(ed2.doc.count == 4, "round-tripped document has 4 blocks");
    CHECK(ed2.doc.blocks[0].type == BLOCK_H1 && text_eq(&ed2.doc.blocks[0], "Titel"), "H1 round-trips");
    CHECK(ed2.doc.blocks[1].type == BLOCK_PARAGRAPH && text_eq(&ed2.doc.blocks[1], "En **fet** rad med \xc3\xa5\xc3\xa4\xc3\xb6."), "paragraph with inline markup and åäö round-trips byte-for-byte");
    CHECK(ed2.doc.blocks[2].type == BLOCK_QUOTE && text_eq(&ed2.doc.blocks[2], "Citat"), "blockquote round-trips");
    CHECK(ed2.doc.blocks[3].type == BLOCK_BULLET && text_eq(&ed2.doc.blocks[3], "Punkt"), "bullet round-trips");

    editor_free(&ed);
    editor_free(&ed2);
}

static void test_document_clone_deep_copy(void) {
    Document doc;
    document_init(&doc);
    sb_insert(&doc.blocks[0].text, 0, "hello", 5);
    document_insert_block(&doc, 1, BLOCK_H1, "world", 5);

    Document clone;
    document_clone(&clone, &doc);
    CHECK(clone.count == 2, "clone has the same block count");
    CHECK(text_eq(&clone.blocks[0], "hello") && text_eq(&clone.blocks[1], "world"), "clone has matching text");
    CHECK(clone.blocks[1].type == BLOCK_H1, "clone has matching block types");

    sb_insert(&doc.blocks[0].text, 5, "XXX", 3); /* mutate the original after cloning */
    CHECK(text_eq(&clone.blocks[0], "hello"), "clone is a deep copy, unaffected by mutating the original afterward");

    document_free(&doc);
    document_free(&clone);
}

static void test_document_compute_stats(void) {
    EditorState ed;
    editor_init(&ed);
    DocStats stats;
    document_compute_stats(&ed.doc, &stats);
    CHECK(stats.byteSize == 1, "a fresh empty document serializes to a single newline byte");
    CHECK(stats.lineCount == 1, "a fresh empty document counts as 1 line");

    type_str(&ed, "ab");
    editor_enter(&ed);
    type_str(&ed, "cd");
    document_compute_stats(&ed.doc, &stats);
    CHECK(stats.byteSize == 7, "two-paragraph doc serializes to \"ab\\n\\ncd\\n\" (7 bytes)");
    CHECK(stats.lineCount == 3, "the blank separator line between blocks counts too (matches wc -l)");
    editor_free(&ed);
}

static void test_undo_typing_coalesces(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "abc");
    CHECK(editor_undo(&ed), "undo has history after typing");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "one undo removes all of a continuously-typed run at once");
    CHECK(!editor_undo(&ed), "a second undo finds nothing left");
    editor_free(&ed);
}

static void test_undo_kind_switch_starts_new_group(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "abc");
    editor_backspace(&ed);
    editor_backspace(&ed);
    CHECK(text_eq(&ed.doc.blocks[0], "a"), "typing 'abc' then two backspaces leaves 'a'");
    CHECK(editor_undo(&ed), "undo #1");
    CHECK(text_eq(&ed.doc.blocks[0], "abc"), "undo #1 undoes both backspaces together, but not the typing before them");
    CHECK(editor_undo(&ed), "undo #2");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "undo #2 undoes the typed 'abc' as its own group");
    editor_free(&ed);
}

static void test_undo_enter_is_own_step(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "ab");
    editor_enter(&ed);
    type_str(&ed, "cd");
    CHECK(ed.doc.count == 2 && text_eq(&ed.doc.blocks[1], "cd"), "'ab' / Enter / 'cd' produced two blocks");

    CHECK(editor_undo(&ed), "undo #1");
    CHECK(ed.doc.count == 2 && text_eq(&ed.doc.blocks[1], ""), "undo #1 removes only the typed 'cd', the Enter split survives");
    CHECK(editor_undo(&ed), "undo #2");
    CHECK(ed.doc.count == 1 && text_eq(&ed.doc.blocks[0], "ab"), "undo #2 undoes the Enter split on its own, merging back to one block");
    CHECK(editor_undo(&ed), "undo #3");
    CHECK(ed.doc.count == 1 && text_eq(&ed.doc.blocks[0], ""), "undo #3 removes the typed 'ab'");
    CHECK(!editor_undo(&ed), "no more history");
    editor_free(&ed);
}

static void test_undo_redo_roundtrip(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "abc");
    CHECK(editor_undo(&ed), "undo the typed 'abc'");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "back to empty");
    CHECK(editor_redo(&ed), "redo restores it");
    CHECK(text_eq(&ed.doc.blocks[0], "abc"), "redo brings back 'abc'");
    CHECK(!editor_redo(&ed), "nothing left to redo");

    type_str(&ed, "d");
    CHECK(text_eq(&ed.doc.blocks[0], "abcd"), "typing after redo appends at the right cursor position");
    CHECK(!editor_redo(&ed), "a genuinely new edit clears the redo stack");
    editor_free(&ed);
}

static void test_snapshot_stack_cap_eviction(void) {
    SnapshotStack s;
    snapshot_stack_init(&s, 3);

    Document doc;
    document_init(&doc);
    for (int i = 0; i < 5; i++) {
        sb_clear(&doc.blocks[0].text);
        char buf[8];
        int n = snprintf(buf, sizeof buf, "%d", i);
        sb_append(&doc.blocks[0].text, buf, n);
        snapshot_stack_push(&s, &doc, 0, i);
    }
    CHECK(s.count == 3, "a cap-3 stack holds only 3 entries after 5 pushes");

    Snapshot top;
    CHECK(snapshot_stack_pop(&s, &top) && top.cursorOffset == 4, "most recent push is on top");
    document_free(&top.doc);
    CHECK(snapshot_stack_pop(&s, &top) && top.cursorOffset == 3, "next most recent is below it");
    document_free(&top.doc);
    CHECK(snapshot_stack_pop(&s, &top) && top.cursorOffset == 2, "oldest surviving entry is #2 (0 and 1 were evicted)");
    document_free(&top.doc);
    CHECK(!snapshot_stack_pop(&s, &top), "stack is now empty");

    document_free(&doc);
    snapshot_stack_free(&s);
}

static void test_selection_range_normalization(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "ab");
    editor_enter(&ed);
    type_str(&ed, "cd");

    ed.cursorBlock = 0; ed.cursorOffset = 1;
    ed.selAnchorBlock = 1; ed.selAnchorOffset = 1;
    CHECK(editor_has_selection(&ed), "anchor != cursor counts as a selection");

    int sb, so, eb, eo;
    editor_selection_range(&ed, &sb, &so, &eb, &eo);
    CHECK(sb == 0 && so == 1 && eb == 1 && eo == 1, "a backward selection (anchor after cursor) normalizes to forward document order");
    editor_free(&ed);
}

static void test_selection_to_text_across_blocks(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "hello");
    editor_enter(&ed);
    type_str(&ed, "world");
    editor_enter(&ed);
    type_str(&ed, "again");

    int len;
    CHECK(editor_selection_to_text(&ed, &len) == NULL, "no active selection yields NULL");

    ed.selAnchorBlock = 0; ed.selAnchorOffset = 3;
    ed.cursorBlock = 2; ed.cursorOffset = 2;
    char *text = editor_selection_to_text(&ed, &len);
    CHECK(text != NULL, "selection_to_text returns text once a selection exists");
    CHECK(text && strcmp(text, "lo\nworld\nag") == 0, "cross-block selection joins blocks with '\\n'");
    free(text);
    editor_free(&ed);
}

static void test_selection_replace_on_type(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "hello world");
    ed.selAnchorBlock = 0; ed.selAnchorOffset = 0;
    ed.cursorBlock = 0; ed.cursorOffset = 5;
    editor_insert_utf8(&ed, "HI", 2);
    CHECK(text_eq(&ed.doc.blocks[0], "HI world"), "typing over a selection replaces it");
    CHECK(!editor_has_selection(&ed), "selection collapses after the replace");
    CHECK(ed.cursorOffset == 2, "cursor lands right after the inserted text");
    editor_free(&ed);
}

static void test_selection_replace_on_backspace_across_blocks(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "## Heading");
    editor_enter(&ed);
    type_str(&ed, "world");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[0].type == BLOCK_H2, "setup: H2 'Heading' then paragraph 'world'");

    ed.selAnchorBlock = 0; ed.selAnchorOffset = 3;
    ed.cursorBlock = 1; ed.cursorOffset = 2;
    editor_backspace(&ed);

    CHECK(ed.doc.count == 1, "backspace over a cross-block selection merges into a single block");
    CHECK(ed.doc.blocks[0].type == BLOCK_H2, "the merged block keeps the start block's type");
    CHECK(text_eq(&ed.doc.blocks[0], "Hearld"), "merged text is start-before-selection + end-after-selection");
    CHECK(!editor_has_selection(&ed), "selection is cleared after the merge");
    editor_free(&ed);
}

static void test_config_parse_line(void) {
    Config cfg = {0};

    CHECK(!config_parse_line("# a comment", 11, &cfg), "a comment line returns false");
    CHECK(!config_parse_line("", 0, &cfg), "a blank line returns false");
    CHECK(!config_parse_line("   ", 3, &cfg), "a whitespace-only line returns false");

    const char *l1 = "text_color = #1a2b3c";
    CHECK(config_parse_line(l1, (int)strlen(l1), &cfg), "a valid hex color line returns true");
    CHECK(cfg.textColor.r == 0x1a && cfg.textColor.g == 0x2b && cfg.textColor.b == 0x3c && cfg.textColor.a == 255,
          "text_color parses each hex byte correctly");

    const char *l2 = "bg_color=#ffffff";
    CHECK(config_parse_line(l2, (int)strlen(l2), &cfg), "no spaces around '=' still parses");
    CHECK(cfg.bgColor.r == 255 && cfg.bgColor.g == 255 && cfg.bgColor.b == 255, "bg_color parsed as white");

    Clay_Color before = cfg.textColor;
    const char *l3 = "text_color = not-a-color";
    CHECK(!config_parse_line(l3, (int)strlen(l3), &cfg), "an invalid hex value returns false");
    CHECK(cfg.textColor.r == before.r && cfg.textColor.g == before.g && cfg.textColor.b == before.b,
          "an invalid color line leaves the previous value untouched");

    const char *l4 = "unknown_key = foo";
    CHECK(!config_parse_line(l4, (int)strlen(l4), &cfg), "an unrecognized key returns false and does not crash");

    const char *l5 = "font_sans_bold = /some/path.ttf";
    CHECK(config_parse_line(l5, (int)strlen(l5), &cfg), "a font-path key returns true");
    CHECK(cfg.fontPaths[FONT_SANS_BOLD] != NULL && strcmp(cfg.fontPaths[FONT_SANS_BOLD], "/some/path.ttf") == 0,
          "the font path is copied into the matching slot");

    const char *l6 = "text_color = #a1b2c3\r";
    CHECK(config_parse_line(l6, (int)strlen(l6), &cfg), "a trailing \\r is trimmed and the line still parses");
    CHECK(cfg.textColor.r == 0xa1, "value from the \\r-trimmed line is correct");

    const char *l7 = "zoom = 1.5";
    CHECK(config_parse_line(l7, (int)strlen(l7), &cfg), "a valid zoom value returns true");
    CHECK(cfg.zoom > 1.499f && cfg.zoom < 1.501f, "zoom parses as a float");

    const char *l8 = "zoom = 99";
    CHECK(config_parse_line(l8, (int)strlen(l8), &cfg), "an out-of-range zoom value still returns true");
    CHECK(cfg.zoom <= ZOOM_MAX, "but is clamped to ZOOM_MAX rather than taken literally");

    const char *l9 = "zoom = not-a-number";
    float zoomBefore = cfg.zoom;
    CHECK(!config_parse_line(l9, (int)strlen(l9), &cfg), "a non-numeric zoom value returns false");
    CHECK(cfg.zoom == zoomBefore, "an invalid zoom line leaves the previous value untouched");

    const char *l10 = "key.open = ctrl+shift+o";
    CHECK(config_parse_line(l10, (int)strlen(l10), &cfg), "a valid key.* line returns true");
    CHECK(cfg.keymap[ACTION_OPEN].key == SDLK_o && cfg.keymap[ACTION_OPEN].ctrl && cfg.keymap[ACTION_OPEN].shift && !cfg.keymap[ACTION_OPEN].alt,
          "key.open rebinds ACTION_OPEN's KeyBinding");

    const char *l11 = "key.open = not+a+real+key";
    CHECK(!config_parse_line(l11, (int)strlen(l11), &cfg), "a key.* line with an unrecognized key name returns false");
    CHECK(cfg.keymap[ACTION_OPEN].key == SDLK_o && cfg.keymap[ACTION_OPEN].shift, "an invalid key.* line leaves the previous binding untouched");

    const char *l12 = "key.unknown_action = ctrl+q";
    CHECK(!config_parse_line(l12, (int)strlen(l12), &cfg), "a key.* line for an unknown action name returns false");

    config_free(&cfg);
}

static void test_keymap(void) {
    KeyBinding defaults[ACTION_COUNT];
    keymap_set_defaults(defaults);
    CHECK(defaults[ACTION_SAVE].key == SDLK_s && defaults[ACTION_SAVE].ctrl, "default ACTION_SAVE is Ctrl+S");
    CHECK(defaults[ACTION_OPEN].key == SDLK_o && defaults[ACTION_OPEN].ctrl, "default ACTION_OPEN is Ctrl+O");
    CHECK(defaults[ACTION_REDO].key == SDLK_z && defaults[ACTION_REDO].ctrl && defaults[ACTION_REDO].shift,
          "default ACTION_REDO is Ctrl+Shift+Z, distinct from ACTION_UNDO's Ctrl+Z");
    CHECK(defaults[ACTION_ZOOM_IN].key == SDLK_PLUS && defaults[ACTION_ZOOM_IN].ctrl,
          "default ACTION_ZOOM_IN is Ctrl++, not Ctrl+= -- \"+\" sits unshifted on more layouts (e.g. Swedish)");

    KeyBinding b;
    CHECK(keymap_parse_spec("ctrl+s", 6, &b) && b.key == SDLK_s && b.ctrl && !b.shift && !b.alt, "\"ctrl+s\" parses");
    CHECK(keymap_parse_spec("Ctrl+Shift+Z", 12, &b) && b.key == SDLK_z && b.ctrl && b.shift, "modifier names are case-insensitive");
    CHECK(keymap_parse_spec("ctrl+/", 6, &b) && b.key == SDLK_SLASH, "a single punctuation character (\"/\") is recognized");
    CHECK(keymap_parse_spec("ctrl+slash", 10, &b) && b.key == SDLK_SLASH, "its named form (\"slash\") parses to the same key");
    CHECK(keymap_parse_spec("ctrl++", 6, &b) && b.key == SDLK_PLUS && b.ctrl,
          "\"ctrl++\" parses the trailing '+' as the literal plus key, not an empty token after the '+' separator");
    CHECK(keymap_parse_spec("ctrl+plus", 9, &b) && b.key == SDLK_PLUS && b.ctrl, "its named form (\"plus\") parses to the same key");
    CHECK(keymap_parse_spec("f1", 2, &b) && b.key == SDLK_F1 && !b.ctrl, "a bare key with no modifiers parses");
    CHECK(keymap_parse_spec("alt+shift+9", 11, &b) && b.key == SDLK_9 && b.alt && b.shift, "digits and \"alt\" parse");
    CHECK(keymap_parse_spec(" ctrl + s ", 10, &b) && b.key == SDLK_s && b.ctrl, "stray whitespace around tokens is trimmed");

    CHECK(!keymap_parse_spec("", 0, &b), "an empty spec is rejected");
    CHECK(!keymap_parse_spec("ctrl+", 5, &b), "a spec with no key token is rejected");
    CHECK(!keymap_parse_spec("ctrl+nonsense", 13, &b), "an unrecognized key name is rejected");
    CHECK(!keymap_parse_spec("ctrl", 4, &b), "a spec that's only a modifier is rejected");

    KeyBinding km[ACTION_COUNT];
    keymap_set_defaults(km);
    CHECK(keymap_parse_config_line("key.zoom_in", 11, "alt+equal", 9, km), "keymap_parse_config_line matches \"key.zoom_in\"");
    CHECK(km[ACTION_ZOOM_IN].key == SDLK_EQUALS && km[ACTION_ZOOM_IN].alt && !km[ACTION_ZOOM_IN].ctrl, "and rebinds only that action");
    CHECK(km[ACTION_ZOOM_OUT].key == SDLK_MINUS && km[ACTION_ZOOM_OUT].ctrl, "leaving every other action's default untouched");
    CHECK(!keymap_parse_config_line("not_a_key_line", 14, "ctrl+s", 6, km), "a non-\"key.*\" name doesn't match");
}

static void test_lang_parse_line(void) {
    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "before loading anything, lang_get returns the built-in English default");

    CHECK(!lang_parse_line("# a comment", 11), "a comment line returns false");
    CHECK(!lang_parse_line("", 0), "a blank line returns false");
    CHECK(!lang_parse_line("unknown.key = foo", 18), "an unrecognized key returns false and does not crash");

    const char *l1 = "help.close = St\xC3\xA4ng";
    CHECK(lang_parse_line(l1, (int)strlen(l1)), "a recognized key returns true");
    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "St\xC3\xA4ng") == 0, "lang_get now returns the overridden value");
    CHECK(strcmp(lang_get(STR_HELP_TITLE), "Keyboard shortcuts") == 0,
          "a string not present in the override still falls back to its English default");

    const char *l2 = "help.close=Fermer";
    CHECK(lang_parse_line(l2, (int)strlen(l2)), "no spaces around '=' still parses");
    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Fermer") == 0, "re-setting the same key overwrites the previous override");

    lang_free();
    CHECK(strcmp(lang_get(STR_HELP_CLOSE), "Close") == 0, "lang_free() clears overrides back to the English defaults");
}

static void test_theme_zoom(void) {
    theme_init_defaults();
    CHECK(g_theme.zoom > 0.999f && g_theme.zoom < 1.001f, "zoom starts at 100%");
    CHECK(theme_scaled_font_size(16) == 16, "at 100% zoom, scaling is a no-op");

    theme_zoom_in();
    CHECK(g_theme.zoom > 1.099f && g_theme.zoom < 1.101f, "zoom in steps up by ZOOM_STEP");
    CHECK(theme_scaled_font_size(16) == (int)roundf(16 * 1.1f), "scaled size reflects the new zoom level");

    theme_zoom_out();
    theme_zoom_out();
    CHECK(g_theme.zoom > 0.899f && g_theme.zoom < 0.901f, "zoom out steps back down by ZOOM_STEP each time");

    theme_zoom_reset();
    CHECK(g_theme.zoom > 0.999f && g_theme.zoom < 1.001f, "zoom_reset always returns to 100%, regardless of prior zoom level");

    for (int i = 0; i < 100; i++) theme_zoom_in();
    CHECK(g_theme.zoom <= ZOOM_MAX + 0.001f, "zoom in is clamped at ZOOM_MAX, doesn't grow unbounded");

    for (int i = 0; i < 100; i++) theme_zoom_out();
    CHECK(g_theme.zoom >= ZOOM_MIN - 0.001f, "zoom out is clamped at ZOOM_MIN, doesn't shrink unbounded");
    CHECK(theme_scaled_font_size(1) >= 6, "scaled font size never drops below the 6px floor even at minimum zoom");

    theme_zoom_reset();
}

static void test_heading_levels_4_5_6(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "#### ");
    CHECK(ed.doc.blocks[0].type == BLOCK_H4, "typing '#### ' converts block to H4");
    CHECK(text_eq(&ed.doc.blocks[0], ""), "H4 marker text is stripped");

    editor_enter(&ed);
    type_str(&ed, "##### ");
    CHECK(ed.doc.blocks[1].type == BLOCK_H5, "typing '##### ' converts block to H5");

    editor_enter(&ed);
    type_str(&ed, "###### ");
    CHECK(ed.doc.blocks[2].type == BLOCK_H6, "typing '###### ' converts block to H6");

    editor_enter(&ed);
    type_str(&ed, "####### ");
    CHECK(ed.doc.blocks[3].type == BLOCK_PARAGRAPH, "7 '#'s does not autoformat (not a valid heading level)");
    CHECK(text_eq(&ed.doc.blocks[3], "####### "), "7 '#'s stays literal paragraph text");
    editor_free(&ed);
}

static void test_heading_levels_round_trip(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "#### Four");
    editor_enter(&ed);
    type_str(&ed, "##### Five");
    editor_enter(&ed);
    type_str(&ed, "###### Six");

    CHECK(document_save_file(&ed.doc, "test_headings456.md"), "save succeeds");
    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_headings456.md"), "load succeeds");
    CHECK(ed2.doc.count == 3, "three heading blocks round-trip");
    CHECK(ed2.doc.blocks[0].type == BLOCK_H4 && text_eq(&ed2.doc.blocks[0], "Four"), "H4 round-trips");
    CHECK(ed2.doc.blocks[1].type == BLOCK_H5 && text_eq(&ed2.doc.blocks[1], "Five"), "H5 round-trips");
    CHECK(ed2.doc.blocks[2].type == BLOCK_H6 && text_eq(&ed2.doc.blocks[2], "Six"), "H6 round-trips");
    editor_free(&ed);
    editor_free(&ed2);
}

static void test_horizontal_rule(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "---");
    editor_enter(&ed);
    CHECK(ed.doc.count == 2, "Enter after '---' creates a second block");
    CHECK(ed.doc.blocks[0].type == BLOCK_HR, "typing '---' then Enter converts the block to a horizontal rule");
    CHECK(ed.doc.blocks[0].text.len == 0, "HR block holds no text");
    CHECK(ed.cursorBlock == 1 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "cursor moves into a fresh paragraph after the rule");

    CHECK(document_save_file(&ed.doc, "test_hr.md"), "save succeeds");
    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_hr.md"), "load succeeds");
    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_HR, "HR round-trips (the trailing empty paragraph doesn't survive, same as any blank line)");
    editor_free(&ed);
    editor_free(&ed2);
}

static void test_horizontal_rule_via_space(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "--- ");
    CHECK(ed.doc.blocks[0].type == BLOCK_HR, "typing '--- ' (space, not Enter) also converts the block to a horizontal rule");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH, "a fresh paragraph is inserted after it, same as the Enter-triggered path");
    CHECK(ed.cursorBlock == 1, "cursor moves into that fresh paragraph");
    editor_free(&ed);

    EditorState ed2;
    editor_init(&ed2);
    type_str(&ed2, "*** ");
    CHECK(ed2.doc.blocks[0].type == BLOCK_HR, "'*** ' also triggers a horizontal rule");
    editor_free(&ed2);

    EditorState ed3;
    editor_init(&ed3);
    type_str(&ed3, "___ ");
    CHECK(ed3.doc.blocks[0].type == BLOCK_HR, "'___ ' also triggers a horizontal rule");
    editor_free(&ed3);
}

static void test_image_markdown(void) {
    EditorState ed;
    editor_init(&ed);
    sb_clear(&ed.doc.blocks[0].text);
    sb_append(&ed.doc.blocks[0].text, "![alt text](path/to/img.png)", 28);
    CHECK(document_save_file(&ed.doc, "test_image.md"), "save succeeds");

    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_image.md"), "load succeeds");
    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_IMAGE, "a whole-line '![alt](url)' parses to BLOCK_IMAGE");
    CHECK(text_eq(&ed2.doc.blocks[0], "path/to/img.png"), "the block's text holds the url");
    CHECK(ed2.doc.blocks[0].alt.len == 8 && memcmp(ed2.doc.blocks[0].alt.data, "alt text", 8) == 0, "the block's alt holds the alt text");

    CHECK(document_save_file(&ed2.doc, "test_image2.md"), "re-save succeeds");
    EditorState ed3b;
    editor_init(&ed3b);
    CHECK(editor_load(&ed3b, "test_image2.md"), "re-load succeeds");
    CHECK(ed3b.doc.blocks[0].type == BLOCK_IMAGE && text_eq(&ed3b.doc.blocks[0], "path/to/img.png")
              && ed3b.doc.blocks[0].alt.len == 8, "the image round-trips losslessly through a second save/load");
    editor_free(&ed);
    editor_free(&ed2);
    editor_free(&ed3b);

    EditorState ed5;
    editor_init(&ed5);
    /* document_save_file always writes exactly what's in the block's text, so to test the *loader's*
       trailing-whitespace tolerance, write the raw file by hand instead. */
    FILE *f = fopen("test_image4.md", "wb");
    fputs("![alt](url.png)   \n", f);
    fclose(f);
    CHECK(editor_load(&ed5, "test_image4.md"), "load succeeds");
    CHECK(ed5.doc.blocks[0].type == BLOCK_IMAGE && text_eq(&ed5.doc.blocks[0], "url.png"),
          "trailing spaces/tabs after the closing ')' are tolerated, matching is_hr_line's tolerance");
    editor_free(&ed5);

    EditorState ed6;
    editor_init(&ed6);
    f = fopen("test_image5.md", "wb");
    fputs("![]()\n", f);
    fclose(f);
    CHECK(editor_load(&ed6, "test_image5.md"), "load succeeds");
    CHECK(ed6.doc.blocks[0].type == BLOCK_PARAGRAPH, "an empty url ('![]()') is not a valid image -- falls through to BLOCK_PARAGRAPH");
    editor_free(&ed6);

    EditorState ed7;
    editor_init(&ed7);
    f = fopen("test_image6.md", "wb");
    fputs("![alt](url.png) trailing text\n", f);
    fclose(f);
    CHECK(editor_load(&ed7, "test_image6.md"), "load succeeds");
    CHECK(ed7.doc.blocks[0].type == BLOCK_PARAGRAPH, "an image followed by other text on the same line is not a whole-line image -- stays BLOCK_PARAGRAPH");
    editor_free(&ed7);
}

static void test_image_enter_does_not_split_url(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "http://example.com/a.png");
    editor_turn_into_apply(&ed, BLOCK_IMAGE);
    CHECK(ed.doc.blocks[0].type == BLOCK_IMAGE, "turn-into converts to an image block");
    ed.cursorOffset = 10; /* mid-string, e.g. right after "http://exa" */
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    editor_enter(&ed);
    CHECK(ed.doc.blocks[0].type == BLOCK_IMAGE, "the image block itself is unchanged in type");
    CHECK(text_eq(&ed.doc.blocks[0], "http://example.com/a.png"),
          "Enter mid-url does NOT split/truncate the url -- it stays fully intact");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[1], ""),
          "a fresh empty paragraph is inserted right after it instead");
    CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 0, "the cursor moves into that fresh paragraph");
    editor_free(&ed);
}

static void test_image_resolve_path(void) {
    char buf[512];

    CHECK(image_resolve_path("/home/user/doc.md", "/etc/passwd", buf, sizeof buf) && strcmp(buf, "/etc/passwd") == 0,
          "an absolute imageRef passes through unchanged, ignoring docFilePath");
    CHECK(image_resolve_path(NULL, "/etc/passwd", buf, sizeof buf) && strcmp(buf, "/etc/passwd") == 0,
          "an absolute imageRef resolves fine even with a NULL docFilePath");

    CHECK(image_resolve_path("/home/user/notes/doc.md", "img/a.png", buf, sizeof buf)
              && strcmp(buf, "/home/user/notes/img/a.png") == 0,
          "a relative imageRef joins against the directory of docFilePath");
    CHECK(image_resolve_path("doc.md", "a.png", buf, sizeof buf) && strcmp(buf, "a.png") == 0,
          "docFilePath with no directory component (bare filename, CWD) resolves the ref as-is");

    CHECK(!image_resolve_path(NULL, "img/a.png", buf, sizeof buf),
          "a relative imageRef is unresolvable when docFilePath is NULL (unsaved buffer)");
    CHECK(!image_resolve_path("/home/user/doc.md", "", buf, sizeof buf), "an empty imageRef is unresolvable");
    CHECK(!image_resolve_path("/home/user/doc.md", NULL, buf, sizeof buf), "a NULL imageRef is unresolvable");
}

static void write_file(const char *path, const char *content) {
    FILE *f = fopen(path, "wb");
    fputs(content, f);
    fclose(f);
}

static void test_table_parse_basic(void) {
    write_file("test_table1.md", "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table1.md"), "load succeeds");
    CHECK(ed.doc.count == 6, "3 header cells + 3 body cells, no delimiter block");
    CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[0], "a")
              && ed.doc.blocks[0].tableCol == 0 && ed.doc.blocks[0].tableCols == 3,
          "first header cell parses correctly");
    CHECK(ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[1], "b") && ed.doc.blocks[1].tableCol == 1,
          "second header cell parses correctly");
    CHECK(ed.doc.blocks[2].type == BLOCK_TABLE_HEADER_CELL && text_eq(&ed.doc.blocks[2], "c") && ed.doc.blocks[2].tableCol == 2,
          "third header cell parses correctly");
    CHECK(ed.doc.blocks[3].type == BLOCK_TABLE_CELL && text_eq(&ed.doc.blocks[3], "1") && ed.doc.blocks[3].tableCol == 0,
          "first body cell parses correctly");
    CHECK(ed.doc.blocks[5].type == BLOCK_TABLE_CELL && text_eq(&ed.doc.blocks[5], "3") && ed.doc.blocks[5].tableCol == 2,
          "third body cell parses correctly");
    editor_free(&ed);
}

static void test_table_alignment_parsing(void) {
    write_file("test_table2.md", "| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table2.md"), "load succeeds");
    CHECK(ed.doc.blocks[0].tableAlign == 'l', "':---' parses as left align");
    CHECK(ed.doc.blocks[1].tableAlign == 'c', "':---:' parses as center align");
    CHECK(ed.doc.blocks[2].tableAlign == 'r', "'---:' parses as right align");
    editor_free(&ed);
}

static void test_table_delimiter_disambiguation(void) {
    write_file("test_table3.md", "| not a table | just text\nmore text\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table3.md"), "load succeeds");
    CHECK(ed.doc.blocks[0].type == BLOCK_PARAGRAPH,
          "a '|'-containing line NOT followed by a valid delimiter row falls through to BLOCK_PARAGRAPH");
    editor_free(&ed);
}

static void test_table_ragged_rows(void) {
    write_file("test_table4.md", "| a | b | c |\n|---|---|---|\n| short |\n| too | many | cells | here |\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table4.md"), "load succeeds");
    CHECK(ed.doc.count == 9, "3 header + 3 (padded) + 3 (truncated) = 9 cells");
    CHECK(text_eq(&ed.doc.blocks[3], "short") && text_eq(&ed.doc.blocks[4], "") && text_eq(&ed.doc.blocks[5], ""),
          "a short row is padded with empty cells");
    CHECK(text_eq(&ed.doc.blocks[6], "too") && text_eq(&ed.doc.blocks[7], "many") && text_eq(&ed.doc.blocks[8], "cells"),
          "a long row is truncated to the header's column count");
    editor_free(&ed);
}

static void test_table_bullet_priority(void) {
    write_file("test_table5.md", "- a | b\nmore | stuff\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table5.md"), "load succeeds");
    CHECK(ed.doc.blocks[0].type == BLOCK_BULLET, "a bullet line containing a literal '|' is still classified as a bullet, not a table candidate");
    editor_free(&ed);
}

static void test_table_serialize_roundtrip(void) {
    write_file("test_table6.md", "before\n\n| a | bb | ccc |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |\n\nafter\n");
    EditorState ed;
    editor_init(&ed);
    CHECK(editor_load(&ed, "test_table6.md"), "load succeeds");
    CHECK(ed.doc.count == 2 + 3 * 3, "before + 9 cells + after");
    CHECK(document_save_file(&ed.doc, "test_table6b.md"), "save succeeds");

    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_table6b.md"), "re-load succeeds");
    CHECK(ed2.doc.count == ed.doc.count, "block count round-trips");
    for (int i = 0; i < ed.doc.count; i++) {
        CHECK(ed2.doc.blocks[i].type == ed.doc.blocks[i].type
                  && ed2.doc.blocks[i].text.len == ed.doc.blocks[i].text.len
                  && memcmp(ed2.doc.blocks[i].text.data, ed.doc.blocks[i].text.data, (size_t)ed.doc.blocks[i].text.len) == 0
                  && ed2.doc.blocks[i].tableCol == ed.doc.blocks[i].tableCol
                  && ed2.doc.blocks[i].tableCols == ed.doc.blocks[i].tableCols,
              "each block round-trips through save/reload");
    }
    CHECK(ed2.doc.blocks[2].tableAlign == 'c', "center alignment round-trips (':---:' collapses correctly)");
    CHECK(ed2.doc.blocks[3].tableAlign == 'r', "right alignment round-trips");
    editor_free(&ed);
    editor_free(&ed2);
}

static void test_table_insert(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 2, 3);
    CHECK(ed.doc.count == 6, "editor_insert_table(2, 3) creates 6 cell blocks, replacing the initial empty paragraph");
    CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL
              && ed.doc.blocks[2].type == BLOCK_TABLE_HEADER_CELL,
          "row 0 is all header cells");
    CHECK(ed.doc.blocks[3].type == BLOCK_TABLE_CELL && ed.doc.blocks[4].type == BLOCK_TABLE_CELL && ed.doc.blocks[5].type == BLOCK_TABLE_CELL,
          "row 1 is all body cells");
    for (int i = 0; i < 6; i++) CHECK(ed.doc.blocks[i].tableCols == 3, "every cell knows the table has 3 columns");
    CHECK(ed.cursorBlock == 0 && ed.cursorOffset == 0, "cursor lands in the first header cell");
    editor_free(&ed);

    EditorState ed2;
    editor_init(&ed2);
    type_str(&ed2, "@table");
    editor_insert_table(&ed2, 1, 2);
    CHECK(ed2.doc.count == 2, "like quick-insert, the '@word' trigger block's text is always discarded, not preserved");
    CHECK(ed2.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL, "the table replaces it in place");
    editor_free(&ed2);
}

static void test_table_tab_navigation(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 2, 2); /* h0 h1 / b0 b1 */
    CHECK(ed.cursorBlock == 0, "starts in cell 0");
    editor_table_move_cell(&ed, 1);
    CHECK(ed.cursorBlock == 1, "Tab moves to cell 1 (same row)");
    editor_table_move_cell(&ed, 1);
    CHECK(ed.cursorBlock == 2, "Tab from the last cell of row 0 wraps to cell 2 (row 1, col 0) -- free, since cells are row-major in the block array");
    editor_table_move_cell(&ed, 1);
    CHECK(ed.cursorBlock == 3, "Tab moves to cell 3");
    editor_table_move_cell(&ed, 1);
    CHECK(ed.cursorBlock == 4 && ed.doc.blocks[4].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[4], ""),
          "Tab from the table's very last cell exits it, inserting a fresh empty paragraph since none followed");
    CHECK(ed.doc.count == 5, "the document grew by exactly one block");

    editor_table_move_cell(&ed, -1);
    CHECK(ed.cursorBlock == 4, "editor_table_move_cell is a full no-op once the cursor has left the table (not a table cell)");
    ed.cursorBlock = 0;
    editor_table_move_cell(&ed, -1);
    CHECK(ed.cursorBlock == 0, "Shift+Tab from the table's very first cell is a no-op (nothing before it)");
    editor_free(&ed);
}

static void test_table_enter_moves_like_tab(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 1, 2);
    type_str(&ed, "hello");
    editor_enter(&ed);
    CHECK(ed.cursorBlock == 1, "Enter inside a cell moves to the next cell, like Tab");
    CHECK(text_eq(&ed.doc.blocks[0], "hello"), "no newline was inserted into the cell's text");
    editor_free(&ed);
}

static void test_table_backspace_guards(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 2, 2);
    ed.cursorBlock = 3; /* last body cell, offset 0 */
    ed.cursorOffset = 0;
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    editor_backspace(&ed);
    CHECK(ed.doc.count == 4 && ed.doc.blocks[3].type == BLOCK_TABLE_CELL,
          "Backspace at offset 0 of a non-first cell is a no-op -- the table is untouched");

    ed.cursorBlock = 0; /* the table's very first cell */
    ed.cursorOffset = 0;
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    editor_backspace(&ed);
    CHECK(ed.doc.count == 1 && ed.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[0], ""),
          "Backspace at offset 0 of the table's first cell removes the whole table, leaving one empty paragraph");
    editor_free(&ed);
}

static void test_table_delete_forward_guards(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 1, 2); /* h0 h1 */
    ed.cursorBlock = 0;
    ed.cursorOffset = ed.doc.blocks[0].text.len; /* end of cell 0 (empty, so offset 0) */
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    editor_delete_forward(&ed);
    CHECK(ed.doc.count == 2 && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL,
          "Delete at end-of-text of a table cell is a no-op -- doesn't swallow the next cell");
    editor_free(&ed);

    EditorState ed2;
    editor_init(&ed2);
    type_str(&ed2, "before");
    editor_enter(&ed2); /* fresh empty paragraph after "before", so the table (which always
                            replaces its target block) lands after "before" without touching it */
    editor_insert_table(&ed2, 1, 2);
    ed2.cursorBlock = 0;
    ed2.cursorOffset = ed2.doc.blocks[0].text.len;
    ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
    editor_delete_forward(&ed2);
    CHECK(ed2.doc.count == 3 && ed2.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL,
          "Delete at end of a plain block immediately before a table is also a no-op -- doesn't swallow the table's first cell");
    editor_free(&ed2);
}

static void test_table_cross_cell_selection_delete(void) {
    EditorState ed;
    editor_init(&ed);
    editor_insert_table(&ed, 2, 2);
    type_str(&ed, "aa"); /* cell 0 */
    ed.cursorBlock = 1; ed.cursorOffset = 0; ed.selAnchorBlock = 1; ed.selAnchorOffset = 0;
    type_str(&ed, "bb"); /* cell 1 */
    ed.cursorBlock = 2; ed.cursorOffset = 0; ed.selAnchorBlock = 2; ed.selAnchorOffset = 0;
    type_str(&ed, "cc"); /* cell 2 */
    ed.cursorBlock = 3; ed.cursorOffset = 0; ed.selAnchorBlock = 3; ed.selAnchorOffset = 0;
    type_str(&ed, "dd"); /* cell 3 */

    /* Select from mid-cell-1 to mid-cell-2 (spans a row boundary) and delete. */
    ed.selAnchorBlock = 1; ed.selAnchorOffset = 1;
    ed.cursorBlock = 2; ed.cursorOffset = 1;
    editor_backspace(&ed);

    CHECK(ed.doc.count == 4, "the table's block count is unchanged -- no cell was removed");
    CHECK(ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL && ed.doc.blocks[1].type == BLOCK_TABLE_HEADER_CELL
              && ed.doc.blocks[2].type == BLOCK_TABLE_CELL && ed.doc.blocks[3].type == BLOCK_TABLE_CELL,
          "every cell keeps its own type");
    CHECK(text_eq(&ed.doc.blocks[0], "aa"), "cell 0, outside the selection, is untouched");
    CHECK(text_eq(&ed.doc.blocks[1], "b"), "cell 1's own covered range is cleared in place, not merged elsewhere");
    CHECK(text_eq(&ed.doc.blocks[2], "c"), "cell 2's own covered range is cleared in place");
    CHECK(text_eq(&ed.doc.blocks[3], "dd"), "cell 3, outside the selection, is untouched");
    CHECK(ed.cursorBlock == 1 && ed.cursorOffset == 1, "cursor lands at the selection start");
    editor_free(&ed);
}

static void test_table_undo_redo(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "before");
    editor_insert_table(&ed, 1, 2);
    CHECK(ed.doc.count == 2, "table inserted, replacing the '@word' block ('before' stands in for it here)");
    CHECK(editor_undo(&ed), "undo succeeds");
    CHECK(ed.doc.count == 1 && ed.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed.doc.blocks[0], "before"),
          "undo removes the table and restores the original paragraph, text intact");
    CHECK(editor_redo(&ed), "redo succeeds");
    CHECK(ed.doc.count == 2 && ed.doc.blocks[0].type == BLOCK_TABLE_HEADER_CELL, "redo replays the table insertion");
    editor_free(&ed);
}

static void test_task_list(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "- ");
    CHECK(ed.doc.blocks[0].type == BLOCK_BULLET, "'- ' first converts to a bullet");
    type_str(&ed, "[ ] ");
    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "'[ ] ' right after turns the bullet into an unchecked task");
    type_str(&ed, "Buy milk");
    CHECK(text_eq(&ed.doc.blocks[0], "Buy milk"), "unchecked task holds typed text");

    CHECK(editor_toggle_task(&ed, 0), "toggling the task succeeds");
    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_CHECKED, "toggle flips unchecked -> checked");
    CHECK(editor_toggle_task(&ed, 0), "toggling again succeeds");
    CHECK(ed.doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "toggle flips back checked -> unchecked");
    CHECK(!editor_toggle_task(&ed, 5), "toggling an out-of-range block index fails");

    editor_toggle_task(&ed, 0); /* leave it checked for the round-trip check below */
    CHECK(document_save_file(&ed.doc, "test_task.md"), "save succeeds");
    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_task.md"), "load succeeds");
    CHECK(ed2.doc.count == 1 && ed2.doc.blocks[0].type == BLOCK_TASK_CHECKED, "checked task round-trips as '- [x] '");
    CHECK(text_eq(&ed2.doc.blocks[0], "Buy milk"), "task text round-trips");
    editor_free(&ed);
    editor_free(&ed2);
}

static void test_task_list_load_variants(void) {
    FILE *f = fopen("test_task_variants.md", "wb");
    fputs("- [ ] unchecked\n\n- [X] uppercase checked\n", f);
    fclose(f);

    Document doc;
    document_init(&doc);
    CHECK(document_load_file(&doc, "test_task_variants.md"), "load succeeds");
    CHECK(doc.count == 2, "two task items loaded");
    CHECK(doc.blocks[0].type == BLOCK_TASK_UNCHECKED, "'- [ ] ' loads as unchecked");
    CHECK(doc.blocks[1].type == BLOCK_TASK_CHECKED, "'- [X] ' (uppercase X) loads as checked");
    document_free(&doc);
}

static void test_code_fence_with_language(void) {
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "```c");
    editor_enter(&ed);
    CHECK(ed.doc.blocks[0].type == BLOCK_CODE, "'```c' then Enter opens a code block");
    CHECK(ed.doc.blocks[0].lang.len == 1 && ed.doc.blocks[0].lang.data[0] == 'c', "language 'c' is captured");

    type_str(&ed, "int x;");
    editor_enter(&ed);
    type_str(&ed, "```");
    editor_enter(&ed);

    CHECK(document_save_file(&ed.doc, "test_codelang.md"), "save succeeds");
    EditorState ed2;
    editor_init(&ed2);
    CHECK(editor_load(&ed2, "test_codelang.md"), "load succeeds");
    CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "code block round-trips");
    CHECK(ed2.doc.blocks[0].lang.len == 1 && ed2.doc.blocks[0].lang.data[0] == 'c', "language round-trips");
    CHECK(text_eq(&ed2.doc.blocks[0], "int x;"), "code text round-trips");
    editor_free(&ed);
    editor_free(&ed2);
}

static void test_inline_extended_styles(void) {
    const char *s = "a ~~b~~ c ==d== e ~f~ g ^h^";
    InlineRunList runs;
    inline_parse(s, (int)strlen(s), &runs);

    int haveStrike = 0, haveHighlight = 0, haveSub = 0, haveSuper = 0;
    for (int i = 0; i < runs.count; i++) {
        InlineRun *r = &runs.runs[i];
        if (r->kind == RUN_STRIKE && s[r->contentStart] == 'b') haveStrike = 1;
        if (r->kind == RUN_HIGHLIGHT && s[r->contentStart] == 'd') haveHighlight = 1;
        if (r->kind == RUN_SUB && s[r->contentStart] == 'f') haveSub = 1;
        if (r->kind == RUN_SUPER && s[r->contentStart] == 'h') haveSuper = 1;
    }
    CHECK(haveStrike, "inline_parse finds ~~strike~~");
    CHECK(haveHighlight, "inline_parse finds ==highlight==");
    CHECK(haveSub, "inline_parse finds ~sub~ (single tilde, distinct from ~~strike~~)");
    CHECK(haveSuper, "inline_parse finds ^super^");
    inline_runs_free(&runs);
}

static void test_inline_autolink(void) {
    const char *s = "see https://example.com. more text";
    InlineRunList runs;
    inline_parse(s, (int)strlen(s), &runs);

    int found = 0;
    for (int i = 0; i < runs.count; i++) {
        InlineRun *r = &runs.runs[i];
        if (r->kind != RUN_LINK) continue;
        if (r->rawStart != r->contentStart || r->rawEnd != r->contentEnd) continue; /* autolinks have no markers to reveal */
        const char *expected = "https://example.com";
        int n = r->contentEnd - r->contentStart;
        if (n == (int)strlen(expected) && memcmp(s + r->contentStart, expected, (size_t)n) == 0) found = 1;
    }
    CHECK(found, "bare https:// URL is autolinked, with the trailing sentence '.' trimmed off");
    inline_runs_free(&runs);
}

static void test_block_type_menu(void) {
    /* --- trigger condition --- */
    EditorState ed;
    editor_init(&ed);
    type_str(&ed, "@x");
    CHECK(block_type_menu_quick_insert_trigger_active(&ed), "\"@x\" with the cursor at the end triggers the quick-insert menu");

    sb_clear(&ed.doc.blocks[0].text);
    ed.cursorOffset = 0;
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "a bare empty paragraph (no '@' yet) does not trigger");

    type_str(&ed, "x@y");
    CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "'@' must be the first character, not just present somewhere");

    sb_clear(&ed.doc.blocks[0].text);
    ed.cursorOffset = 0;
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    type_str(&ed, "@foo bar");
    CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "a space anywhere in the block cancels the trigger");

    sb_clear(&ed.doc.blocks[0].text);
    ed.cursorOffset = 0;
    ed.selAnchorBlock = ed.cursorBlock; ed.selAnchorOffset = ed.cursorOffset;
    type_str(&ed, "@abc");
    ed.cursorOffset = 1;
    ed.selAnchorOffset = ed.cursorOffset;
    CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "cursor not at the end of the '@word' does not trigger");

    ed.doc.blocks[0].type = BLOCK_H1;
    ed.cursorOffset = ed.doc.blocks[0].text.len;
    ed.selAnchorOffset = ed.cursorOffset;
    CHECK(!block_type_menu_quick_insert_trigger_active(&ed), "only a BLOCK_PARAGRAPH can trigger, not e.g. a heading");
    editor_free(&ed);

    /* --- filter matching --- */
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        CHECK(block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "", 0), "an empty filter matches every item");
    }
    int headingMatches = 0;
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "head", 4)) headingMatches++;
    }
    CHECK(headingMatches == 6, "\"head\" matches exactly the 6 heading items");
    int junkMatches = 0;
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        if (block_type_menu_item_matches(&BLOCK_TYPE_MENU_ITEMS[i], "xyz", 3)) junkMatches++;
    }
    CHECK(junkMatches == 0, "an unrelated filter matches nothing");

    /* --- table invariants --- */
    bool anyTaskChecked = false, hrOffered = true;
    for (int i = 0; i < BLOCK_TYPE_MENU_ITEM_COUNT; i++) {
        if (BLOCK_TYPE_MENU_ITEMS[i].type == BLOCK_TASK_CHECKED) anyTaskChecked = true;
        if (BLOCK_TYPE_MENU_ITEMS[i].type == BLOCK_HR) hrOffered = BLOCK_TYPE_MENU_ITEMS[i].offerInTurnInto;
    }
    CHECK(!anyTaskChecked, "no menu item targets BLOCK_TASK_CHECKED (a fresh task always starts unchecked)");
    CHECK(!hrOffered, "BLOCK_HR is never offered by Turn Into (it can't preserve existing content)");

    /* --- editor_quick_insert_apply --- */
    EditorState ed2;
    editor_init(&ed2);
    type_str(&ed2, "@heading1");
    editor_quick_insert_apply(&ed2, BLOCK_H1);
    CHECK(ed2.doc.blocks[0].type == BLOCK_H1, "quick-insert converts the block to the chosen type");
    CHECK(text_eq(&ed2.doc.blocks[0], ""), "quick-insert clears the '@word' text");
    CHECK(ed2.cursorOffset == 0, "cursor lands at the start of the now-empty block");

    sb_clear(&ed2.doc.blocks[0].text);
    ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
    ed2.cursorOffset = 0;
    ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
    type_str(&ed2, "@bullet");
    editor_quick_insert_apply(&ed2, BLOCK_BULLET);
    CHECK(ed2.doc.blocks[0].type == BLOCK_BULLET, "quick-insert -> bullet list");

    sb_clear(&ed2.doc.blocks[0].text);
    ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
    ed2.cursorOffset = 0;
    ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
    type_str(&ed2, "@code");
    editor_quick_insert_apply(&ed2, BLOCK_CODE);
    CHECK(ed2.doc.blocks[0].type == BLOCK_CODE, "quick-insert -> code block");

    int countBefore = ed2.doc.count;
    sb_clear(&ed2.doc.blocks[0].text);
    ed2.doc.blocks[0].type = BLOCK_PARAGRAPH;
    ed2.cursorOffset = 0;
    ed2.selAnchorBlock = ed2.cursorBlock; ed2.selAnchorOffset = ed2.cursorOffset;
    type_str(&ed2, "@hr");
    editor_quick_insert_apply(&ed2, BLOCK_HR);
    CHECK(ed2.doc.blocks[0].type == BLOCK_HR, "quick-insert -> horizontal rule");
    CHECK(ed2.doc.count == countBefore + 1, "a following empty paragraph is inserted after the new HR");
    CHECK(ed2.doc.blocks[1].type == BLOCK_PARAGRAPH && text_eq(&ed2.doc.blocks[1], ""), "the following block is an empty paragraph");
    CHECK(ed2.cursorBlock == 1 && ed2.cursorOffset == 0, "the cursor moves into the new empty paragraph");
    editor_free(&ed2);

    /* --- editor_turn_into_apply preserves content --- */
    EditorState ed3;
    editor_init(&ed3);
    type_str(&ed3, "some real text");
    editor_turn_into_apply(&ed3, BLOCK_H2);
    CHECK(ed3.doc.blocks[0].type == BLOCK_H2, "turn-into converts the block to the chosen type");
    CHECK(text_eq(&ed3.doc.blocks[0], "some real text"), "turn-into preserves the existing text");
    editor_free(&ed3);

    /* --- the critical regression: leaving BLOCK_CODE scrubs '\n' and clears lang --- */
    EditorState ed4;
    editor_init(&ed4);
    type_str(&ed4, "```c");
    editor_enter(&ed4);
    type_str(&ed4, "line1");
    editor_enter(&ed4); /* embeds a '\n', stays BLOCK_CODE (not a closing fence) */
    type_str(&ed4, "line2");
    CHECK(ed4.doc.blocks[0].type == BLOCK_CODE && ed4.doc.blocks[0].lang.len == 1, "multi-line code block with a language is set up");
    bool hasNewline = false;
    for (int i = 0; i < ed4.doc.blocks[0].text.len; i++) if (ed4.doc.blocks[0].text.data[i] == '\n') hasNewline = true;
    CHECK(hasNewline, "the code block really does contain an embedded newline before conversion");

    editor_turn_into_apply(&ed4, BLOCK_H3);
    CHECK(ed4.doc.blocks[0].type == BLOCK_H3, "turn-into converts the (former) code block");
    bool stillHasNewline = false;
    for (int i = 0; i < ed4.doc.blocks[0].text.len; i++) if (ed4.doc.blocks[0].text.data[i] == '\n') stillHasNewline = true;
    CHECK(!stillHasNewline, "the embedded newline is scrubbed to a space, since no other block renderer expects one");
    CHECK(text_eq(&ed4.doc.blocks[0], "line1 line2"), "the scrubbed text is exactly the two lines joined by a space");
    CHECK(ed4.doc.blocks[0].lang.len == 0, "lang is cleared when leaving BLOCK_CODE");
    editor_free(&ed4);

    /* --- the critical regression: leaving BLOCK_IMAGE clears alt --- */
    EditorState ed7;
    editor_init(&ed7);
    type_str(&ed7, "some/path.png");
    editor_turn_into_apply(&ed7, BLOCK_IMAGE);
    CHECK(ed7.doc.blocks[0].type == BLOCK_IMAGE, "turn-into converts to an image block");
    CHECK(text_eq(&ed7.doc.blocks[0], "some/path.png"), "turn-into preserves the existing text as the url");
    sb_append(&ed7.doc.blocks[0].alt, "a photo", 7);
    CHECK(ed7.doc.blocks[0].alt.len == 7, "alt text is set up for the regression check");
    editor_turn_into_apply(&ed7, BLOCK_H4);
    CHECK(ed7.doc.blocks[0].type == BLOCK_H4, "turn-into converts the (former) image block");
    CHECK(ed7.doc.blocks[0].alt.len == 0, "alt is cleared when leaving BLOCK_IMAGE");
    editor_free(&ed7);

    /* --- undo/redo --- */
    EditorState ed5;
    editor_init(&ed5);
    type_str(&ed5, "@quote");
    editor_quick_insert_apply(&ed5, BLOCK_QUOTE);
    CHECK(ed5.doc.blocks[0].type == BLOCK_QUOTE, "quick-insert applied");
    CHECK(editor_undo(&ed5), "undo the quick-insert conversion");
    CHECK(ed5.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed5.doc.blocks[0], "@quote"),
          "undo restores the original paragraph with the '@word' text intact -- it does not step through the typing too");
    CHECK(editor_undo(&ed5), "a second undo now reverts the coalesced typing");
    CHECK(text_eq(&ed5.doc.blocks[0], ""), "back to the empty paragraph");
    CHECK(editor_redo(&ed5) && editor_redo(&ed5), "redo replays both steps");
    CHECK(ed5.doc.blocks[0].type == BLOCK_QUOTE, "redo ends back at the converted quote block");
    editor_free(&ed5);

    EditorState ed6;
    editor_init(&ed6);
    type_str(&ed6, "keep me");
    editor_turn_into_apply(&ed6, BLOCK_BULLET);
    CHECK(editor_undo(&ed6), "undo the turn-into conversion");
    CHECK(ed6.doc.blocks[0].type == BLOCK_PARAGRAPH && text_eq(&ed6.doc.blocks[0], "keep me"),
          "undo restores the original type with the text still intact");
    editor_free(&ed6);
}

int main(void) {
    test_heading_autoformat();
    test_bullet_list_continuation();
    test_numbered_list_renumbers_on_save();
    test_backspace_demotes_then_merges();
    test_code_fence();
    test_code_fence_double_enter_exit();
    test_utf8_backspace();
    test_inline_parse();
    test_save_load_roundtrip();
    test_document_clone_deep_copy();
    test_document_compute_stats();
    test_undo_typing_coalesces();
    test_undo_kind_switch_starts_new_group();
    test_undo_enter_is_own_step();
    test_undo_redo_roundtrip();
    test_snapshot_stack_cap_eviction();
    test_selection_range_normalization();
    test_selection_to_text_across_blocks();
    test_selection_replace_on_type();
    test_selection_replace_on_backspace_across_blocks();
    test_config_parse_line();
    test_lang_parse_line();
    test_theme_zoom();
    test_keymap();
    test_heading_levels_4_5_6();
    test_heading_levels_round_trip();
    test_horizontal_rule();
    test_horizontal_rule_via_space();
    test_image_markdown();
    test_image_enter_does_not_split_url();
    test_image_resolve_path();
    test_table_parse_basic();
    test_table_alignment_parsing();
    test_table_delimiter_disambiguation();
    test_table_ragged_rows();
    test_table_bullet_priority();
    test_table_serialize_roundtrip();
    test_table_insert();
    test_table_tab_navigation();
    test_table_enter_moves_like_tab();
    test_table_backspace_guards();
    test_table_delete_forward_guards();
    test_table_cross_cell_selection_delete();
    test_table_undo_redo();
    test_task_list();
    test_task_list_load_variants();
    test_code_fence_with_language();
    test_inline_extended_styles();
    test_inline_autolink();
    test_block_type_menu();

    printf("\n%s (%d failure%s)\n", g_failures == 0 ? "ALL PASS" : "SOME FAILED", g_failures, g_failures == 1 ? "" : "s");
    return g_failures == 0 ? 0 : 1;
}
