File size: 50,882 Bytes
9a08963
5f80ef1
 
9a08963
 
 
 
 
 
 
e89951a
 
 
 
 
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
0b65b92
 
9a08963
 
 
 
 
 
 
 
e89951a
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d3384a
 
 
 
 
 
 
 
 
e89951a
3d3384a
 
 
 
 
 
 
 
 
 
 
 
 
9a08963
 
 
e9b6d6a
 
 
9a08963
 
 
 
 
 
 
 
 
62c1832
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
0b65b92
9a08963
 
 
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
 
 
 
693519a
76aeacc
 
 
 
d926362
 
76aeacc
d926362
281f95c
 
693519a
 
3d3384a
 
693519a
3d3384a
281f95c
62c1832
3d3384a
 
281f95c
62c1832
 
281f95c
 
 
 
 
 
 
 
 
 
693519a
 
 
 
62c1832
 
 
 
 
 
 
 
d59ebea
 
 
 
 
 
 
 
 
3d3384a
 
d59ebea
3d3384a
d59ebea
 
3d3384a
 
d59ebea
62c1832
d59ebea
 
 
 
 
 
 
 
 
 
 
 
 
cc503f9
0b65b92
cc503f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
 
 
 
9a08963
 
 
 
 
 
 
 
 
693519a
 
 
 
d59ebea
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
 
9a08963
 
 
 
0b65b92
 
9d7cca0
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
 
 
 
 
 
 
 
 
 
 
693519a
 
 
 
d59ebea
 
 
 
 
cc503f9
 
 
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
 
 
0b65b92
9a08963
 
0b65b92
9a08963
 
 
 
 
 
 
 
 
0b65b92
1190087
 
 
9a08963
1190087
 
 
 
 
 
 
 
 
 
9a08963
 
 
 
 
 
 
 
 
 
 
 
 
 
9d7cca0
 
 
 
 
 
9a08963
 
00b6f48
339b914
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d7cca0
 
 
339b914
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62c1832
 
 
 
339b914
 
 
 
 
 
 
 
 
e89951a
339b914
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a68db4
 
 
 
 
 
339b914
d59ebea
 
 
 
339b914
 
 
 
 
 
 
 
 
 
aa3af88
 
 
 
 
 
 
 
339b914
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d7cca0
 
 
 
 
 
 
 
 
 
 
 
1190087
 
 
 
 
 
9d7cca0
 
1190087
9d7cca0
1190087
 
 
9d7cca0
 
 
1190087
 
339b914
 
 
1190087
 
 
 
00b6f48
1190087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00b6f48
1190087
 
 
00b6f48
 
 
1190087
00b6f48
 
 
1190087
00b6f48
 
 
 
 
 
f40e9a4
1190087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2939738
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1190087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1dfad36
 
 
1190087
 
 
 
2939738
1190087
 
f40e9a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
"""
lumen_formatting.py — markdown Lumen в Telegram HTML.
Вынесен из bot.py: чистые функции над строками без I/O и состояния.
"""

from __future__ import annotations

import re

_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
# Регекс квадратичен на длинных пробельных хвостах: строку режем до match,
# разделитель шире капа в живых сообщениях не встречается.
_TABLE_SEP_MAX_LEN = 500


def _is_table_separator(line: str) -> bool:
    """Строка-разделитель markdown-таблицы с bounded match."""
    return _TABLE_SEP_RE.match(line[:_TABLE_SEP_MAX_LEN]) is not None

def _split_table_cells(line: str) -> list[str]:
    s = line.strip()
    if s.startswith("|"):
        s = s[1:]
    if s.endswith("|"):
        s = s[:-1]
    return [c.strip() for c in s.split("|")]

def _convert_markdown_tables_to_lists(text: str) -> str:
    """Telegram не рендерит таблицы: блок "заголовок + дефисы + строки" разворачиваем
    в "• Заголовок: значение". Rich-путь шлёт настоящий <table>."""
    if "|" not in text or "-" not in text:
        return text
    lines = text.split("\n")
    out: list[str] = []
    i = 0
    n = len(lines)
    while i < n:
        line = lines[i]
        if "|" in line and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]):
            header_cells = _split_table_cells(line)
            if len(header_cells) >= 2:
                data_rows = []
                j = i + 2
                while j < n and "|" in lines[j] and lines[j].strip():
                    data_rows.append(_split_table_cells(lines[j]))
                    j += 1
                if data_rows:
                    for row in data_rows:
                        parts = []
                        for h_idx, header in enumerate(header_cells):
                            val = row[h_idx] if h_idx < len(row) else ""
                            if not val:
                                continue
                            parts.append(f"**{header}:** {val}" if header else val)
                        if parts:
                            out.append("• " + "; ".join(parts))
                    i = j
                    continue
        out.append(line)
        i += 1
    return "\n".join(out)

def _table_block_line_indexes(lines: list[str]) -> set[int]:
    """Индексы строк markdown-таблиц (шапка + разделитель + тело) — теми же
    условиями, что _convert_markdown_tables_to_lists выше. Разносчикам списков
    эти строки трогать нельзя: в обычном пути таблицы разворачиваются ПОСЛЕ
    разноса, и строка без ведущего `|` (валидная таблица без внешних пайпов)
    иначе рвалась до детекта таблицы — стрим и финал расходились."""
    idx: set[int] = set()
    i, n = 0, len(lines)
    while i < n:
        if "|" in lines[i] and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]):
            if len(_split_table_cells(lines[i])) >= 2:
                j = i + 2
                data = 0
                while j < n and "|" in lines[j] and lines[j].strip():
                    data += 1
                    j += 1
                if data:
                    idx.update(range(i, j))
                    i = j
                    continue
        i += 1
    return idx

