/*
 * mdview2_index.c — парсер/индексатор markdown (сердце приложения).
 *
 * Единственный проход по исходному файлу (index_begin/index_step): классификация строк
 * (classify_line, is_*_raw), сборка абзацев с переносом (scan_join_stream),
 * inline-разметка (inline_marker/inline_scan) и эмиссия готовых (char,attr)-
 * ячеек в рендер-кэш (gc_put → seg_commit_cells → cache_*). Ведёт индекс
 * сегментов в EMM (idx_get/idx_put) и докручивает UTF-конвертацию впереди
 * позиции чтения. Разделяемые символы и API — в mdview2.h.
 *
 * Приватно (не торчит в .h): idx_rec_t, cur_rec/cur_seg_off, буфер ячеек
 * g_cells, idx_get/idx_put и все классификаторы/сканеры.
 */

#include <stdint.h>
#include <string.h>
#include <conio.h>          /* COLOR()/COLOR_* для ATTR_* в сканерах */
#include <sprinter.h>
#include <sprinter_mem.h>   /* bank_read/bank_write (idx_get/idx_put) */
#include "mdview2.h"

static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr);  /* forward (нужна в inline-сканере) */
static uint8_t is_hr_raw(uint32_t p);                          /* forward (нужна в classify_line) */

/* Начало следующей физической строки (байт после '\n', либо file_size). */
static uint32_t next_line(uint32_t p)
{
    p = row_end(p);
    return (p < file_size) ? p + 1 : p;
}

typedef struct idx_rec_s {
    uint32_t off;     /* смещение первого байта исходного текста для сегмента */
    uint8_t  flags;   /* битовые флаги IF_* ниже */
    uint8_t  style;   /* начальный стиль INIT_STYLE_* в начале сегмента */
    uint8_t  pad0;    /* добивка до 8 байт (2048 записей на страницу 16 КБ) */
    uint8_t  pad1;
} idx_rec_t;

#define INDEX_REC_SIZE       8u

static uint32_t  cur_seg_off;     /* смещение последнего emit_seg (кэш в near-памяти) */
static idx_rec_t cur_rec;            /* near-копия последней записанной idx-записи */

/* Для проверки границ выделения считаем пробел/таб/конец строки/0 пробельными. */
static uint8_t ws_or_eol(char c)
{
    return (uint8_t)(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0);
}

static uint8_t ws_or_eol_or_delim(char c)
{
    return (uint8_t)(c == ' ' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '/'
        || c == '\t' || c == '\n' || c == '\r' || c == 0);
}

/* Маркер inline-выделения (*, **, _) распознаётся только если
 * пробельная граница есть ровно с одной стороны (XOR-правило).
 * Это защищает выражения вроде "2 * 3", "2 ** 3" и внутрисловные
 * случаи (например COLOR_YELLOW) от ложного форматирования. */
static uint8_t is_emph_flanked(char prev_ch, char next_ch)
{
    return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch));
}

/* Символ можно экранировать обратным слешем (\X): любая печатная ASCII-
 * пунктуация (как в CommonMark). \* \_ \` \[ и т.п. → литерал X. */
static uint8_t is_escapable(char c)
{
    return (uint8_t)(c >= '!' && c <= '~' &&
        !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')));
}

/* Типы сегмента CK_* и стили INIT_STYLE_* — в mdview2.h (общие с таблицами). */

/* Доступ к индексу в EMM.
 * Размер записи 8 байт, поэтому запись не пересекает границу страницы
 * и bank_read/bank_write работают с цельным блоком. */
static void idx_get(uint16_t idx, idx_rec_t *r)
{
    uint8_t  page = (uint8_t)(idx >> 11);            /* номер страницы: idx / 2048 */
    uint16_t off  = (uint16_t)((idx & 2047u) << 3);  /* смещение записи: (idx % 2048) * 8 */
    bank_read(index_phys[page], off, r, INDEX_REC_SIZE);
}

static void idx_put(uint16_t idx, const idx_rec_t *r)
{
    uint8_t  page = (uint8_t)(idx >> 11);
    uint16_t off  = (uint16_t)((idx & 2047u) << 3);
    bank_write(index_phys[page], off, r, INDEX_REC_SIZE);
}

uint32_t seg_off(uint16_t idx)
{
    idx_rec_t r;
    if (idx >= n_lines) return file_size;
    idx_get(idx, &r);
    return r.off;
}

/* Последняя строка индекса с исходным offset <= off — обратный перевод
 * байт-позиции в MD-строку (позиционирование при F2/F8). Оффсеты сегментов
 * монотонны, поэтому бинарный поиск (~15 bank-чтений). */
uint16_t line_at_off(uint32_t off)
{
    uint16_t lo = 0, hi = n_lines;
    while (lo < hi) {
        uint16_t mid = (uint16_t)((lo + hi) >> 1);
        if (seg_off(mid) <= off) lo = (uint16_t)(mid + 1);
        else                     hi = mid;
    }
    return (lo > 0) ? (uint16_t)(lo - 1) : 0;
}

/* Установка флагов (маска IF_*) всегда идёт в последний emit_seg (n_lines-1).
 * Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */
static void set_cur_flags(uint8_t f) { cur_rec.flags |= f; idx_put((uint16_t)(n_lines - 1), &cur_rec); }

/* ==================================================================
 *                       Индексация строк
 * ================================================================== */

/* INIT_STYLE_* — в mdview2.h (общие с модулем таблиц). */

/* Тип строки, который возвращает classify_line(). */
#define LK_PLAIN  0
#define LK_H1     1
#define LK_H2     2
#define LK_H3     3
#define LK_H4     4   /* также используется для H5/H6 */
#define LK_HR     5
#define LK_ULIST  6   /* маркер ненумерованного списка */
#define LK_OLIST  7   /* маркер нумерованного списка */
#define LK_QUOTE  8   /* маркер цитаты */

/* Классифицирует логическую строку.
 *   - Заголовки: 1..4 символа '#' и затем пробел/таб (H5/H6 сводятся к H4).
 *   - Горизонтальная линия: только '-', '*' или '_' (одного вида, >=3),
 *     возможно с пробелами/табами между символами.
 *   - Ненумерованный список: "- ", "* " или "+ " перед содержимым.
 *   - Нумерованный список: цифры + '.'/ ')' + пробел.
 *   - Цитата: '>' с опциональным пробелом.
 *   - Иначе обычный текст.
 * Для заголовков/списков/цитат out_content указывает на первый байт
 * содержимого после маркера и завершающего пробела. */
static uint8_t classify_line(uint32_t p_start, uint32_t *out_content)
{
    if (p_start >= file_size) return LK_PLAIN;

    char c0 = fb(p_start);

    /* Заголовок? */
    if (c0 == '#') {
        uint8_t  lvl = 0;
        uint32_t p   = p_start;
        while (p < file_size && fb(p) == '#' && lvl < 6) {
            lvl++; p++;
        }
        if (p < file_size) {
            char ch = fb(p);
            if (ch == ' ' || ch == '\t') {
                p++;
                *out_content = p;
                if (lvl > 4) lvl = 4;
                return lvl;             /* LK_H1 .. LK_H4 */
            }
        }
        /* Символ '#' без пробела после него не считается заголовком. */
    }

    /* Горизонтальный разделитель.
     * Проверяется до списков, чтобы `- - -` не стало маркером списка. */
    if (is_hr_raw(p_start)) return LK_HR;

    /* Для списков/цитат дополнительно допускаем ведущие пробелы
     * (базовая поддержка вложенности). */
    uint32_t lp = p_start;
    while (lp < file_size && fb(lp) == ' ') lp++;
    char cl = (lp < file_size) ? fb(lp) : 0;

    /* Ненумерованный список? */
    if ((cl == '-' || cl == '*' || cl == '+') &&
        (lp + 1) < file_size &&
        fb(lp + 1) == ' ') {
        *out_content = lp + 2;
        return LK_ULIST;
    }

    /* Нумерованный список? */
    if (cl >= '0' && cl <= '9') {
        uint32_t p = lp;
        while (p < file_size) {
            char ch = fb(p);
            if (ch < '0' || ch > '9') break;
            p++;
        }
        if (p < file_size) {
            char ch = fb(p);
            if ((ch == '.' || ch == ')') &&
                (p + 1) < file_size && fb(p + 1) == ' ') {
                *out_content = p + 2;
                return LK_OLIST;
            }
        }
    }

    /* Цитата? */
    if (cl == '>') {
        uint32_t p = lp + 1;
        if (p < file_size && fb(p) == ' ') p++;
        *out_content = p;
        return LK_QUOTE;
    }

    return LK_PLAIN;
}

/* Возвращает видимую ширину префикса (отступ + маркер) в колонках. */
static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t content_off)
{
    switch (kind) {
        case LK_ULIST:
        case LK_QUOTE:
        case LK_OLIST: return (uint8_t)(content_off - p_start);
        default:       return 0;                          /* plain/headers спец-префикса не имеют */
    }
}

/* ================== Merge: сборка ячеек на этапе индекс-скана ==============
 * Форвард-сканер (scan_join_stream/inline_scan + прямые emit'ы в index_lines)
 * собирает готовые (char,attr)-ячейки в g_cells ПО ХОДУ единственного прохода
 * по файлу — без отдельного второго скана. На каждом emit_seg ячейки
 * ПРЕДЫДУЩего сегмента флашатся в кэш (lag-1: к этому моменту сегмент уже
 * полностью просканирован); последний сегмент флашится после цикла index_lines.
 * g_ncells_at_space — снимок длины на последнем пробеле для усечения буфера
 * при переносе строки (отбрасываем уехавшее за границу слово). */
static uint8_t  g_cells[MAX_CACHE_LINE_LEN * 2];
uint8_t         g_ncells;   /* ячеек в текущем сегменте (общий с таблицами) */
static uint8_t  g_ncells_at_space;   /* снимок g_ncells на последнем пробеле (для усечения при переносе) */
static uint8_t  g_skip_flush;   /* следующий emit_seg пропустит lag-флаш (pending уже зафлашен вручную, напр. перед таблицей) */

/* Обрыв индексации: причина (0 / TRUNC_CONTENT / TRUNC_LINES) и строка, на
 * которой упёрлись. Выставляются ОДИН раз (первая причина побеждает); по ним
 * index_lines() в конце пишет строку-сообщение (IF_TRUNC_MSG, рисуется вживую). */
static uint8_t  g_trunc_cause;
static uint16_t g_trunc_line;

/* Пишет одну (char,attr)-ячейку в g_cells, молча игнорируя переполнение
 * MAX_CACHE_LINE_LEN. Оставлена ФУНКЦИЕЙ намеренно: инлайн в сильно
 * регистро-нагруженный скан-цикл на Z80/SDCC увеличивает спиллы и замедляет
 * (проверено замером — макро-инлайн дал регрессию ~11→13с). */
void gc_put(char ch, uint8_t attr)
{
    if (g_ncells < MAX_CACHE_LINE_LEN) {
        uint16_t i = (uint16_t)g_ncells * 2u;
        g_cells[i]     = (uint8_t)ch;
        g_cells[i + 1] = attr;
        g_ncells++;
    }
}
void gc_fill(char ch, uint8_t attr, uint8_t n) { while (n-- > 0) gc_put(ch, attr); }