# ── Защитная сетка от сырого LaTeX ──────────────────────────────────────────
# Реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free выдала
# "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода в ответе про
# площадь круга. Промпт разрешает LaTeX для rich-пути (<tg-math>), а это —
# второй рубеж для обычного пути: не полагаемся только на послушание модели,
# страхуем детерминированной пост-обработкой в юникод.
_LATEX_SUPERSCRIPT_MAP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ"}
_LATEX_SUBSCRIPT_MAP = {"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉"}
# Порядок важен: многобуквенные команды (\times, \infty...) должны замениться
# ДО одиночного \t/\i и т.п., иначе оставшийся общий "\команда -> без бэкслеша"
# в конце срежет их раньше времени. dict сохраняет порядок вставки в Python 3.7+.
_LATEX_SYMBOL_MAP: dict[str, str] = {
    r"\times": "×", r"\cdot": "·", r"\approx": "≈", r"\infty": "∞",
    r"\leq": "≤", r"\le": "≤", r"\geq": "≥", r"\ge": "≥", r"\neq": "≠", r"\ne": "≠",
    r"\rightarrow": "→", r"\Rightarrow": "⇒", r"\to": "→",
    r"\forall": "∀", r"\exists": "∃", r"\emptyset": "∅", r"\cup": "∪", r"\cap": "∩",
    r"\int": "∫",  # ДО \in: иначе "\int" съедался как "\in" и давал "∈t" (враждебное ревью 27.09.2026)
    r"\in": "∈",
    r"\pi": "π", r"\pm": "±", r"\mp": "∓", r"\sum": "∑", r"\prod": "∏",
    r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\Gamma": "Γ", r"\theta": "θ",
    r"\lambda": "λ", r"\mu": "μ", r"\sigma": "σ", r"\Sigma": "Σ", r"\delta": "δ", r"\Delta": "Δ",
    r"\phi": "φ", r"\omega": "ω", r"\Omega": "Ω",
}

def _latex_superscript(m: re.Match) -> str:
    return "".join(_LATEX_SUPERSCRIPT_MAP.get(ch, ch) for ch in m.group(1))

def _latex_subscript(m: re.Match) -> str:
    return "".join(_LATEX_SUBSCRIPT_MAP.get(ch, ch) for ch in m.group(1))

def _scrub_latex(text: str) -> str:
    """Сырой LaTeX в юникод. Вызывать только после вырезки кода, иначе портятся слэши в regex/путях Windows."""
    if "\\" not in text and "$" not in text:
        return text
    # Снимаем только $$/\[..\]/\(..\): одиночный $ не трогаем, иначе сумма "$100" спарится с формулой и испортит оба.
    text = re.sub(r"\\\[(.*?)\\\]", r"\1", text, flags=re.DOTALL)
    text = re.sub(r"\\\((.*?)\\\)", r"\1", text, flags=re.DOTALL)
    text = re.sub(r"\$\$(.*?)\$\$", r"\1", text, flags=re.DOTALL)
    # \frac{a}{b} -> a/b (одноуровневая вложенность, самый частый случай)
    text = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text)
    # \sqrt{x} -> √x, \sqrt[n]{x} -> ⁿ√x
    text = re.sub(r"\\sqrt\[([^\]]*)\]\{([^{}]*)\}", r"\1√\2", text)
    text = re.sub(r"\\sqrt\{([^{}]*)\}", r"√\1", text)
    for cmd, repl in _LATEX_SYMBOL_MAP.items():
        text = text.replace(cmd, repl)
    # x^{2} / x^2 -> x², x_{2} / x_2 -> x₂ — только короткие индексы/степени,
    # чтобы случайно не тронуть код вида a^b в языках, где это не степень.
    # Осознанный компромисс: голый x^2 вне кода считаем степенью, редкий XOR в прозе без бэктиков сломается.
    text = re.sub(r"\^\{([0-9n+\-]{1,3})\}", _latex_superscript, text)
    text = re.sub(r"\^([0-9n])(?![0-9])", _latex_superscript, text)
    text = re.sub(r"_\{([0-9]{1,3})\}", _latex_subscript, text)
    text = re.sub(r"_([0-9])(?![0-9])", _latex_subscript, text)
    # Оставшиеся одиночные \command без известного юникод-эквивалента — просто
    # снимаем бэкслеш, чтобы пользователь не видел сырое "\int"/"\mathbb" и т.п.
    text = re.sub(r"\\([a-zA-Z]+)", r"\1", text)
    return text

# ── Маркеры списков "- текст" / "* текст" в начале строки → "• текст" ───────
# Маркеры "-/* " в начале строки в "• ": иначе литеральные "-" видны в Telegram и "*" ломает italic дальше.
_BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE)

def _normalize_bullet_markers(text: str) -> str:
    return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text)

# Слипшиеся в один абзац буллеты ("• A ... • B ... • C", прод 22.09.2026: модель
# написала весь список сравнения в одну строку) — разносим по строкам. Порог —
# 2+ разделителя (т.е. уже 3 пункта: прод 26.09.2026 показал, что ровно
# трёхпунктовые списки — самый частый случай, а с порогом 3 они не ловились).
# Короткие "чай • кофе" (1 разделитель) и короткие абзацы — обычная проза, не трогаем.
# Прод 07.10.2026: строки "Telegram: • ..." (~140 символов) не ловились, порог 200→120.
# Сепаратор с необязательными пробелами ловит склейку без пробелов (прод 05.10.2026).
_INLINE_BULLETS_MIN_SEPS = 2
_INLINE_BULLETS_MIN_LEN = 120
_INLINE_BULLET_SEP_RE = re.compile(r"\s*[•·]\s*")
_INLINE_BULLET_START_RE = re.compile(r"\s*[•·]")