/* Пишет g_cells как контент кэша для line_idx: flags берём из idx-записи,
 * пустой буфер/blank → len 0; иначе резервируем место в пуле и коммитим. */
static void seg_commit_cells(uint16_t line_idx)
{
    cache_rec_t out_rec;
    out_rec.flags = 0; out_rec.reserved = 0; out_rec.pad[0] = 0; out_rec.pad[1] = 0;
    idx_rec_t r;
    uint8_t have = (uint8_t)(line_idx < n_lines);
    if (have) { idx_get(line_idx, &r); out_rec.flags = r.flags; }
    if (!have || (r.flags & IF_BLANK) || g_ncells == 0) {
        out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
        cache_dir_put(line_idx, &out_rec);
        return;
    }
    out_rec.len = g_ncells;
    if (cache_reserve((uint16_t)(g_ncells * 2u), &out_rec.page, &out_rec.off)) {
        cache_commit(out_rec.page, out_rec.off, g_cells, (uint16_t)(g_ncells * 2u));
    } else {
        /* Контент-кэш исчерпан: фиксируем причину и строку обрыва (один раз).
         * index_lines() оборвёт цикл и перезапишет эту строку сообщением. */
        if (!g_trunc_cause) { g_trunc_cause = TRUNC_CONTENT; g_trunc_line = line_idx; }
        out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
    }
    cache_dir_put(line_idx, &out_rec);
}


static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
{
    (void)ckind;                       /* тип сегмента восстанавливается флагами idx-записи */
    if (n_lines >= max_lines) {
        index_truncated = 1;
        if (!g_trunc_cause) { g_trunc_cause = TRUNC_LINES; g_trunc_line = (uint16_t)(max_lines - 1); }
        return;
    }
    cur_rec.off   = off;
    cur_rec.flags = (uint8_t)(cont ? IF_CONT : 0);
    cur_rec.style = (uint8_t)(style & 7u);
    cur_rec.pad0  = 0;
    cur_rec.pad1  = 0;
    idx_put(n_lines, &cur_rec);
    cur_seg_off = off;
    n_lines++;
    /* Флаш ячеек ПРЕДЫДУЩего сегмента (lag-1): он полностью собран форвард-сканером. */
    if (n_lines >= 2) {
        if (g_skip_flush) g_skip_flush = 0;          /* pending уже зафлашен вручную */
        else              seg_commit_cells((uint16_t)(n_lines - 2));
    }
    g_ncells          = 0;               /* буфер ячеек под новый сегмент */
    g_ncells_at_space = 0;
}

/* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */
static uint8_t is_line_blank(uint32_t p)
{
    while (p < file_size) {
        char c = fb(p);
        if (c == '\n') return 1;
        if (c != ' ' && c != '\t') return 0;
        p++;
    }
    return 1;
}

/* Число ведущих пробелов строки (отступ). Используется для отделения
 * dedent-пунктов списка (#3) и для отступа обычного абзаца (#4). */
static uint8_t leading_spaces(uint32_t p)
{
    uint8_t n = 0;
    while (p < file_size && fb(p) == ' ' && n < 250) { n++; p++; }
    return n;
}

/* Быстрая проверка границы fenced-блока по маркеру ``` .
 * Ведущие пробелы/табы пропускаются — fence может быть с отступом
 * (например, ``` ```c ``` внутри элемента списка с отступом 2+ пробела).
 * Язык после бэктиков (```c, ```sh) игнорируется: достаточно трёх ` подряд. */
static uint8_t is_fence_raw(uint32_t p)
{
    while (p < file_size && (fb(p) == ' ' || fb(p) == '\t')) p++;
    if (p + 2 >= file_size) return 0;
    return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`');
}

/* Быстрая проверка горизонтального разделителя (`---`, `***`, `___`). */
static uint8_t is_hr_raw(uint32_t p)
{
    char c0 = fb(p);
    if (c0 != '-' && c0 != '*' && c0 != '_') return 0;
    char marker = 0;
    uint8_t count = 0, ok = 1;
    uint32_t hr_p = p;
    while (hr_p < file_size) {
        char ch = fb(hr_p++);
        if (ch == '\n' || ch == '\r') break;
        if (ch == ' ' || ch == '\t') continue;
        if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; }
        if (marker == 0) marker = ch;
        else if (ch != marker) { ok = 0; break; }
        count++;
    }
    return ok && count >= 3;
}

/* Строка таблицы: первый непустой символ — '|'.
 * Ведущие пробелы разрешены; такие строки не склеиваются и не переносятся. */
static uint8_t is_table_raw(uint32_t p)
{
    while (p < file_size) {
        char c = fb(p);
        if (c == ' ' || c == '\t') { p++; continue; }
        return (uint8_t)(c == '|');
    }
    return 0;
}

/* Разбор одного inline-МАРКЕРА в позиции *pq (< bound): \X, [x], `, **, ~~,
 * *, _, \t. Общий код для inline_scan() и scan_join_stream(). Возврат: 1 —
 * маркер обработан (вызывающий делает continue), 0 — символ обычный (пробел/
 * текст), его кладёт сам вызывающий (и проверяет перенос). Меняет
 * *pq/*pls/*pseg/*pprev. attr берётся как emph_to_attr(line_style, base) —
 * для join base=ATTR_TEXT (атрибут «обычного» текста). */
static uint8_t inline_marker(uint32_t *pq, uint32_t bound, uint8_t *pls,
                             uint8_t *pseg, char *pprev, uint8_t base)
{
    uint32_t q  = *pq;
    uint8_t  ls = *pls;
    char     ch = fb(q);
    uint8_t  at = emph_to_attr(ls, base);   /* атрибут литералов текущего стиля */

    if (ls != INIT_STYLE_CODE && ch == '\\' && (q + 1) < bound && is_escapable(fb(q + 1))) {
        char nb = fb(q + 1);
        gc_put(nb, at);
        *pseg += 1; *pq = q + 2; *pprev = nb; return 1;
    }
    if (ls != INIT_STYLE_CODE && ch == '[' && (q + 2) < bound && fb(q + 2) == ']') {
        gc_put('[', ATTR_TEXT_BOLD); gc_put(fb(q + 1), ATTR_TEXT_BOLD); gc_put(']', ATTR_TEXT_BOLD);
        *pseg += 3; *pq = q + 3; *pprev = ']'; return 1;
    }
    if (ch == '`') {
        if (ls == INIT_STYLE_PLAIN) { *pls = INIT_STYLE_CODE;  *pq = q + 1; return 1; }
        if (ls == INIT_STYLE_CODE)  { *pls = INIT_STYLE_PLAIN; *pq = q + 1; return 1; }
        gc_put('`', at); *pseg += 1; *pq = q + 1; *pprev = '`'; return 1;
    }
    if (ch == '*' && (q + 1) < bound && fb(q + 1) == '*') {
        char nx = ((q + 2) < bound) ? fb(q + 2) : '\n';
        if (is_emph_flanked(*pprev, nx)) {
            if (ls == INIT_STYLE_PLAIN && ws_or_eol(*pprev))            { *pls = INIT_STYLE_BOLD;  *pq = q + 2; return 1; }
            if (ls == INIT_STYLE_BOLD  && ws_or_eol_or_delim(nx))       { *pls = INIT_STYLE_PLAIN; *pq = q + 2; return 1; }
        }
        gc_put('*', at); gc_put('*', at);
        *pseg += 2; *pq = q + 2; *pprev = '*'; return 1;
    }
    if (ch == '~' && (q + 1) < bound && fb(q + 1) == '~') {
        char nx = ((q + 2) < bound) ? fb(q + 2) : '\n';
        if (is_emph_flanked(*pprev, nx)) {
            if (ls == INIT_STYLE_PLAIN  && ws_or_eol(*pprev))          { *pls = INIT_STYLE_STRIKE; *pq = q + 2; return 1; }
            if (ls == INIT_STYLE_STRIKE && ws_or_eol_or_delim(nx))     { *pls = INIT_STYLE_PLAIN;  *pq = q + 2; return 1; }
        }
        gc_put('~', at); gc_put('~', at);
        *pseg += 2; *pq = q + 2; *pprev = '~'; return 1;
    }
    if (ch == '*' || ch == '_') {
        char nx = ((q + 1) < bound) ? fb(q + 1) : '\n';
        if (is_emph_flanked(*pprev, nx)) {
            uint8_t ns = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER;
            if (ls == INIT_STYLE_PLAIN && ws_or_eol(*pprev))          { *pls = ns;               *pq = q + 1; return 1; }
            if (ls != INIT_STYLE_PLAIN && ws_or_eol_or_delim(nx))     { *pls = INIT_STYLE_PLAIN; *pq = q + 1; return 1; }
        }
        gc_put(ch, at); *pseg += 1; *pq = q + 1; *pprev = ch; return 1;
    }
    if (ch == '\t') {
        uint8_t old_sc   = *pseg;
        uint8_t tgt_full = (uint8_t)((old_sc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
        uint8_t tgt      = tgt_full; if (tgt > SCREEN_W) tgt = SCREEN_W;
        gc_fill(' ', at, (uint8_t)(tgt_full - old_sc));
        *pseg = tgt; *pq = q + 1; *pprev = ' '; return 1;
    }
    return 0;   /* пробел или обычный символ — обрабатывает вызывающий */
}

#define JOIN_MODE_NONE   0u
#define JOIN_MODE_LIST   1u
#define JOIN_MODE_QUOTE  2u
#define JOIN_MODE_PLAIN  3u

/* ================== Единый сканер сегмента ================================
 * Общий цикл inline-форматирования, переносов и (в join-режимах) склейки
 * физических строк — бывшие inline_scan + scan_join_stream. mode:
 *   JOIN_MODE_NONE — сканировать РОВНО [q, bound) без склейки; '\n' в
 *       диапазоне не ожидается (вызывающие дают строку/ячейку до EOL);
 *       активны base_attr (title-attr заголовка) и nowrap (замер таблиц);
 *       ckind всегда CK_OTHER (continuation-префикс не эмитится).
 *   JOIN_MODE_LIST/QUOTE/PLAIN — правила '\n'-склейки; bound == file_size,
 *       base_attr == ATTR_TEXT (emph_to_attr(ls, ATTR_TEXT) тождественно
 *       бывшему styles_map[ls]), nowrap == 0.
 * Возвращает позицию останова; итоговый стиль — через *io_line_style. */
static uint32_t scan_stream(uint32_t q, uint32_t bound, uint32_t para_start,
                            uint8_t col, uint8_t ckind, uint8_t *io_line_style,
                            uint8_t base_attr, uint8_t nowrap, uint8_t mode)
{
    uint32_t last_space = 0xFFFFFFFFu;
    uint8_t  last_space_style = *io_line_style; /* снимок line_style на момент last_space */
    char prev_ch = ' ';
    uint8_t seg_col = col;
    uint8_t line_style = *io_line_style;
    /* base — атрибут «обычного» текста сегмента. Для заголовка первый сегмент
     * имеет title-attr, а continuation-сегменты render рисует как PLAIN —
     * переключаем base после 1-го переноса (в join-режимах base == ATTR_TEXT
     * и присваивание ничего не меняет). */
    uint8_t base = base_attr;

    while (q < bound && n_lines < max_lines) {
        char ch = fb(q);
        uint8_t soft_break = 0;

        if (ch == '\n') {
            uint32_t next = q + 1;
            if (mode == JOIN_MODE_NONE) break;  /* NONE: '\n' в [q,bound) не ожидается */
            if (next >= bound) break;

            if (mode == JOIN_MODE_LIST) {
                if (is_line_blank(next)) break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;

                {
                    uint32_t next_content = next;
                    uint8_t next_kind = classify_line(next, &next_content);
                    if (next_kind == LK_ULIST || next_kind == LK_OLIST ||
                        next_kind == LK_QUOTE ||
                        (next_kind >= LK_H1 && next_kind <= LK_H4) ||
                        next_kind == LK_HR) {
                        break;
                    }
                }
                while (next < bound) {
                    char ws = fb(next);
                    if (ws == ' ' || ws == '\t') next++;
                    else break;
                }
                q = next;
                soft_break = 1;
                ch = ' ';
            } else if (mode == JOIN_MODE_QUOTE) {
                if (is_line_blank(next)) break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;

                {
                    uint32_t next_content = next;
                    uint8_t next_kind = classify_line(next, &next_content);
                    if (next_kind != LK_QUOTE) break;

                    {
                        uint32_t next_line_end = row_end(next_content);

                        {
                            uint8_t empty_quote = 1;
                            for (uint32_t z = next_content; z < next_line_end; z++) {
                                char c = fb(z);
                                if (c != ' ' && c != '\t') { empty_quote = 0; break; }
                            }
                            if (empty_quote) break;
                        }

                        {
                            uint32_t t = next_content;
                            while (t < next_line_end && (fb(t) == ' ' || fb(t) == '\t')) t++;
                            if (t < next_line_end && fb(t) == '>') break;
                        }

                        while (next_content < next_line_end) {
                            char ws = fb(next_content);
                            if (ws == ' ' || ws == '\t') next_content++;
                            else break;
                        }
                        if (next_content < next_line_end && fb(next_content) == '>') {
                            next_content++;
                            if (next_content < next_line_end && fb(next_content) == ' ') next_content++;
                        }
                        q = next_content;
                        soft_break = 1;
                        ch = ' ';
                    }
                }
            } else { /* JOIN_MODE_PLAIN */
                if (fb(next) == '\n') break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
                {
                    uint32_t dummy;
                    if (classify_line(next, &dummy) != LK_PLAIN) break;
                }
                if (is_line_blank(next)) break;

                {
                    uint8_t is_hard = 0;
                    if (q >= para_start + 1) {
                        char b1 = fb(q - 1);
                        if (b1 == '\\') {
                            is_hard = 1;
                        } else if (q >= para_start + 2) {
                            char b2 = fb(q - 2);
                            if ((b1 == ' ' || b1 == '\t') && (b2 == ' ' || b2 == '\t'))
                                is_hard = 1;
                        }
                    }
                    if (is_hard) {
                        q++;
                        emit_seg(q, line_style, ckind, 0);
                        last_space = 0xFFFFFFFFu;
                        prev_ch = ' ';
                        seg_col = col;
                        continue;
                    }
                }

                q++;
                /* как render: пропускаем ведущие пробелы/табы строки-продолжения,
                 * склейка даёт РОВНО один пробел (иначе merge эмитит отступ как лишние ячейки) */
                while (q < bound && (fb(q) == ' ' || fb(q) == '\t')) q++;
                soft_break = 1;
                ch = ' ';
            }
        }

        /* Inline-маркеры (\X [x] ` ** ~~ * _ \t) — общий разбор; при
         * soft_break ch==' ' (синтетический пробел склейки) — маркеры
         * пропускаем, его кладёт ветка пробела ниже. */
        if (!soft_break && inline_marker(&q, bound, &line_style, &seg_col, &prev_ch, base))
            continue;

        if (ch == ' ') {
            gc_put(' ', emph_to_attr(line_style, base));
            if (soft_break) {
                last_space = q;                 /* синтетический пробел склейки */
            } else {
                last_space = q + 1;
                q++;
            }
            last_space_style = line_style;
            g_ncells_at_space = g_ncells;       /* снимок для усечения */
            seg_col++;
            prev_ch = ' ';
        } else {
            gc_put(ch, emph_to_attr(line_style, base));
            seg_col++;
            q++;   /* soft_break здесь всегда 0 (ch != ' ') */
            prev_ch = ch;
        }
        if (!nowrap && seg_col >= SCREEN_W) {
            uint8_t  use_ls  = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off);
            uint32_t wrap_at = use_ls ? last_space : q;
            if (wrap_at >= bound || wrap_at == cur_seg_off) break;
            uint8_t wrap_style = use_ls ? last_space_style : line_style;
            if (use_ls) g_ncells = g_ncells_at_space;  /* усечь до пробела */
            emit_seg(wrap_at, wrap_style, ckind, 1);
            base = ATTR_TEXT;   /* continuation заголовка → PLAIN (в join base и так ATTR_TEXT) */
            /* Префикс continuation-сегмента: список и отступленный абзац (#4) —
             * col пробелов выравнивания; цитата — отступ + маркер 0xB3 + пробел.
             * Для обычного абзаца без отступа col==0 → префикса нет; CK_OTHER
             * (заголовки/таблицы, NONE-режим) — префикса нет. */
            if (ckind == CK_LIST || ckind == CK_PLAIN) {
                gc_fill(' ', ATTR_TEXT, col);
            } else if (ckind == CK_QUOTE) {
                uint32_t content_off2 = para_start + col;
                uint8_t qsp = 0;
                while (qsp + 2 < content_off2 && qsp < SCREEN_W && fb(para_start + qsp) == ' ') qsp++;
                gc_fill(' ', ATTR_TEXT, qsp);
                gc_put(0xB3, ATTR_QUOTE_MARKER);
                gc_put(' ', ATTR_TEXT);
            }
            seg_col = col;
            last_space = 0xFFFFFFFFu;
            prev_ch = ' ';
            q = wrap_at;
            /* Откатываем line_style к снимку на момент wrap_at: диапазон
             * [wrap_at, q_old) будет пересканирован заново, и переключающие
             * символы (обратная кавычка, звёздочка, подчёркивание, тильда)
             * внутри него иначе применились бы дважды — именно так возникал
             * баг: code-маркер на границе переноса ломает цвет следующего слова (mdview.c, отчёт пользователя
             * 2026-06-23). */
            line_style = wrap_style;
        }
    }

    *io_line_style = line_style;
    return q;
}

/* Тонкая обёртка прежнего API: скан ровно [q, q_end) без склейки строк.
 * Вызывается для заголовков и ячеек таблиц (всегда CK_OTHER, col=0). */
uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col,
                    uint8_t ckind, uint8_t line_style, uint8_t base_attr,
                    uint8_t nowrap)
{
    (void)scan_stream(q, q_end, q, col, ckind, &line_style, base_attr, nowrap,
                      JOIN_MODE_NONE);
    return line_style;
}

/* Потоковый сканер для index_lines(): mode задаёт правила '\n'-склейки. */
static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
                                 uint8_t ckind, uint8_t *io_line_style, uint8_t mode)
{
    return scan_stream(q, file_size, para_start, col, ckind, io_line_style,
                       ATTR_TEXT, 0, mode);
}