def _split_inline_bullets(text: str) -> str:
    lines = text.split("\n")
    skip = _table_block_line_indexes(lines)
    out = []
    for idx, line in enumerate(lines):
        chunks: list[str] = []
        if (
            idx not in skip
            and len(line) >= _INLINE_BULLETS_MIN_LEN
            and len(_INLINE_BULLET_SEP_RE.findall(line)) >= _INLINE_BULLETS_MIN_SEPS
            and not _is_structural_line(line)
        ):
            chunks = [c.strip() for c in _INLINE_BULLET_SEP_RE.split(line)]
            chunks = [c for c in chunks if c]
        if len(chunks) >= 2:
            if _INLINE_BULLET_START_RE.match(line):
                # Строка начинается с маркера — вводной фразы нет, все куски пункты.
                out.extend("• " + chunk for chunk in chunks)
            else:
                # Первый кусок — вводная фраза ("Вот моменты:"), дальше — пункты.
                out.append(chunks[0])
                out.extend("• " + chunk for chunk in chunks[1:])
        else:
            out.append(line)
    return "\n".join(out)

# Строки служебной разметки: разносчик не должен ни есть заголовок, ни вытаскивать
# пункты из цитаты, ни рвать строку markdown-таблицы (враждебное ревью 27.09.2026).
# Проверка нужна в ОБОИХ разносчиках и в обоих путях рендера.
_STRUCT_LINE_RE = re.compile(r"^\s*(?:#{1,6}\s|>|\|)")

def _is_structural_line(line: str) -> bool:
    return bool(_STRUCT_LINE_RE.match(line))

# Слипшиеся в один абзац нумерованные пункты ("1. ... 2. ... 3. ...", прод
# 25.09.2026: модель написала все 3 причины голубого неба одной строкой) —
# разносим по строкам. Строго: последовательность с 1, 3+ пункта, каждый
# содержательный — иначе дробим прозу вида "смотри пункты 1. и 2. ниже".
_INLINE_NUMBERED_MIN_ITEMS = 3
_INLINE_NUMBERED_MIN_ITEM_LEN = 12
_NUMBERED_MARKER_RE = re.compile(r"(?<!\d)(\d{1,3})\. ")

def _split_inline_numbered(text: str) -> str:
    lines = text.split("\n")
    skip = _table_block_line_indexes(lines)
    out = []
    for idx, line in enumerate(lines):
        markers = [(m.start(), int(m.group(1))) for m in _NUMBERED_MARKER_RE.finditer(line)]
        if (
            idx not in skip
            and len(markers) >= _INLINE_NUMBERED_MIN_ITEMS
            and [num for _, num in markers] == list(range(1, len(markers) + 1))
            and not _is_structural_line(line)
        ):
            bounds = [pos for pos, _ in markers] + [len(line)]
            items = [line[bounds[i]:bounds[i + 1]].strip() for i in range(len(markers))]
            if all(len(it) >= _INLINE_NUMBERED_MIN_ITEM_LEN for it in items):
                # Вводная фраза до "1." ("Причины:") — отдельной строкой, как у буллетов.
                head = line[:bounds[0]].rstrip()
                if head:
                    out.append(head)
                out.extend(items)
                continue
        out.append(line)
    return "\n".join(out)

# ── Markdown-заголовки "#"/"##"/"###" → **жирный текст** ────────────────────
# Регрессия 18.08.2026: модели пишут ### вопреки промпту. Режем ATX по CommonMark (решётки + пробел), C#/#tag не трогаем.
_HEADER_MARKER_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+(.*)$", re.MULTILINE)

def _normalize_headers(text: str) -> str:
    def _repl(m: re.Match) -> str:
        content = m.group(1).rstrip()
        if not content:
            # Голая строка из одних "#" без текста — нечего выделять жирным,
            # просто убираем маркер целиком, а не оставляем пустую "****".
            return ""
        if content.startswith("**") and content.endswith("**") and len(content) > 4:
            # Модель сама уже обернула текст заголовка в **bold** (нередкий
            # случай — "### **Важно**") — оборачивать ЕЩЁ раз дало бы "****Важно****"
            # и сломало бы парность звёздочек в Phase 3 ниже. Раз обёртка уже
            # есть, только снимаем сам маркер "#", остальное не трогаем.
            return content
        return f"**{content}**"
    return _HEADER_MARKER_RE.sub(_repl, text)