/* Отрисовка таблиц (row_end/table_*) — в модуле mdview2_table.c. */

/* ================== Резюмируемый проход индексации =======================
 * Бывший монолит index_lines() порезан на begin/step/finish, чтобы фоновая
 * сборка второго набора (bg_step ядра) могла выполнять индексацию порциями
 * в паузах между клавишами. Всё межшаговое состояние — в статиках модуля
 * (idx_p/idx_in_block ниже + g_cells/cur_rec/g_trunc_* выше); оно НЕ входит
 * в docset_t и переживает свапы наборов, потому что обработчики клавиш
 * видимого набора индексатор не трогают. */
static uint32_t idx_p;          /* позиция чтения исходника между шагами */
static uint8_t  idx_in_block;   /* внутри fenced code-блока */
static uint8_t  idx_active;     /* 1 = проход начат и ещё не финализирован */

/* Начинает новый проход (сброс позиции/буферов/причин обрыва). */
void index_begin(void)
{
    idx_p        = 0;
    idx_in_block = 0;
    n_lines = 0;
    index_truncated = 0;
    g_trunc_cause = 0;
    g_trunc_line  = 0;
    cur_seg_off = 0xFFFFFFFFu;           /* пока не эмитили ни одного сегмента */
    g_ncells          = 0;
    g_ncells_at_space = 0;
    g_skip_flush      = 0;
    idx_active = 1;
}

/* Хвост прохода: финальный флаш ячеек + строка-обрыв (бывший финал index_lines). */
static void index_finish(void)
{
    idx_active = 0;
    /* emit_seg() флашит ячейки с отставанием на одну строку (см. её
     * комментарий) — последняя эмитированная строка файла так и остаётся
     * незафлашенной, её ячейки ещё в g_cells; коммитим здесь. */
    if (n_lines > 0) seg_commit_cells((uint16_t)(n_lines - 1));

    /* Лимит строк ловим ЗДЕСЬ, а не в emit_seg: главный цикл выходит по
     * `n_lines < max_lines` ДО того, как emit_seg будет вызван в переполненном
     * состоянии (для code-block — один emit_seg на строку, эта ветка не
     * достигается). Признак — остановились, а файл не кончился; g_abort и
     * исчерпание контента (TRUNC_CONTENT) уже обработаны/исключены. */
    if (!g_trunc_cause && !g_abort && idx_p < file_size) {
        g_trunc_cause = TRUNC_LINES;
        g_trunc_line  = (uint16_t)(max_lines - 1);
    }

    /* Файл был обрезан до 256 КБ при загрузке, а индексация дошла до конца
     * клампнутых данных без исчерпания контента/строк → ДОПИСЫВАЕМ строку
     * (место есть: n_lines < max_lines). Приоритет ниже content/lines —
     * те означают, что обрезали ещё раньше, их сообщение точнее. */
    if (!g_trunc_cause && !g_abort && g_file_clamped) {
        g_trunc_cause = TRUNC_FILE;
        g_trunc_line  = n_lines;
    }

    /* Обрыв по контенту/лимиту строк (не путать с пользовательским g_abort):
     * документ заканчиваем строкой-сообщением на g_trunc_line. Сообщение
     * рисуется вживую (IF_TRUNC_MSG) — место в контент-кэше ему не нужно, что
     * и снимает тупик «кэш полон, а сообщение тоже надо закэшировать». */
    if (g_trunc_cause && !g_abort) {
        cache_rec_t r;
        r.page = 0; r.off = 0; r.len = 0;
        r.flags = IF_TRUNC_MSG; r.reserved = g_trunc_cause;
        r.pad[0] = 0; r.pad[1] = 0;
        cache_dir_put(g_trunc_line, &r);
        n_lines = (uint16_t)(g_trunc_line + 1);   /* документ кончается на строке-обрыве */
    }
}

/* Одна итерация бывшего главного цикла index_lines(): классифицирует и
 * эмитит очередной блок исходника (параграф/строку кода/таблицу целиком).
 * do{}while(0) сохраняет исходные `continue` тела как выход из итерации
 * с записью локальной p обратно в idx_p. */