# ── Markdown-цитаты "> текст" → Telegram <blockquote> ───────────────────────
# Тот же принцип, что и у _normalize_bullet_markers выше: "> " — обычный
# GFM-синтаксис цитаты, модели он известен без единого слова в system_prompt.py
# (там про цитаты вообще ничего не сказано — как и про списки, см. комментарий
# у _normalize_bullet_markers). Раньше строка "> текст" просто уходила в
# Telegram буквально с ">" в начале. Строится ДО HTML-экранирования (Phase 2),
# как и остальные построчные нормализации этой секции — сама обёртка
# <blockquote> добавляется тут же, а не как markdown-маркер для Phase 3, чтобы
# не путать её с обычным ">" внутри текста (например, "5 > 3").
_BLOCKQUOTE_LINE_RE = re.compile(r"^> ?(.*)$")
# \x00-сентинелы вместо буквальных <blockquote>/</blockquote> — та же причина,
# что и у плейсхолдеров код-блоков в _md_to_html (см. Phase 1 там): если вставить
# реальный HTML-тег здесь, Phase 2 (HTML-escape) его же и экранирует. Сентинелы
# невидимы для escape (тот трогает только &/</>) и заменяются на настоящие теги
# уже ПОСЛЕ Phase 3 — так текст внутри цитаты всё ещё проходит обычные
# escape/markdown-фазы (например, "> **важно**" корректно станет цитатой с
# жирным текстом внутри), меняется только сама обёртка.
_BLOCKQUOTE_START = "\x00BQS\x00"
_BLOCKQUOTE_END = "\x00BQE\x00"

def _convert_blockquotes(text: str) -> str:
    lines = text.split("\n")
    out: list[str] = []
    quote_buf: list[str] = []

    def _flush():
        if quote_buf:
            out.append(_BLOCKQUOTE_START + "\n".join(quote_buf) + _BLOCKQUOTE_END)
            quote_buf.clear()

    for line in lines:
        m = _BLOCKQUOTE_LINE_RE.match(line)
        if m:
            quote_buf.append(m.group(1))
        else:
            _flush()
            out.append(line)
    _flush()
    return "\n".join(out)