static void index_iter(void)
{
    if (g_loading)
        progress_tick();      /* первый экран + статус + скролл по готовым данным */
    else if (!g_bg_building && (n_lines & 15) == 0)
        spinner_tick();

    uint32_t p = idx_p;
    do {
        /* Пустая строка — разделитель параграфов.
         * В индекс добавляем не более одной пустой экранной строки подряд. */
        if (is_line_blank(p)) {
            p = next_line(p);
            /* Серия пустых строк схлопывается в одну визуальную строку. */
            if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) {
                emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0);
                set_cur_flags(IF_BLANK);
            }
            continue;
        }

        /* ---- fenced code-блок (граница или тело) ---- */
        if (is_fence_raw(p)) {
            idx_in_block = !idx_in_block;
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_cur_flags(IF_NOWRAP);
            p = next_line(p);
            continue;
        }
        if (idx_in_block) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_cur_flags(IF_NOWRAP | IF_CODE | IF_HSCROLL);   /* код скроллится целым блоком */
            {
                uint8_t  ccx = 0;
                uint32_t pp = p;
                while (pp < file_size && fb(pp) != '\n') {
                    char ch = fb(pp);
                    if (ch == '\t') {
                        uint8_t tgt = (uint8_t)((ccx & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
                        gc_fill(' ', ATTR_TEXT_CODE, (uint8_t)(tgt - ccx));
                        ccx = tgt;
                    } else if (ch == '\r') {
                        gc_put(' ', ATTR_TEXT_CODE); ccx++;   /* как render: \r → пробел */
                    } else {
                        gc_put(ch, ATTR_TEXT_CODE); ccx++;
                    }
                    pp++;
                }
            }
            p = next_line(p);
            continue;
        }

        /* ---- горизонтальный разделитель ---- */
        if (is_hr_raw(p)) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_cur_flags(IF_NOWRAP);
            gc_fill(0xC4, ATTR_HR, SCREEN_W);
            p = next_line(p);
            continue;
        }

        /* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */
        if (is_table_raw(p)) {
            /* Освобождаем g_cells под измерение ширин: коммитим pending-сегмент
             * сейчас; первый emit_seg таблицы пропустит свой lag-флаш. */
            if (n_lines >= 1) { seg_commit_cells((uint16_t)(n_lines - 1)); g_skip_flush = 1; }
            g_ncells = 0;

            /* Проход 1: границы блока + ОТРЕНДЕРЕННЫЕ ширины колонок (мерим тем же
             * inline_scan, что и при отрисовке — невидимые маркеры стиля не считаются). */
            uint8_t  widths[TBL_MAX_COLS];
            for (uint8_t i = 0; i < TBL_MAX_COLS; i++) widths[i] = 0;
            uint8_t  ncols = 0;
            uint32_t r = p;
            while (r < file_size && is_table_raw(r)) {
                uint32_t le  = row_end(r);
                uint8_t  sep = table_is_sep_row(r, le);
                uint32_t cp  = table_first_cell(r);
                uint32_t cs, ce;
                uint8_t  c = 0;
                while (c < TBL_MAX_COLS && table_next_cell(&cp, le, &cs, &ce)) {
                    if (!sep && ce > cs) {
                        g_ncells = 0;
                        (void)inline_scan(cs, ce, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1);
                        if (g_ncells > widths[c]) widths[c] = g_ncells;
                    }
                    c++;
                }
                if (c > ncols) ncols = c;
                r = (le < file_size) ? le + 1 : le;
            }
            g_ncells = 0;                       /* чисто для верхней рамки */
            uint32_t tbl_end = r;
            if (ncols == 0) ncols = 1;

            /* Проход 2: верхняя рамка → строки/разделитель → нижняя рамка. */
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_cur_flags(IF_NOWRAP | IF_HSCROLL);
            table_border(widths, ncols, TBL_TL, TBL_TM, TBL_TR);

            r = p;
            while (r < tbl_end) {
                uint32_t le = row_end(r);
                emit_seg(r, INIT_STYLE_PLAIN, CK_OTHER, 0);
                set_cur_flags(IF_NOWRAP | IF_HSCROLL);
                if (table_is_sep_row(r, le)) table_border(widths, ncols, TBL_ML, TBL_MM, TBL_MR);
                else                         table_data_row(r, le, widths, ncols);
                r = (le < file_size) ? le + 1 : le;
            }

            emit_seg(tbl_end, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_cur_flags(IF_NOWRAP | IF_HSCROLL);
            table_border(widths, ncols, TBL_BL, TBL_BM, TBL_BR);

            p = tbl_end;
            continue;
        }

        /* ---- классификация текущей строки ---- */
        uint32_t content_off = p;
        uint8_t kind = classify_line(p, &content_off);

        /* ---- Заголовок: отдельная строка, не склеивается с параграфом ---- */
        if (kind >= LK_H1 && kind <= LK_H4) {
            uint8_t hbase;                     /* title-attr, как в render */
            switch (kind) {
                case LK_H1: hbase = ATTR_TEXT_TITLE1; break;
                case LK_H2: hbase = ATTR_TEXT_TITLE2; break;
                case LK_H3: hbase = ATTR_TEXT_TITLE3; break;
                default:    hbase = ATTR_TEXT_TITLE4; break;
            }
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            uint32_t line_end = row_end(content_off);
            (void)inline_scan(content_off, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN, hbase, 0);
            p = next_line(p);
            continue;
        }

        /* ---- Пункт списка: может занимать несколько физических строк ---- */
        if (kind == LK_ULIST || kind == LK_OLIST) {
            uint8_t  col = marker_visible_col(kind, p, content_off);
            uint8_t  line_style = INIT_STYLE_PLAIN;
            uint32_t q = content_off;

            emit_seg(p, line_style, CK_LIST, 0);
            /* Префикс ПЕРВОГО сегмента: отступ + маркер + пробел (как render). */
            {
                uint32_t qq = p, qmax = p + SCREEN_W;
                if (kind == LK_ULIST) {
                    while (qq + 2 < content_off && qq < qmax && fb(qq) == ' ') qq++;
                    gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
                    gc_put(0x07, ATTR_LIST_MARKER);
                    gc_put(' ', ATTR_TEXT);
                } else {
                    while (qq < content_off && qq < qmax && fb(qq) == ' ') qq++;
                    gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
                    while (qq < content_off - 1) { gc_put(fb(qq), ATTR_LIST_MARKER); qq++; }
                    gc_put(' ', ATTR_TEXT);
                }
            }
            q = scan_join_stream(q, p, col, CK_LIST, &line_style, JOIN_MODE_LIST);

            /* Переходим к началу следующей физической строки после пункта. */
            q = next_line(q);

            /* Одна пустая строка между соседними маркерами списка подавляется.
             * Две и более пустых строки оставляют видимый разделитель. */
            if (q < file_size && is_line_blank(q)) {
                uint32_t t = q;
                uint8_t blanks = 0;
                while (t < file_size && is_line_blank(t)) {
                    t = next_line(t);
                    blanks++;
                    // if (blanks >= 2) break;
                }
                if (blanks == 1 && t < file_size) {
                    uint32_t d = t;
                    uint8_t nk = classify_line(t, &d);
                    if (nk == LK_ULIST || nk == LK_OLIST) {
                        /* #3: подавляем blank только если следующий пункт того же
                         * уровня или глубже; при МЕНЬШЕМ отступе (dedent) пустую
                         * строку оставляем — пункты должны визуально разделиться. */
                        if (leading_spaces(t) >= leading_spaces(p)) {
                            p = t;         /* подавляем единственную пустую строку */
                            continue;
                        }
                    }
                }
            }

            p = q;
            continue;
        }

        /* ---- Параграф цитаты: может покрывать несколько физических строк ---- */
        if (kind == LK_QUOTE) {
            uint8_t  col = marker_visible_col(kind, p, content_off);
            uint8_t  line_style = INIT_STYLE_PLAIN;
            uint32_t q = content_off;

            emit_seg(p, line_style, CK_QUOTE, 0);
            /* Префикс ПЕРВОГО сегмента: отступ + маркер 0xB3 + пробел (как render LK_QUOTE). */
            {
                uint32_t qq = p, qmax = p + SCREEN_W;
                while (qq + 1 < content_off && qq < qmax && fb(qq) == ' ') qq++;
                gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
                gc_put(0xB3, ATTR_QUOTE_MARKER);
                gc_put(' ', ATTR_TEXT);
            }
            q = scan_join_stream(q, p, col, CK_QUOTE, &line_style, JOIN_MODE_QUOTE);

            p = next_line(q);
            continue;
        }

        /* ---- Обычный текстовый параграф: склейка через soft-break ---- */
        {
            uint8_t line_style = INIT_STYLE_PLAIN;
            /* #4: если абзац начинается с отступа — все его перенесённые строки
             * получают такой же отступ (как continuation-префикс списка, но без
             * маркера). Контент сканируем после отступа. Патологически большой
             * отступ игнорируем. */
            uint8_t indent = leading_spaces(p);
            if (indent >= SCREEN_W / 2) indent = 0;

            emit_seg(p, line_style, CK_PLAIN, 0);
            if (indent) gc_fill(' ', ATTR_TEXT, indent);   /* префикс первого сегмента */

            uint32_t q = p + indent;
            q = scan_join_stream(q, p, indent, CK_PLAIN, &line_style, JOIN_MODE_PLAIN);

            if (q >= file_size) {
                p = file_size;
            } else {
                /* q стоит на завершающем newline параграфа.
                 * Переходим на следующую строку, чтобы внешняя петля её
                 * корректно переклассифицировала. */
                p = next_line(q);
            }
        }
    } while (0);
    idx_p = p;
}