def _md_to_html(text: str) -> str:
    """Конвертирует markdown-подобный текст в Telegram HTML.

    Контракт: независимые regex-проходы, порядок критичен. Не переписывать в парсер:
    ~20 тестов ловят межфазовые конфликты на прод-трафике.

    Обязательный порядок фаз (нарушение порядка ломает уже отлаженные edge-case'ы):
      0. Нормализация сырых HTML-тегов (<b>/<i>/<code>/<pre> и битые self-closing) в
         markdown-эквивалент — ДО экранирования (шаг 2), иначе легитимные теги от
         модели превратились бы в видимый мусор "&lt;b&gt;".
      1. Код-блоки/спаны (```...```/`...`) вырезаются и заменяются плейсхолдерами —
         ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
         реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
      1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
       1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
            чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
            и не была принята за маркер списка.
       1.405. Слипшиеся "• A • B" в одном абзаце → по строкам (_split_inline_bullets).
       1.406. Слипшиеся "1. A 2. B 3. C" в одном абзаце → по строкам (_split_inline_numbered).
      1.45. Markdown-цитаты "> " → сентинелы \x00BQS\x00/\x00BQE\x00 (_convert_
            blockquotes) — сентинелы, не сразу <blockquote>, т.к. Phase 2 экранировал
            бы буквальный тег; настоящий тег подставляется после Phase 3 (см. ниже).
      1.5. Markdown-таблицы → список пунктов (_convert_markdown_tables_to_lists) —
           код и списки уже обработаны/вырезаны шагами 1/1.4.
      2. HTML-экранирование остального текста (&/</>).
      3. Markdown (**bold**/*italic*/~~strike~~/[текст](url)) → HTML-теги — ПОСЛЕ
         экранирования, иначе символы разметки сами могли бы быть экранированы
         раньше времени. Ссылки [текст](url) — последними в этой фазе (после bold/
         italic), чтобы regex-проходы italic/bold не залезли внутрь href, если URL
         содержит "_" (см. комментарий в коде).
      3.5. Сентинелы цитаты (шаг 1.45) → настоящий <blockquote> — после Phase 3,
           чтобы markdown внутри цитаты успел стать HTML до финализации обёртки.
       4. Код-блоки/спаны восстанавливаются из плейсхолдеров с собственным
          экранированием — самыми последними, чтобы шаги 2-3 их не затронули.
    """
    if not text:
        return ""

    # ── Phase 0: сырой HTML модели в markdown до escape, иначе "&lt;b&gt;" видно.
    # <br> в сентинел, <blockquote> не трогаем.
    text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
    text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
    text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    # spoiler — тот же трюк, что и <u> выше: system_prompt.py не просит модель их
    # использовать, поэтому это чисто защитная сетка на случай, если модель всё же
    # напишет буквальный тег. Раньше он не ловился здесь вообще и долетал до Phase 2
    # экранирования — показывался пользователю как видимый мусор "&lt;tg-spoiler&gt;".
    text = re.sub(r"<tg-spoiler>(.*?)</tg-spoiler>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r'<span\s+class=["\']tg-spoiler["\']>(.*?)</span>', r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<code>(.*?)</code>", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL)

    # ── Фаза 1: вырезаем код до обработки ────────────────
    _saved: dict[str, str] = {}
    _counter = [0]

    def _save_block(m: re.Match) -> str:
        key = f"\x00CB{_counter[0]}\x00"
        _counter[0] += 1
        _saved[key] = m.group(0)
        return key

    text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", _save_block, text, flags=re.DOTALL)
    text = re.sub(r"`[^`\n]+`", _save_block, text)
    # Ссылки вырезаем как код: "_" в URL ломает italic/bold. Markdown внутри label не поддерживаем.
    text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", _save_block, text)

    # ── Phase 1.3: сырой LaTeX → юникод (см. _scrub_latex выше) — код уже
    # вынесен на предыдущем шаге, поэтому обратные слэши в реальном коде
    # (regex, пути Windows и т.п.) не затрагиваются.
    text = _scrub_latex(text)

    # ── Phase 1.4: маркеры списков "- "/"* " → "• " (см. _normalize_bullet_
    # markers выше) — ДО таблиц и ДО Phase 3, чтобы не путаться с "**bold**" и
    # чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой.
    text = _normalize_bullet_markers(text)

    # ── Phase 1.405: слипшиеся "• A • B" в одном абзаце → по строкам (см.
    # _split_inline_bullets выше) — после нормализации маркеров, до таблиц/escape.
    text = _split_inline_bullets(text)

    # ── Phase 1.406: слипшиеся "1. A 2. B 3. C" в одном абзаце → по строкам
    # (см. _split_inline_numbered выше) — код уже вынесен шагом 1, нумерация
    # версий/дат ("3.5", "1995.") под правило не попадает (нужна цепочка с 1).
    text = _split_inline_numbered(text)

    # ── Phase 1.41: markdown-заголовки "#"/"##"/"###" → **жирный текст** (см.
    # _normalize_headers выше) — после списков, до HTML-экранирования и до Phase 3
    # (получившийся "**...**" обрабатывается обычным bold-регэкспом на общих
    # основаниях, отдельная ветка не нужна).
    text = _normalize_headers(text)

    # ── Phase 1.45: markdown-цитаты "> " → сентинелы blockquote (см.
    # _convert_blockquotes выше) — ДО HTML-экранирования, т.к. решение "это
    # строка цитаты" принимается по буквальному "> " в начале строки; сама
    # обёртка <blockquote> подставляется позже (после Phase 3), сентинелы же
    # (\x00BQS\x00/\x00BQE\x00) escape в Phase 2 не трогает.
    text = _convert_blockquotes(text)

    # ── Phase 1.5: markdown-таблицы → список пунктов (см. _convert_markdown_
    # tables_to_lists выше) — код уже вынесен на предыдущем шаге, поэтому "|"
    # внутри кода (например, битовое ИЛИ в Rust/C) сюда не попадёт.
    text = _convert_markdown_tables_to_lists(text)

    # ── Фаза 2: экранируем остальное в HTML ────────────────────────────────────────
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    # ── Фаза 3: применяем markdown ───────────────────────────────────────────────
    text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
    text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
    text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)

    # ── Phase 3.5: blockquote-сентинелы → настоящие <blockquote> ─────────────
    # После Phase 3, чтобы markdown внутри цитаты (например "> **важно**")
    # успел превратиться в HTML до того, как обёртка станет реальным тегом.
    text = text.replace(_BLOCKQUOTE_START, "<blockquote>").replace(_BLOCKQUOTE_END, "</blockquote>")

    # ── Фаза 4: возвращаем код с экранированием ────────────────────
    # Ссылки первыми: метка ссылки может содержать плейсхолдер кода
    # ([`code` text](url) — код вырезается раньше ссылки), поздняя замена кода
    # находит его уже внутри вставленного <a> и протечки \x00CB0\x00 нет.
    for key, orig in _saved.items():
        if not orig.startswith("["):
            continue
        m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
        label, url = m.group(1), m.group(2)
        label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
        text = text.replace(key, f'<a href="{url}">{label}</a>')
    for key, orig in _saved.items():
        if orig.startswith("["):
            continue
        if orig.startswith("```"):
            m = re.match(r"```([a-zA-Z0-9]*)\n(.*)\n```", orig, re.DOTALL)
            lang, inner = (m.group(1), m.group(2)) if m else ("", orig[3:-3])
            inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            # language — атрибут entity "pre" в Telegram (даёт подсветку синтаксиса
            # в клиентах, которые её поддерживают); раньше язык из ```python вырезался
            # регэкспом при сохранении, но никогда не доходил до вывода.
            replacement = f'<pre><code class="language-{lang}">{inner}</code></pre>' if lang else f"<pre>{inner}</pre>"
        else:
            inner = orig[1:-1]
            inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            replacement = f"<code>{inner}</code>"
        text = text.replace(key, replacement)

    # Сентинел <br> (см. Phase 0): в обычном HTML-пути — перенос строки.
    # Оговорка: <br> ВНУТРИ код-блоков тоже превратится в перенос (Phase 0
    # идёт до экстракции кода) — тот же класс компромисса, что уже есть у
    # <b>/<i> в коде выше; код-примеры с буквальным <br> редки.
    text = text.replace("\x00BR\x00", "\n")

    return text


def _rich_inline(text: str) -> str:
    """Инлайн-разметка для внутренностей рич-блоков (заголовков, ячеек таблиц):
    тот же escape + **bold**/*italic*/~~strike~~, что Phase 2–3 в _md_to_html.
    Плейсхолдеры кода/ссылок (символы \x00) намеренно не трогаются — они
    восстанавливаются позже общим финалом, как и в _md_to_html."""
    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
    text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
    text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
    return text


def _build_rich_table(header: list[str], rows: list[list[str]]) -> str:
    """Строит <table bordered> из распарсенных ячеек (см. _split_table_cells).
    Ячейки идут через _rich_inline — внутри работают **bold**/*italic*/`код`
    (кодовые плейсхолдеры раскрываются общим финалом позже)."""
    parts = ["<table bordered>"]
    parts.append("<tr>" + "".join(f"<th>{_rich_inline(h)}</th>" for h in header) + "</tr>")
    for row in rows:
        cells = [row[i] if i < len(row) else "" for i in range(len(header))]
        parts.append("<tr>" + "".join(f"<td>{_rich_inline(c)}</td>" for c in cells) + "</tr>")
    parts.append("</table>")
    return "".join(parts)