/* Выполняет до budget итераций индексации. Возврат: 1 — проход завершён
 * (индекс финализирован index_finish), 0 — есть ещё работа.
 * Конвертация UTF-8 держится впереди позиции чтения ЗДЕСЬ, до проверки
 * условия останова: пока исходник не сконвертирован до конца, idx_p не
 * может «догнать» file_size и оборвать проход преждевременно. */
uint8_t index_step(uint8_t budget)
{
    if (!idx_active) return 1;
    while (budget-- > 0) {
        /* UTF-8: держим конвертацию впереди позиции чтения (включая look-ahead
         * склейки/классификации ~ один абзац). file_size при этом = доступная
         * сконвертированная часть и растёт по ходу. */
        if (g_utf_building && (uint32_t)(idx_p + CONV_MARGIN) > file_size)
            utf_convert_more((uint32_t)(idx_p + CONV_MARGIN));

        if (!(idx_p < file_size && n_lines < max_lines && !g_abort && !g_trunc_cause)) {
            index_finish();
            return 1;
        }
        index_iter();
    }
    return 0;
}
#define EM_NONE   0
#define EM_BOLD   1
#define EM_ITALIC 2
#define EM_UNDER  3
#define EM_CODE   4
#define EM_STRIKE 5

/* Преобразует внутреннее состояние emphasis в экранный атрибут. */
static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr)
{
    switch (emph) {
        case EM_BOLD:   return ATTR_TEXT_BOLD;
        case EM_ITALIC: return ATTR_TEXT_ITALIC;
        case EM_UNDER:  return ATTR_TEXT_UNDERSORE;
        case EM_CODE:   return ATTR_TEXT_CODE;
        case EM_STRIKE: return ATTR_TEXT_STRIKE;
        default:        return base_attr;
    }
}