def _md_to_rich_html(text: str) -> str:
    """Вариант _md_to_html для Rich Messages (Bot API 10.1+, sendRichMessage):
    сервер Telegram сам рендерит структурные блоки, поэтому здесь НЕ действуют
    три запрета обычного пути — markdown-таблицы идут настоящим <table>,
    #-заголовки — <h2>/<h3>/<h4>, а LaTeX ($...$/$$...$$) — сырым текстом в
    <tg-math>/<tg-math-block> (НЕ юникод-заменой, как в _md_to_html).
    Остальное 1:1 как в _md_to_html: тот же Phase 0 (сырой HTML модели),
    те же плейсхолдеры кода/ссылок, те же цитаты, списки и escape, тот же
    порядок восстановления (код — последним). Держать в синхроне с _md_to_html
    при правках escape/Phase 0: расхождение даст разный рендер стрима (HTML)
    и финала (rich) одного и того же ответа.
    Обычный (не-rich) путь НЕ тронут: стрим-правки идут через _md_to_html,
    а sendRichMessage при ошибке API откатывается на него же (см. bot.py)."""
    if not text:
        return ""

    # Тот же сентинел <br>, что в _md_to_html выше, но раскрывается в <br/>
    # (валидный рич-тег переноса, в т.ч. внутри ячеек <td>).
    text = re.sub(r"<br\s*/?>", "\x00BR\x00", text, flags=re.IGNORECASE)
    text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
    text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<tg-spoiler>(.*?)</tg-spoiler>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r'<span\s+class=["\']tg-spoiler["\']>(.*?)</span>', r"\1", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
    text = re.sub(r"<code>(.*?)</code>", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL)

    _code: dict[str, str] = {}
    _links: dict[str, str] = {}
    _math: dict[str, tuple[str, bool]] = {}
    _tables: dict[str, tuple[list[str], list[list[str]]]] = {}
    _heads: dict[str, tuple[int, str]] = {}
    _counter = [0]

    def _take(store: dict, prefix: str, value) -> str:
        key = f"\x00{prefix}{_counter[0]}\x00"
        _counter[0] += 1
        store[key] = value
        return key

    text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", lambda m: _take(_code, "CB", m.group(0)), text, flags=re.DOTALL)
    text = re.sub(r"`[^`\n]+`", lambda m: _take(_code, "CB", m.group(0)), text)
    text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", lambda m: _take(_links, "CB", m.group(0)), text)
    text = re.sub(r"\$\$([\s\S]+?)\$\$", lambda m: _take(_math, "MM", (m.group(1), True)), text)
    # Инлайн-форма требует содержимого без пробелов по краям (как в GFM): иначе
    # цены вида "$50 до $100" превратились бы в "формулу". Одиночные "$80 000"
    # без закрывающего знака и так не матчатся.
    text = re.sub(r"(?<!\$)\$(?!\$)([^\s$][^$\n]*?)(?<!\s)\$(?!\$)", lambda m: _take(_math, "MM", (m.group(1), False)), text)
    # Скобочные формы LaTeX (реальный кейс nemotron — \[ S = \pi r^{2} \]): тот же
    # смысл, что $/$$ выше, извлекаются раньше них, чтобы $ внутри не разобрали.
    text = re.sub(r"\\\[([\s\S]+?)\\\]", lambda m: _take(_math, "MM", (m.group(1), True)), text)
    text = re.sub(r"\\\((.+?)\\\)", lambda m: _take(_math, "MM", (m.group(1), False)), text)

    def _take_heading(m: re.Match) -> str:
        # Тот же допуск, что у легаси _HEADER_MARKER_RE (ведущие пробелы, до 6
        # решёток); масштаб под чат: # → h2, ## → h3, остальное → h4.
        level = len(m.group(1))
        tag_level = 2 if level == 1 else (3 if level == 2 else 4)
        # Остаточный LaTeX в заголовке скрабим сразу: заголовок уходит в плейсхолдер
        # ДО общего _scrub_latex ниже, и юзер видел "\alpha" в <h3> вместо α
        # (враждебное ревью 27.09.2026).
        return _take(_heads, "HD", (tag_level, _scrub_latex(m.group(2).strip())))

    text = re.sub(r"^[ \t]*(#{1,6})[ \t]+(.+)$", _take_heading, text, flags=re.MULTILINE)

    lines = text.split("\n")
    out: list[str] = []
    i = 0
    n = len(lines)
    while i < n:
        line = lines[i]
        if "|" in line and i + 1 < n and "-" in lines[i + 1] and _is_table_separator(lines[i + 1]):
            header_cells = _split_table_cells(line)
            if len(header_cells) >= 2:
                data_rows: list[list[str]] = []
                j = i + 2
                while j < n and "|" in lines[j] and lines[j].strip():
                    data_rows.append(_split_table_cells(lines[j]))
                    j += 1
                if data_rows:
                    out.append(_take(_tables, "TB", (header_cells, data_rows)))
                    i = j
                    continue
        out.append(line)
        i += 1
    text = "\n".join(out)

    # Остаточный LaTex ВНЕ разделителей ($…$, \[…\]) → юникод, как в обычном пути.
    # Раньше rich-путь его не трогал, и пользователь видел "\frac{1}{2}" и "\alpha\times\beta"
    # в финальном сообщении, тогда как стрим/HTML-фолбэк показывали 1/2 и α×β (аудит 26.09.2026).
    # Плейсхолдеры кода/математики/таблиц безопасны: в них нет ни слэшей, ни "^"/"_".
    text = _scrub_latex(text)

    text = _normalize_bullet_markers(text)
    # Та же разноска слипшихся списков, что в обычном пути (Phase 1.405/1.406):
    # иначе стрим (HTML) и финал (rich) одного ответа выглядят по-разному.
    text = _split_inline_bullets(text)
    text = _split_inline_numbered(text)
    text = _convert_blockquotes(text)

    text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

    text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
    text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
    text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)

    text = text.replace(_BLOCKQUOTE_START, "<blockquote>").replace(_BLOCKQUOTE_END, "</blockquote>")

    # Прод 07.10.2026: в rich-сообщении голый перенос строки НЕ разрыв — Telegram
    # склеивает абзацы, и разнесённый список приходил одной кашей (в обычном
    # HTML-пути \n работает, поэтому баг был виден только в финале). Переносы
    # заменяем на <br/> — ровно так их показывают доки для <blockquote>.
    # Момент: код/ссылки/таблицы/заголовки ещё в плейсхолдерах (без \n внутри),
    # поэтому многострочные <pre> не пострадают.
    text = text.replace("\n", "<br/>")

    for key, (tag_level, inner) in _heads.items():
        text = text.replace(key, f"<h{tag_level}>{_rich_inline(inner)}</h{tag_level}>")
    for key, (header, rows) in _tables.items():
        text = text.replace(key, _build_rich_table(header, rows))
    for key, (latex, block) in _math.items():
        latex = latex.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        tag = "tg-math-block" if block else "tg-math"
        text = text.replace(key, f"<{tag}>{latex}</{tag}>")
    for key, orig in _links.items():
        m = re.match(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", orig, re.DOTALL)
        label, url = m.group(1), m.group(2)
        label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        url = url.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
        text = text.replace(key, f'<a href="{url}">{label}</a>')
    for key, orig in _code.items():
        if orig.startswith("```"):
            m = re.match(r"```([a-zA-Z0-9]*)\n(.*)\n```", orig, re.DOTALL)
            lang, inner = (m.group(1), m.group(2)) if m else ("", orig[3:-3])
            inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            replacement = f'<pre><code class="language-{lang}">{inner}</code></pre>' if lang else f"<pre>{inner}</pre>"
        else:
            inner = orig[1:-1]
            inner = inner.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            replacement = f"<code>{inner}</code>"
        text = text.replace(key, replacement)

    text = text.replace("\x00BR\x00", "<br/>")

    return text


def _strip_markdown(text: str) -> str:
    """Сносит markdown-разметку в голый текст для последнего рубежа отправки
    (plain-text без parse_mode): иначе при отказе HTML/рич в чат уходят сырые
    `**`, backtick-и и `[label](url)` (прод-кейс 17.09.2026 — ответ с `**`
    прилетел как есть). Чистит только синтаксис, слова не трогает."""
    if not text:
        return ""
    # Голые URL прячем до чистки: "_" внутри них не выделение.
    _urls: dict[str, str] = {}
    def _save_url(m: re.Match) -> str:
        key = f"\x00U{len(_urls)}\x00"
        _urls[key] = m.group(0)
        return key
    text = re.sub(r"```[a-zA-Z0-9]*\n(.*?)\n```", r"\1", text, flags=re.DOTALL)
    text = re.sub(r"\[([^\[\]]+)\]\((https?://[^\s()]+)\)", r"\1", text)
    text = re.sub(r"https?://[^\s()<>]+", _save_url, text)
    text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL)
    # Одиночное "_" только вне слова: my_file_name и имена файлов не трогаем.
    text = re.sub(r"(?<!\w)_([^_\n]+?)_(?!\w)", r"\1", text)
    text = re.sub(r"\*(.*?)\*", r"\1", text)
    text = re.sub(r"~~(.*?)~~", r"\1", text)
    text = re.sub(r"`([^`\n]+)`", r"\1", text)
    text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
    for key, url in _urls.items():
        text = text.replace(key, url)
    return text


def _tg_len(text: str) -> int:
    """Длина глазами Telegram: UTF-16 code units, не codepoints (эмодзи вне BMP
    стоят 2 единицы, 3000 штук уже переполнение при "коротких" 3000 символах)."""
    return len(text.encode("utf-16-le")) // 2


def _take_prefix_by_units(text: str, max_units: int) -> str:
    """Самый длинный префикс не длиннее max_units (кодпоинт не рвём)."""
    if _tg_len(text) <= max_units:
        return text
    lo, hi = 0, len(text)
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if _tg_len(text[:mid]) <= max_units:
            lo = mid
        else:
            hi = mid - 1
    return text[:lo]


_FENCE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9]*\n.*?\n```", re.DOTALL)
_FENCE_UNWRAP_RE = re.compile(r"\A```([a-zA-Z0-9]*)\n(.*)\n```\Z", re.DOTALL)
# Дальше этого пол не дробим: патологический синтаксис теоретически раздувает
# HTML без края, вечный бисект хуже одного негабаритного чанка с фолбэком.
_SPLIT_FLOOR_UNITS = 64


def _chunk_plain(span: str, max_len: int) -> list[str]:
    """Кусочки plain-сегмента по границам абзацев/строк/предложений (в units)."""
    if _tg_len(span) <= max_len:
        return [span]
    chunks: list[str] = []
    remaining = span
    while _tg_len(remaining) > max_len:
        window = _take_prefix_by_units(remaining, max_len)
        cut = -1
        for sep in ("\n\n", "\n", ". ", " "):
            idx = window.rfind(sep)
            if idx > len(window) * 0.5:
                cut = idx + len(sep)
                break
        if cut <= 0:
            cut = len(window)
        chunks.append(remaining[:cut].rstrip())
        remaining = remaining[cut:].lstrip()
    if remaining:
        chunks.append(remaining)
    return chunks


def _chunk_fence_block(block: str, max_len: int) -> list[str]:
    """Забор целиком, если влезает; иначе построчно с переоткрытием забора."""
    if _tg_len(_md_to_html(block)) <= max_len:
        return [block]
    m = _FENCE_UNWRAP_RE.match(block)
    if not m:
        return _chunk_plain(block, max_len)
    lang, inner = m.group(1), m.group(2)
    wrap_overhead = _tg_len(f"```{lang}\n") + _tg_len("\n```")
    inner_chunks = _chunk_plain(inner, max(64, max_len - wrap_overhead)) or [inner]
    return [f"```{lang}\n{part}\n```" for part in inner_chunks]


def _split_to_fit_html(chunk_md: str, max_len: int) -> list[str]:
    """Дробление markdown-куска, пока его итоговый HTML не влезет в лимит."""
    if _tg_len(_md_to_html(chunk_md)) <= max_len:
        return [chunk_md]
    m = _FENCE_UNWRAP_RE.match(chunk_md)
    wrap = (m.group(1), True) if m else None
    inner = m.group(2) if m else chunk_md
    if not inner.strip():
        return [chunk_md]
    lines = inner.split("\n")
    if len(lines) > 1:
        mid = len(lines) // 2
        parts = ["\n".join(lines[:mid]), "\n".join(lines[mid:])]
    else:
        half = _take_prefix_by_units(inner, _tg_len(inner) // 2) or inner[:1]
        rest = inner[len(half):]
        parts = [half, rest] if rest else [half]
    out: list[str] = []
    for part in parts:
        piece = f"```{wrap[0]}\n{part}\n```" if wrap else part
        if _tg_len(part) <= _SPLIT_FLOOR_UNITS:
            out.append(piece)
        else:
            out.extend(_split_to_fit_html(piece, max_len))
    return out


def _merge_short_chunks(chunks: list[str], max_len: int) -> list[str]:
    """Склейка соседних кусков обратно, пока влезают: сегментация по заборам
    иначе дробила бы каждое короткое сообщение с кодом на три."""
    merged: list[str] = []
    acc = ""
    for chunk in chunks:
        candidate = chunk if not acc else acc + chunk
        if _tg_len(_md_to_html(candidate)) <= max_len:
            acc = candidate
        else:
            if acc:
                merged.append(acc)
            acc = chunk
    if acc or not merged:
        merged.append(acc)
    return merged


def _split_text_chunks(text: str, max_len: int = 4096) -> list[str]:
    """Разбивает длинный текст на части не длиннее max_len, стараясь резать по
    границам абзацев/строк/предложений, а не посреди слова. Раньше сообщения
    длиннее лимита Telegram (4096 симв.) просто не отправлялись — пользователь
    не видел вообще ничего.

    Вынесено из bot.py (срез монолита, сентябрь 2026): чистая функция над
    строками без единой зависимости от Telegram/рантайма — тот же класс, что и
    _md_to_html выше. Дефолт 4096 дублирует TG_MAX_LEN из bot.py буквально
    (импортировать константу оттуда нельзя — циклический импорт): оба места
    про лимит Telegram, меняются только вместе с ним.

    Лимит соблюдается дважды: сам markdown режется в units (эмодзи вне BMP
    стоят 2), затем каждый кусок проверяется по длине итогового HTML —
    разметка раздувает текст, и чанк "4095 символов" иначе давал 400 от
    Telegram. Заборы не рвутся: длинные делятся построчно с переоткрытием."""
    if not text:
        return [text]
    pieces: list[tuple[bool, str]] = []
    pos = 0
    for m in _FENCE_BLOCK_RE.finditer(text):
        if m.start() > pos:
            pieces.append((False, text[pos:m.start()]))
        pieces.append((True, m.group(0)))
        pos = m.end()
    if pos < len(text):
        pieces.append((False, text[pos:]))
    if not pieces:
        return [text]
    md_chunks: list[str] = []
    for is_fence, seg in pieces:
        if is_fence:
            md_chunks.extend(_chunk_fence_block(seg, max_len))
        else:
            # Слипшиеся буллеты разносим ДО резки: иначе чанкер рвёт список
            # по ". " и куски с 1 маркером мимо порога сплиттера (прод 07.10.2026).
            seg = _split_inline_numbered(_split_inline_bullets(seg))
            md_chunks.extend(_chunk_plain(seg, max_len))
    out: list[str] = []
    for chunk in md_chunks:
        out.extend(_split_to_fit_html(chunk, max_len))
    return _merge_short_chunks(out, max_len) or [text]


def _truncate_html_to_fit(md_text: str, limit: int) -> str:
    """HTML по границе исходника, а не по границе тегов: резать готовый HTML
    по codepoint можно угодить в середину <b>/ссылки — Telegram ответит
    "can't parse entities" (AUD-J-002). Бинарным поиском ищем самый длинный
    префикс исходника, чей HTML влезает в лимит. Вынесено из bot.py (гостевые
    ответы — одиночный InlineQueryResultArticle без фолбэка на plain-текст)."""
    full = _md_to_html(md_text)
    if len(full) <= limit:
        return full
    lo, hi = 0, len(md_text)
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if len(_md_to_html(md_text[:mid])) <= limit - 1:
            lo = mid
        else:
            hi = mid - 1
    return _md_to_html(md_text[:lo])[:limit - 1] + "…"