File size: 64,349 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
"""PoolCoach BE — FastAPI bọc engine ZonePlanner V2 (23/07/2026; một engine
duy nhất từ 31/07/2026 — BRIEF việc D, bản cũ oracle/hybrid ở nhánh v1-full).

Chạy từ gốc repo poolcoach-rl (hoặc dùng run_app.bat ở D:\\Khoa luan):
    python -m uvicorn app.main:app --port 8000

4 endpoint (contract §4 PoolCoach_App_Web_Design.md):
    GET  /api/table       hằng số bàn để FE dựng Canvas đúng tỉ lệ
    GET  /api/health      ok | warming_up | error (FE poll lúc mở trang)
    POST /api/recommend   thế bàn → 1 + N cú đã rank theo tiêu chí V2; CHỈ
                          cú rank 1 kèm trajectories (render lười, 30/07)
    POST /api/trajectory  thế bàn + (phi, V0, spin) → quỹ đạo MỘT cú. FE gọi
                          khi người dùng bấm sang một cú thay thế.
Thêm 04/08 (bàn giao 11, F3 MVP): GET /api/drills(+/{id}) đọc file
app/drills.json; POST/GET /api/drill-attempts ghi/đọc lượt tập tự khai
(ghi best-effort, đọc cần DB). GET /api/table-qr/{token} từ bàn giao 10.
Static FE mount ở "/" (app/static), Cache-Control: no-cache (31/07).
Warmup JIT Numba chạy nền lúc startup. Concurrency: 1 uvicorn worker +
asyncio.Lock quanh search (pooltool không cần thread-safe; MVP một người
dùng). V2 chạy serial 99–198 sim/cú — không ProcessPool, không net/torch.
Bàn giao 14 (04/08): set REDIS_URL → /api/recommend đi qua worker riêng
(scripts/engine_worker.py, transport app/jobqueue.py); không set → in-process
Y HỆT bản cũ. Engine hai mode là MỘT hàm: app/engine.py run_recommend.
"""

from __future__ import annotations

import asyncio
import functools
import sys
import threading
import time
from contextlib import asynccontextmanager
from datetime import timedelta, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT / "src") not in sys.path:
    sys.path.insert(0, str(ROOT / "src"))

from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.staticfiles import StaticFiles

import sqlalchemy as sa

from . import analyzer_store as astore
from . import db, displays, drills, jobqueue, localcv, qr
from .engine import _pt, run_recommend
from .schemas import (AnalyzerCornersOut, AnalyzerDemoOut, AnalyzerShotItem,
                      AnalyzerShotNetBrief,
                      AnalyzerShotsOut, AnalyzerVideoCreateOut,
                      AnalyzerVideoInfo, DrillAttemptIn,
                      DrillAttemptItem, DrillAttemptOut, DrillAttemptsListOut,
                      DrillListItem, DrillOut, DrillsListOut, HealthOut,
                      RecommendRequest, ScanBall, ScanOut, TableInfo,
                      TablePocket, TableQrOut, TableQrTable,
                      TrajectoryRequest, TrajectoryResponse)

STATIC_DIR = Path(__file__).resolve().parent / "static"


# ------------------------------------------------------------------ state

class _State:
    env_h = None                    # PositionPlayEnv dùng chung mọi request
    jit_ready: bool = False
    warmup_s: float | None = None
    boot_error: str | None = None


state = _State()
_search_lock = asyncio.Lock()


def _boot() -> None:
    """Import pooltool + tạo env + kích JIT — chạy trong thread nền để
    uvicorn lên ngay, FE poll /api/health trong lúc chờ."""
    try:
        t0 = time.time()
        from poolcoach_rl.envs import PositionPlayEnv
        from poolcoach_rl.recommend import warmup

        state.env_h = PositionPlayEnv()
        print(f"[poolcoach] env san sang sau {time.time() - t0:.1f}s "
              f"-- warmup JIT Numba (~40s lan dau)...", flush=True)
        state.warmup_s = warmup(state.env_h)
        state.jit_ready = True
        print(f"[poolcoach] JIT xong sau {state.warmup_s:.1f}s -- API san sang",
              flush=True)
    except Exception as e:  # noqa: BLE001 — báo lỗi qua /api/health
        state.boot_error = f"{type(e).__name__}: {e}"
        print(f"[poolcoach] LOI khoi dong: {state.boot_error}", flush=True)


@asynccontextmanager
async def lifespan(_app: FastAPI):
    # DB bật/tắt theo DATABASE_URL — tắt là chạy degraded Y HỆT bản không DB
    # (BRIEF 04/08: Space HF không có Postgres, rebuild bất kỳ lúc nào).
    if db.setup():
        print("[poolcoach] DB bat (DATABASE_URL co)", flush=True)
    else:
        print("[poolcoach] DB tat -- khong co DATABASE_URL, "
              "table-qr se tra 503, recommend khong log", flush=True)
    if qr.secret_is_default():
        print("[poolcoach] WARNING: POOLCOACH_SECRET chua set -- token QR "
              "dang ky bang secret dev, KHONG dung cho prod", flush=True)
    # Queue bật/tắt theo REDIS_URL — cùng nếp DATABASE_URL ở trên: không set
    # là mode inprocess, chạy Y HỆT hôm nay (bàn giao 14 — Space không được vỡ).
    qmode = jobqueue.setup()
    if qmode == "queue":
        print("[poolcoach] queue bat (REDIS_URL co) -- /api/recommend di qua "
              "worker rieng, KHONG fallback in-process khi worker chet",
              flush=True)
    elif qmode == "local":
        # Một container (Space HF): CV worker là một LUỒNG trong chính tiến
        # trình này, engine vẫn in-process. Xem app/localcv.py về ba đường
        # đã cân và vì sao chọn đường này.
        print("[poolcoach] mode LOCAL (POOLCOACH_LOCAL_CV) -- CV worker chay "
              "bang luong nhung trong app; recommend van in-process",
              flush=True)
        localcv.start()
    else:
        print("[poolcoach] queue tat -- khong co REDIS_URL, recommend "
              "in-process nhu cu", flush=True)
    # Video mẫu đã phân tích sẵn (việc B): chép vào thư mục kết quả nếu chưa
    # có, để mở app KHÔNG GPU là thấy ngay danh sách cú (không phân tích lại).
    astore.seed_demo()
    threading.Thread(target=_boot, daemon=True, name="poolcoach-warmup").start()
    try:
        yield
    finally:
        if jobqueue.mode() == "local":
            localcv.stop()


app = FastAPI(title="PoolCoach", lifespan=lifespan)


# -------------------------------------------------------------- endpoints

@app.get("/api/health", response_model=HealthOut)
def api_health():
    """Trạng thái boot. `workers`/`hybrid_available` cũ gỡ 31/07 cùng pool +
    net (V2 chạy serial, không model) — FE chỉ đọc status/jit_ready/error.

    Bàn giao 14 THÊM mode/redis_ok/worker_alive — phản ánh đủ 3 trạng thái
    (inprocess / queue+alive / queue+worker chết) và KHÔNG được 500 khi Redis
    chết: redis_ok=False là câu trả lời, không phải lỗi. Bàn giao 19 THÊM
    cv_worker_alive (heartbeat CV worker — độc lập engine worker), cùng quy
    ước: inprocess → null "không áp dụng", Redis chết → False.
    """
    status = ("error" if state.boot_error
              else "ok" if state.jit_ready else "warming_up")
    qmode = jobqueue.mode()
    redis_ok = worker_alive = cv_worker_alive = None
    if qmode == "queue":
        redis_ok = jobqueue.ping()
        worker_alive = jobqueue.worker_alive() if redis_ok else False
        cv_worker_alive = (jobqueue.worker_alive(
            key=jobqueue.SCAN_HEARTBEAT_KEY) if redis_ok else False)
    elif qmode == "local":
        # `worker_alive` (engine) để NULL đúng nghĩa "không áp dụng": mode
        # local chạy engine in-process, không có engine worker để mà chết.
        redis_ok = jobqueue.ping()
        cv_worker_alive = (jobqueue.worker_alive(
            key=jobqueue.SCAN_HEARTBEAT_KEY) if redis_ok else False)
    return HealthOut(status=status, jit_ready=state.jit_ready,
                     error=state.boot_error, mode=qmode,
                     redis_ok=redis_ok, worker_alive=worker_alive,
                     cv_worker_alive=cv_worker_alive)


@app.get("/api/table", response_model=TableInfo)
def api_table():
    if state.boot_error:
        raise HTTPException(500, f"Server lỗi khởi động: {state.boot_error}")
    env_h = state.env_h
    if env_h is None:
        raise HTTPException(503, "Đang khởi động — thử lại sau")

    from poolcoach_rl.envs.position_env import BALL_R
    from poolcoach_rl.recommend import pocket_name

    # env._pockets và table.pockets cùng thứ tự (giả định đã dùng ở
    # render_png CLI — nghiệm thu smoke 23/07 pass)
    pocket_objs = list(env_h.table.pockets.values())
    pockets = [
        TablePocket(x=float(p[0]), y=float(p[1]),
                    r=float(getattr(pocket_objs[i], "radius", 2 * BALL_R)),
                    name=pocket_name(env_h, i))
        for i, p in enumerate(env_h._pockets)
    ]
    return TableInfo(w=float(env_h.w), l=float(env_h.l),
                     ball_r=float(BALL_R), pockets=pockets)


# ------------------------------------------------- /api/table-qr (04/08)

GUEST_SESSION_TTL = timedelta(hours=24)


@app.get("/api/table-qr/{token}", response_model=TableQrOut)
def api_table_qr(token: str):
    """Khách quét QR bàn → verify token → tạo guest session TTL 24h.

    Không cần engine/JIT — endpoint sống ngay từ giây đầu boot. Sync def
    (starlette chạy trong threadpool) vì call DB là blocking.
    """
    if not db.enabled():
        # Space HF chưa có Postgres — nói thẳng tính năng chưa bật, đừng 500.
        raise HTTPException(503, "Server chưa cấu hình cơ sở dữ liệu — "
                                 "tính năng QR bàn chưa bật trên bản này.")
    ids = qr.read_table_token(token)
    if ids is None:
        raise HTTPException(400, "Mã QR không hợp lệ — quét lại mã dán trên bàn.")
    tenant_id, table_id = ids

    with db.session() as s:
        tbl = s.get(db.Table, table_id)
        # Token ký đúng nhưng bàn đã xoá / gán nhầm tenant → cũng là "không
        # tồn tại", không lộ bàn của tenant khác.
        if tbl is None or tbl.tenant_id != tenant_id:
            raise HTTPException(404, "Bàn trong mã QR không còn tồn tại — "
                                     "gọi nhân viên đổi mã.")
        gs = db.GuestSession(tenant_id=tenant_id, table_id=table_id,
                             expires_at=db.utcnow() + GUEST_SESSION_TTL)
        s.add(gs)
        s.flush()          # áp default id/created_at trước khi ra khỏi session
        return TableQrOut(
            session_id=gs.id,
            table=TableQrTable(id=tbl.id, name=tbl.name),
            # DB lưu naive-UTC (app/db.py) — gắn lại tz để FE khỏi đoán múi giờ
            expires_at=gs.expires_at.replace(tzinfo=timezone.utc))


# --------------------------------- /api/drills + drill-attempts (F3, 04/08)

@app.get("/api/drills", response_model=DrillsListOut)
def api_drills():
    """Danh sách drill chuẩn hệ thống — đọc từ file repo (app/drills.json).

    Sống từ giây đầu boot: không cần engine/JIT, không cần DB — Space
    degraded vẫn demo được F3 (quyết định nền của BRIEF 04/08 bước 1).
    """
    return DrillsListOut(drills=[
        DrillListItem(id=d["id"], title=d["title"], tags=d["tags"],
                      reps=d["reps"])
        for d in drills.all_drills().values()])


@app.get("/api/drills/{drill_id}", response_model=DrillOut)
def api_drill_detail(drill_id: str):
    """Đủ layout/goal/scoring để FE dựng bàn + vẽ cue_zone."""
    d = drills.get_drill(drill_id)
    if d is None:
        raise HTTPException(404, f"Không có drill '{drill_id}' trong thư viện.")
    return DrillOut(**d)


@app.post("/api/drill-attempts", response_model=DrillAttemptOut)
def api_drill_attempt(req: DrillAttemptIn):
    """Ghi 1 lượt tập TỰ KHAI — best-effort như log recommendations (§5.3).

    Không DB (hoặc DB nổ giữa chừng) → vẫn 200 + ``persisted: false``:
    degraded vẫn tập được, chỉ mất lưu tiến bộ. ``drill_id`` rác thì 404 —
    đó là lỗi client, không liên quan DB nên không được nuốt.

    ``tenant_id``/``guest_session_id`` điền từ guest session nếu
    ``session_id`` trỏ tới một phiên CÒN HẠN — cùng check ``expires_at`` với
    log recommend (bàn giao 13, đồng bộ hai chỗ). Phiên THẬT nhưng hết hạn →
    row không gắn phiên (cả hai cột NULL): hết TTL là hết tư cách quy lượt
    tập về quán/phiên đó. ``session_id`` không có trong DB thì không phải
    phiên — giữ nguyên text làm dấu vết (không FK, như ``scan_id``), tenant
    NULL. Cả hai trường hợp đều KHÔNG chặn lượt tập — luật của bảng này là
    không bao giờ vứt một lượt tập vì chuyện lưu trữ.
    """
    if drills.get_drill(req.drill_id) is None:
        raise HTTPException(404, f"Không có drill '{req.drill_id}' trong thư viện.")
    if not db.enabled():
        return DrillAttemptOut(persisted=False)
    try:
        with db.session() as s:
            tenant_id, guest_session_id = None, req.session_id
            if req.session_id:
                gs = s.get(db.GuestSession, req.session_id)
                if gs is not None:
                    if gs.expires_at > db.utcnow():
                        tenant_id = gs.tenant_id
                    else:
                        guest_session_id = None
                        print(f"[poolcoach] WARNING: session het han trong "
                              f"drill-attempt -- bo qua, khong gan phien "
                              f"({req.session_id!r})", flush=True)
            row = db.DrillAttempt(tenant_id=tenant_id,
                                  guest_session_id=guest_session_id,
                                  drill_id=req.drill_id, result=req.result,
                                  detail=req.detail)
            s.add(row)
            s.flush()          # lấy id trước khi ra khỏi session
            return DrillAttemptOut(persisted=True, id=row.id)
    except Exception as e:  # noqa: BLE001 — best-effort đúng nghĩa đen
        print(f"[poolcoach] WARNING: khong ghi duoc drill attempt: "
              f"{type(e).__name__}: {e}", flush=True)
        return DrillAttemptOut(persisted=False)


@app.get("/api/drill-attempts", response_model=DrillAttemptsListOut)
def api_drill_attempts(session_id: str | None = None,
                       drill_id: str | None = None):
    """Lịch sử + tổng hợp lượt tập. KHÁC POST một cách có chủ đích: xem lịch
    sử thì PHẢI có DB — không có dữ liệu nào để best-effort, 503 nói thẳng."""
    if not db.enabled():
        raise HTTPException(503, "Server chưa cấu hình cơ sở dữ liệu — "
                                 "lịch sử lượt tập chưa xem được trên bản "
                                 "demo này (lượt tập không được lưu).")
    q = sa.select(db.DrillAttempt).order_by(db.DrillAttempt.id)
    if session_id:
        q = q.where(db.DrillAttempt.guest_session_id == session_id)
    if drill_id:
        q = q.where(db.DrillAttempt.drill_id == drill_id)
    with db.session() as s:
        rows = s.scalars(q).all()

    summary: dict[str, dict[str, int]] = {}
    for r in rows:
        c = summary.setdefault(r.drill_id, {"pass": 0, "fail": 0})
        c[r.result] = c.get(r.result, 0) + 1
    return DrillAttemptsListOut(
        attempts=[DrillAttemptItem(
            id=r.id, drill_id=r.drill_id, result=r.result,
            session_id=r.guest_session_id, detail=r.detail,
            # DB lưu naive-UTC — gắn lại tz như expires_at của table-qr
            created_at=r.created_at.replace(tzinfo=timezone.utc))
            for r in rows],
        summary=summary)


@app.post("/api/recommend")
async def api_recommend(req: RecommendRequest):
    if state.boot_error:
        raise HTTPException(500, f"Server lỗi khởi động: {state.boot_error}")
    if not state.jit_ready or state.env_h is None:
        raise HTTPException(503, "Đang khởi động vật lý (JIT ~40s) — thử lại sau")

    if req.balls is None:
        # Đường 3 bi cũ (cue/b1/b2) gỡ 31/07 cùng oracle/hybrid — client cũ
        # nhận 422 message rõ thay vì một shape response không ai đọc nữa.
        raise HTTPException(422, "Cần balls (thế bàn full rack) — đường 3 bi "
                                 "cũ đã gỡ 31/07/2026, xem nhánh v1-full")
    return await _recommend_full(req)


# ------------------------------------------------------- full rack (API v2)
# Toàn bộ phần DỰNG response (ShotFullOut/_full_response) chuyển sang
# app/engine.py (bàn giao 14) — worker cần nó mà không được kéo theo FastAPI
# app. Ở đây chỉ còn transport (inprocess/queue) + logging DB.

ENGINE_VER = "zoneplanner-v2"   # hằng số ghi vào log recommendations (04/08)


def _log_recommend(req: RecommendRequest, resp: dict,
                   latency_ms: int) -> None:
    """Ghi 1 row `recommendations` — BEST-EFFORT, không bao giờ ném.

    DB lỗi/tắt thì response vẫn đi ra bình thường (BRIEF 04/08: KHÔNG BAO GIỜ
    để DB làm chết `/api/recommend`). Vì thế nuốt MỌI exception, chỉ warning.

    `resp` là dict JSON-thuần của `run_recommend` — CÙNG shape ở cả hai mode
    (bàn giao 14), nên logging cũng là một code path duy nhất.

    `trajectories` không vào log, cố ý: hàng trăm điểm toạ độ mỗi cú chỉ để
    vẽ, còn phân tích thì tham số cú + outcome là đủ — sim tất định nên cần
    lại quỹ đạo cứ đưa 4 tham số cho `/api/trajectory`.
    """
    if not db.enabled():
        return
    try:
        top = resp["shots"][0] if resp["shots"] else None
        with db.session() as s:
            # Phiên bàn (bàn giao 12): session sống → row mang tenant_id của
            # quán. Session rác/hết hạn KHÔNG làm mất row — log nặc danh vẫn
            # có giá trị, chỉ warning để dev thấy client gửi phiên hỏng.
            tenant_id = None
            if req.session_id:
                gs = s.get(db.GuestSession, req.session_id)
                if gs is not None and gs.expires_at > db.utcnow():
                    tenant_id = gs.tenant_id
                else:
                    print(f"[poolcoach] WARNING: session_id la/het han trong "
                          f"recommend -- bo qua, khong gan tenant "
                          f"({req.session_id!r})", flush=True)
            s.add(db.Recommendation(
                tenant_id=tenant_id,
                scan_id=req.scan_id,
                balls={bid: {"x": p.x, "y": p.y}
                       for bid, p in req.balls.items()},
                edited=req.edited,
                engine_ver=ENGINE_VER,
                shot=({k: v for k, v in top.items() if k != "trajectories"}
                      if top is not None else None),
                alternatives=[{k: v for k, v in s2.items()
                               if k != "trajectories"}
                              for s2 in resp["shots"][1:]],
                latency_ms=latency_ms,
            ))
    except Exception as e:  # noqa: BLE001 — best-effort đúng nghĩa đen
        print(f"[poolcoach] WARNING: khong ghi duoc log recommend: "
              f"{type(e).__name__}: {e}", flush=True)


async def _submit_queue(loop, payload: dict) -> dict:
    """Một job qua Redis → dict response. BLPOP chạy trong executor để không
    block event loop (BRIEF 2.4 — nhất quán với cách engine được gọi).

    Queue lỗi (Redis chết / worker không trả lời) → 503 message tiếng Việt
    rõ + log, KHÔNG âm thầm fallback in-process — che chết worker tệ hơn lỗi
    rõ (BRIEF 1.4).

    Fast-fail (bàn giao 20, đối xứng /api/scan bàn giao 19): vắng heartbeat
    engine worker → 503 NGAY với message riêng, KHÔNG đẩy job rồi bắt client
    chờ trọn timeout 30s. Chỉ chặn ca "worker KHÔNG chạy"; worker chết SAU
    enqueue vẫn đi đường QueueTimeout như cũ (heartbeat TTL 15s — cửa sổ
    ngắn worker vừa chết vẫn timeout, chấp nhận, là thiết kế).
    `worker_alive_or_raise` ném QueueDown khi Redis chết — rơi đúng nhánh
    message "Redis" bên dưới, không đổ oan cho worker.
    """
    try:
        alive = await loop.run_in_executor(None, jobqueue.worker_alive_or_raise)
        if not alive:
            raise HTTPException(503, "Engine worker không chạy (không thấy "
                                "heartbeat) — mode queue cần engine worker "
                                "đang bật (scripts/engine_worker.py). Thử "
                                "lại sau khi bật worker.")
        reply = await loop.run_in_executor(
            None, functools.partial(jobqueue.submit, payload))
    except jobqueue.QueueTimeout as e:
        print(f"[poolcoach] LOI queue: het han cho ket qua ({e})", flush=True)
        raise HTTPException(503, f"Engine không trả lời trong "
                            f"{jobqueue.timeout_s():g}s — worker có thể đã "
                            f"chết hoặc đang kẹt. Thử lại sau; nếu lặp lại, "
                            f"kiểm tra process engine_worker.") from e
    except jobqueue.QueueDown as e:
        print(f"[poolcoach] LOI queue: Redis khong noi chuyen duoc ({e})",
              flush=True)
        raise HTTPException(503, "Không kết nối được hàng đợi engine (Redis) "
                            "— kiểm tra Redis và worker rồi thử lại.") from e

    if reply.get("ok"):
        return reply["result"]
    if reply.get("error") == "validation":
        # Cùng message validate_full nguyên văn — 422 hai mode trùng từng byte
        raise HTTPException(422, reply.get("message", ""))
    print(f"[poolcoach] LOI worker: {reply.get('message')}", flush=True)
    raise HTTPException(500, "Engine gặp lỗi khi tính cú: "
                        f"{reply.get('message', 'không rõ')}")


async def _recommend_full(req: RecommendRequest) -> dict:
    """Full rack — MỘT engine duy nhất từ 31/07/2026: ZonePlanner V2, từ bàn
    giao 14 qua hàm chung `run_recommend` (app/engine.py) để in-process và
    worker CÙNG code path; ở đây chỉ chọn transport.

    ``req.engine``/``req.topk`` BỎ QUA CÓ CHỦ ĐÍCH (G5.5): request cũ còn gửi
    "oracle"/"hybrid" vẫn 200 và nhận cú của zone V2 — app không còn engine
    nào khác để chiều theo, và 4xx hoá một client cũ chỉ vì nó gửi thừa một
    trường là làm phiền đúng người đang không làm gì sai. `search.engine`
    trong response khai "zone" — sự thật về đường đã chạy.

    Mode queue KHÔNG cầm `_search_lock`: env của process API không bị đụng
    tới (worker có env riêng và tự serial hoá bằng vòng BRPOP đơn).
    """
    payload = {"balls": {bid: {"x": p.x, "y": p.y}
                         for bid, p in req.balls.items()},
               "alternatives": req.alternatives}
    loop = asyncio.get_running_loop()

    t0 = time.perf_counter()
    if jobqueue.mode() == "queue":
        resp = await _submit_queue(loop, payload)
    else:
        call = functools.partial(run_recommend, payload, state.env_h)
        try:
            async with _search_lock:
                resp = await loop.run_in_executor(None, call)
        except ValueError as e:   # validate_full: ngoài bàn / chồng bi / id lạ
            raise HTTPException(422, str(e)) from e
    # round chứ không int-truncate, và kẹp >= 1: engine thật đo hàng nghìn ms,
    # nhưng engine mock trong test xong dưới 1 ms — 0 sẽ trông như "không đo".
    latency_ms = max(1, round((time.perf_counter() - t0) * 1000))

    # TV tại bàn (bàn giao 15, Bước 3): push best-effort xuống TV đã ghép.
    # Ở API LAYER sau khi có kết quả, KHÔNG trong run_recommend/worker — một
    # code path cho CẢ HAI transport (bất biến bàn giao 14, bẫy 27/07).
    # `push_recommend` tự nuốt mọi lỗi + chặn trần thời gian: không TV /
    # TV rớt thì response vẫn y nguyên, không chậm đi.
    if req.session_id:
        await displays.hub.push_recommend(req.session_id, payload["balls"],
                                          resp)

    # Ghi log qua executor: write DB là blocking, không để nó chặn event loop.
    # Best-effort — `_log_recommend` tự nuốt lỗi, response không phụ thuộc nó.
    await loop.run_in_executor(
        None, functools.partial(_log_recommend, req, resp, latency_ms))
    return resp


# ------------------------------------------------- /api/scan (05/08, F2)

# Ảnh FE ĐÃ resize (max dim ~1280) trước khi upload — payload bình thường
# vài trăm KB. Trần này chỉ chặn client lạ đẩy ảnh gốc chục MB làm nghẽn
# Redis (BRIEF "Nếu bí": payload >10MB là chuyện phải dừng lại hỏi — chặn
# từ cửa để nó không bao giờ thành chuyện).
SCAN_MAX_UPLOAD_B = 10 * 1024 * 1024


# exclude_unset: trường BallID (number/number_conf/wb — bàn giao 22) chỉ
# xuất hiện khi WORKER thật sự gửi. Worker cũ → balls giữ nguyên 4 key cũ,
# assertion test cũ sống nguyên; worker mới gửi number=null → null đi qua
# NGUYÊN VẸN (FE cần phân biệt "không gán được" để fallback).
@app.post("/api/scan", response_model=ScanOut, response_model_exclude_unset=True)
async def api_scan(image: UploadFile = File(...), corners: str = Form(...)):
    """Upload ảnh bàn + 4 góc pixel → CV worker detect → thế bi (m).

    CHỈ sống ở mode queue: CV chạy trên venv CUDA riêng (bàn giao 17), app
    không import torch nên không có đường in-process để fallback — degraded
    là 503 nói thẳng, cùng nếp "che chết worker tệ hơn lỗi rõ" (bàn giao 14).
    Từ bàn giao 19: vắng heartbeat CV worker → 503 NGAY trước khi enqueue
    (fast-fail), không bắt client chờ trọn timeout 30s.

    `corners` là JSON ``[[x,y]×4]`` pixel TRÊN ẢNH GỬI LÊN, thứ tự như
    ``poolcoach_cv.table_corners``: đi vòng quanh bàn, 2 góc đầu là một băng
    ngắn — (0,0) → (W,0) → (W,L) → (0,L). Route chỉ kiểm shape; nghĩa hình
    học (suy biến, thẳng hàng) do worker phán — message 422 nguyên văn.

    `scan_id` = uuid chuỗi mờ (như session_id): KHÔNG bảng scans, KHÔNG
    migration (chờ auth) — FE gửi lại trong /api/recommend để ghi log.
    """
    import base64
    import json as _json
    import uuid as _uuid

    if not jobqueue.cv_enabled():
        raise HTTPException(503, "Tính năng scan ảnh cần CV worker — server "
                                 "này chưa bật hàng đợi (REDIS_URL) nên chưa "
                                 "scan được. Thế bi vẫn đặt tay được như thường.")
    try:
        pts = _json.loads(corners)
        ok_shape = (isinstance(pts, list) and len(pts) == 4
                    and all(isinstance(p, list) and len(p) == 2
                            and all(isinstance(v, (int, float)) for v in p)
                            for p in pts))
    except ValueError:
        ok_shape = False
    if not ok_shape:
        raise HTTPException(422, "corners phải là JSON 4 điểm [x, y] pixel "
                                 "— chấm đủ 4 góc bàn theo thứ tự hướng dẫn.")
    raw = await image.read()
    if not raw:
        raise HTTPException(422, "Ảnh rỗng — chọn lại file ảnh bàn (JPEG/PNG).")
    if len(raw) > SCAN_MAX_UPLOAD_B:
        raise HTTPException(422, "Ảnh quá lớn (>10 MB) — app tự thu nhỏ ảnh "
                                 "trước khi gửi, tải lại trang rồi thử lại.")

    payload = {"image_b64": base64.b64encode(raw).decode("ascii"),
               "corners": pts}
    loop = asyncio.get_running_loop()
    t0 = time.perf_counter()
    try:
        # Fast-fail (bàn giao 19): vắng heartbeat CV worker → 503 NGAY với
        # message riêng, KHÔNG đẩy job rồi bắt client chờ trọn timeout 30s.
        # Chỉ chặn ca "worker KHÔNG chạy"; worker chết SAU enqueue vẫn đi
        # đường QueueTimeout như cũ (heartbeat TTL 15s — cửa sổ ngắn worker
        # vừa chết vẫn timeout, chấp nhận, là thiết kế). Check đặt SAU mọi
        # validate input: lỗi client là 422 bất kể worker sống chết.
        alive = await loop.run_in_executor(
            None, functools.partial(jobqueue.worker_alive_or_raise,
                                    key=jobqueue.SCAN_HEARTBEAT_KEY))
        if not alive:
            raise HTTPException(503, "CV worker không chạy (không thấy "
                                "heartbeat) — tính năng scan cần CV worker "
                                "đang bật (scripts/cv_worker.py). Thế bi vẫn "
                                "đặt tay được như thường.")
        reply = await loop.run_in_executor(
            None, functools.partial(jobqueue.submit, payload,
                                    jobs_key=jobqueue.SCAN_JOBS_KEY))
    except jobqueue.QueueTimeout as e:
        print(f"[poolcoach] LOI scan: het han cho CV worker ({e})", flush=True)
        raise HTTPException(503, f"CV worker không trả lời trong "
                            f"{jobqueue.timeout_s():g}s — tính năng scan cần "
                            f"CV worker đang chạy (scripts/cv_worker.py). "
                            f"Thử lại sau khi bật worker.") from e
    except jobqueue.QueueDown as e:
        print(f"[poolcoach] LOI scan: Redis khong noi chuyen duoc ({e})",
              flush=True)
        raise HTTPException(503, "Không kết nối được hàng đợi CV (Redis) — "
                            "tính năng scan cần Redis + CV worker sống. "
                            "Kiểm tra rồi thử lại.") from e

    if not reply.get("ok"):
        if reply.get("error") == "validation":
            raise HTTPException(422, reply.get("message", ""))
        print(f"[poolcoach] LOI cv_worker: {reply.get('message')}", flush=True)
        raise HTTPException(500, "CV worker gặp lỗi khi scan ảnh: "
                            f"{reply.get('message', 'không rõ')}")

    balls = reply["result"]["balls"]
    print(f"[poolcoach] scan xong sau "
          f"{(time.perf_counter() - t0) * 1000:.0f}ms -- {len(balls)} bi, "
          f"dropped={reply['result'].get('dropped')}", flush=True)
    return ScanOut(scan_id=_uuid.uuid4().hex,
                   balls=[ScanBall(**b) for b in balls])


# ------------------------------------- Broadcast Analyzer — helper chung
# (Endpoint 1-cú POST/GET /api/analyze của BG24 GỠ ở A2b — video 1 cú là
# trường hợp con của luồng đa cú /api/analyzer/*. Queue "analyze" + status
# key trong jobqueue GIỮ NGUYÊN: job per cú của luồng đa cú vẫn đi đường đó.)


def _probe_duration_s(path: Path) -> float | None:
    """Thời lượng video qua ffprobe (binary hệ thống — máy demo đã có ffmpeg
    9.0, BRIEF #8). Ba kết cục, cố ý tách bạch:

    - trả float: đo được — route enforce trần ngay tại POST (4xx tử tế).
    - trả None: máy KHÔNG có ffprobe — không đo được ở tầng API, worker vẫn
      enforce lần hai bằng cv2; POST cho qua, không 500.
    - ném ValueError: ffprobe CÓ mà không đọc được file — video hỏng, 422.
    """
    import shutil
    import subprocess

    exe = shutil.which("ffprobe")
    if exe is None:
        return None
    try:
        out = subprocess.run(
            [exe, "-v", "error", "-show_entries", "format=duration",
             "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
            capture_output=True, text=True, timeout=15)
    except subprocess.TimeoutExpired as e:
        raise ValueError("ffprobe không trả lời trong 15s") from e
    raw = (out.stdout or "").strip()
    if out.returncode != 0 or not raw:
        raise ValueError((out.stderr or "không đọc được định dạng").strip()
                         .splitlines()[-1][:200])
    try:
        return float(raw)
    except ValueError as e:
        raise ValueError(f"ffprobe trả duration lạ: {raw!r}") from e


# ------------------------------------- /api/analyzer/* (13/08, lát A1)
# Analyzer đa cú: upload VIDEO (1..N cú, trần 20 phút) + 4 pocket pixel →
# job segment (cắt cú, queue pc:jobs:segment) → worker sinh N job analyze
# per cú NGUYÊN TRẠNG → JSON per cú ghi FILE ngay khi có (nếp BG31 sau vụ
# mất kết quả Redis TTL BG29b). GET đọc từ FILE làm nguồn sự thật, Redis
# chỉ bù tiến độ; DB ghi danh sách best-effort (nếp recommendations).


def _analyzer_video_dir(video_id: str):
    """Bọc astore.video_dir để test monkeypatch trỏ tmp_path (nếp
    _analyze_tmp_dir)."""
    return astore.video_dir(video_id)


def _parse_corners4(raw: str):
    """4 điểm [x, y] pixel từ JSON string — None khi hỏng (route trả 422).
    Cùng luật với /api/analyze; helper riêng để KHÔNG đụng code path cũ."""
    import json as _json

    try:
        pts = _json.loads(raw)
    except ValueError:
        return None
    ok = (isinstance(pts, list) and len(pts) == 4
          and all(isinstance(p, list) and len(p) == 2
                  and all(isinstance(v, (int, float)) for v in p)
                  for p in pts))
    return pts if ok else None


def _valid_video_id(video_id: str) -> bool:
    """uuid4 hex do chính route sinh — chặn mọi id lạ TRƯỚC khi ghép vào
    đường dẫn file (không có chuyện ../ đi lạc thư mục)."""
    return (len(video_id) == 32
            and all(c in "0123456789abcdef" for c in video_id))


@app.get("/api/analyzer/demo", response_model=AnalyzerDemoOut)
def api_analyzer_demo():
    """Id video mẫu ĐÃ PHÂN TÍCH SẴN đóng gói trong repo — FE mở tab là nạp.

    Không cần queue, không cần CV worker, không cần GPU: kết quả nằm sẵn
    trên đĩa và GET /shots đọc từ file. Đây là đường để "mở link thấy ngay
    danh sách cú" (BRIEF 14/08 việc B, mục tiêu demo gửi thầy).
    """
    ids = astore.demo_ids()
    return AnalyzerDemoOut(id=ids[0] if ids else None)


SUGGEST_MAX_UPLOAD_B = 10 * 1024 * 1024


@app.post("/api/analyzer/suggest-corners", response_model=AnalyzerCornersOut,
          response_model_exclude_none=True)
async def api_analyzer_suggest_corners(image: UploadFile = File(...)):
    """Frame đầu (JPEG/PNG do FE trích từ chính video) → 4 góc bàn ĐỀ XUẤT.

    Chạy IN-PROCESS kể cả mode queue — quyết định, không phải bỏ sót: đây là
    một lần dò màu thuần cv2/numpy vài chục ms trên MỘT ảnh, không đụng
    torch/YOLO, nên không có lý do đẩy qua CV worker (nếp /api/trajectory
    giữ in-process trong khi /api/recommend đi worker).

    LUÔN 200 khi ảnh đọc được: "không đề xuất" là một CÂU TRẢ LỜI hợp lệ
    (`ok=false` + lý do), không phải lỗi — FE quay về luồng chấm tay y hệt
    trước đây. Chỉ input rác (ảnh rỗng/quá to/không giải mã được) mới 422.
    Van sanity của đề xuất là van BG32 sẵn có trong camera.py, gọi nguyên
    qua ``autocorner.suggest_corners`` — không nới, không bản sao.
    """
    raw = await image.read()
    if not raw:
        raise HTTPException(422, "Ảnh rỗng — chọn lại video.")
    if len(raw) > SUGGEST_MAX_UPLOAD_B:
        raise HTTPException(422, "Ảnh frame quá lớn (>10 MB).")

    def _work() -> dict:
        import numpy as np

        try:
            import cv2
        except ImportError:
            # Bản app không cài cv2 (tầng requirements-cv) — nói thẳng, FE
            # về luồng chấm tay. KHÔNG 500: thiếu tính năng phụ ≠ hỏng app.
            return {"ok": False, "reason": "Server này chưa cài cv2 nên "
                                           "không gợi ý được góc bàn — chấm "
                                           "tay 4 pocket như thường."}
        from poolcoach_cv.autocorner import suggest_corners

        img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            return None
        res = suggest_corners(img)
        return {"ok": res["ok"], "corners": res["corners"],
                "reason": res["reason"], "camera": res["camera"],
                "rails": res["rails"]}

    loop = asyncio.get_running_loop()
    out = await loop.run_in_executor(None, _work)
    if out is None:
        raise HTTPException(422, "Không giải mã được frame gửi lên — cần "
                                 "JPEG hoặc PNG.")
    return AnalyzerCornersOut(**out)


@app.post("/api/analyzer/videos", response_model=AnalyzerVideoCreateOut)
async def api_analyzer_upload(video: UploadFile = File(...),
                              corners: str = Form(...)):
    """Upload video 1..N cú + 4 pocket pixel (chấm MỘT lần trên frame đầu,
    mọi cú dùng chung homography — design §2) → job segment async → id.

    Nếp /api/analyze giữ nguyên từng bước: chỉ sống ở mode queue, validate
    input trước heartbeat check, fast-fail khi vắng CV worker, status
    "queued" ghi TRƯỚC enqueue (race đã bắt được bằng test ở BG24). KHÁC:
    video lưu vào thư mục kết quả data/analyzer/{id}/ (không phải temp) —
    kết quả per cú phải sống lâu hơn phiên, còn video gốc worker sẽ tự xoá
    sau khi scan xong (bản quyền, không đọng).
    """
    import uuid as _uuid

    if not jobqueue.cv_enabled():
        raise HTTPException(503, "Tính năng phân tích video cần CV worker — "
                                 "server này chưa bật hàng đợi (REDIS_URL) "
                                 "nên chưa phân tích được.")
    pts = _parse_corners4(corners)
    if pts is None:
        raise HTTPException(422, "corners phải là JSON 4 điểm [x, y] pixel "
                                 "— chấm đủ 4 pocket góc theo thứ tự hướng dẫn.")
    suffix = Path(video.filename or "").suffix.lower()
    if suffix not in astore.VIDEO_SUFFIXES:
        raise HTTPException(422, "Cần file MP4 hoặc MOV (video 1..nhiều cú, "
                                 f"≤ {astore.VIDEO_MAX_DUR_S / 60:g} phút) — "
                                 f"nhận {suffix or 'file không đuôi'}.")

    video_id = _uuid.uuid4().hex
    vdir = _analyzer_video_dir(video_id)
    vdir.mkdir(parents=True, exist_ok=True)
    path = vdir / f"video{suffix}"
    total = 0
    try:
        with open(path, "wb") as f:
            while chunk := await video.read(1 << 20):
                total += len(chunk)
                if total > astore.VIDEO_MAX_UPLOAD_B:
                    raise HTTPException(
                        422, "Video quá lớn (>1 GB) — cắt đoạn trận cần "
                             "phân tích (một rack ≤ 20 phút) rồi thử lại.")
                f.write(chunk)
        if total == 0:
            raise HTTPException(422, "Video rỗng — chọn lại file MP4/MOV.")
        try:
            dur = _probe_duration_s(path)
        except ValueError as e:
            raise HTTPException(422, f"Không đọc được video ({e}) — cần "
                                     f"MP4/MOV H.264 lành lặn.") from e
        if dur is not None and dur > astore.VIDEO_MAX_DUR_S + 0.5:
            raise HTTPException(
                422, f"Video dài {dur / 60:.1f} phút — giới hạn "
                     f"{astore.VIDEO_MAX_DUR_S / 60:g} phút cho một lần "
                     f"quét: cắt đoạn trận cần phân tích rồi thử lại.")

        loop = asyncio.get_running_loop()
        try:
            alive = await loop.run_in_executor(
                None, functools.partial(jobqueue.worker_alive_or_raise,
                                        key=jobqueue.SCAN_HEARTBEAT_KEY))
            if not alive:
                raise HTTPException(503, "CV worker không chạy (không thấy "
                                    "heartbeat) — tính năng phân tích video "
                                    "cần CV worker đang bật "
                                    "(scripts/cv_worker.py).")
            payload = {"video_id": video_id, "video_path": str(path),
                       "corners": pts, "filename": video.filename,
                       "out_dir": str(vdir)}
            await loop.run_in_executor(
                None, functools.partial(jobqueue.set_video_status, video_id,
                                        {"status": "queued",
                                         "progress": 0.0}))
            await loop.run_in_executor(
                None, functools.partial(jobqueue.enqueue, payload,
                                        jobs_key=jobqueue.SEGMENT_JOBS_KEY,
                                        job_id=video_id))
        except jobqueue.QueueDown as e:
            print(f"[poolcoach] LOI analyzer upload: Redis khong noi chuyen "
                  f"duoc ({e})", flush=True)
            raise HTTPException(503, "Không kết nối được hàng đợi CV (Redis) "
                                "— kiểm tra Redis + CV worker rồi thử lại.") from e
    except BaseException:
        # không thành job thì dọn cả thư mục — video bản quyền không đọng
        path.unlink(missing_ok=True)
        try:
            vdir.rmdir()
        except OSError:
            pass
        raise

    await asyncio.get_running_loop().run_in_executor(
        None, _log_analyzer_video, video_id, video.filename)
    print(f"[poolcoach] segment job {video_id} vao queue "
          f"({total / 1e6:.1f}MB, {video.filename})", flush=True)
    return AnalyzerVideoCreateOut(id=video_id)


def _log_analyzer_video(video_id: str, filename: str | None) -> None:
    """Row `analyzer_videos` — BEST-EFFORT như _log_recommend, không ném."""
    if not db.enabled():
        return
    try:
        with db.session() as s:
            s.add(db.AnalyzerVideo(id=video_id, filename=filename,
                                   status="queued"))
    except Exception as e:  # noqa: BLE001 — best-effort đúng nghĩa đen
        print(f"[poolcoach] WARNING: khong ghi duoc analyzer_videos: "
              f"{type(e).__name__}: {e}", flush=True)


def _shot_summary(item: dict, result: dict) -> None:
    """Điền tóm tắt tham số của MỘT cú từ JSON kết quả analyze vào dict
    hàng bảng — chỉ trích, không tính lại số nào."""
    m = result.get("metrics") or {}
    item.update(
        status="done",
        v0_mps=m.get("v0_mps"),
        phi_deg=m.get("phi_deg"),
        spin_class=result.get("spin_class"),
        spin_confidence=result.get("spin_confidence"),
        n_collisions=m.get("n_collisions"),
        n_warnings=len(result.get("warnings") or []),
        # van A2 phần 1 — chỉ trích từ JSON, không tính lại; kết quả trước
        # A2 không có key thì hàng không mọc cờ (exclude_none)
        suspect_not_shot=result.get("suspect_not_shot"),
    )
    # RMSE resim (A2 phần 2): cột bảng lấy bộ shotnet (model đang chạy app);
    # vắng shotnet thì fallback analytic NHƯNG dán nhãn qua rmse_set —
    # "ghi rõ bộ nào, đừng trộn" (BRIEF A2 bối cảnh 3)
    rsets = (result.get("resim") or {}).get("sets") or {}
    for set_name in ("shotnet", "analytic"):
        rmse = (rsets.get(set_name) or {}).get("rmse_mm")
        if rmse is not None:
            item["rmse_mm"] = rmse
            item["rmse_set"] = set_name
            break
    sn = result.get("shotnet")
    if sn:
        item["shotnet"] = AnalyzerShotNetBrief(
            v0_cue_mps=sn["v0_cue_mps"], phi_deg=sn["phi_deg"],
            a=sn["a"], b=sn["b"], spin_vert=sn["spin_vert"],
            spin_side=sn["spin_side"], confidence=sn["confidence"],
            confidence_raw=sn.get("confidence_raw"),
            det_density=sn.get("det_density"))


def _sync_analyzer_db(video_id: str, video_status: str, n_shots: int | None,
                      items: list[AnalyzerShotItem]) -> None:
    """Đồng bộ danh sách cú vào DB — BEST-EFFORT, chỉ ghi khi có THAY ĐỔI
    (GET bị FE poll mỗi giây, không được thành máy spam UPDATE). Nguồn sự
    thật vẫn là file; DB là sổ ghi danh sách để tra cứu sau (BRIEF A1)."""
    if not db.enabled():
        return
    try:
        with db.session() as s:
            v = s.get(db.AnalyzerVideo, video_id)
            if v is not None and (v.status != video_status
                                  or v.n_shots != n_shots):
                v.status = video_status
                v.n_shots = n_shots
                v.updated_at = db.utcnow()
            rows = {r.shot_idx: r for r in
                    s.query(db.AnalyzerShot).filter_by(video_id=video_id)}
            for it in items:
                brief = it.model_dump(exclude_none=True,
                                      exclude={"thumb_url", "result_url",
                                               "progress", "stage"})
                row = rows.get(it.idx)
                if row is None:
                    s.add(db.AnalyzerShot(
                        video_id=video_id, shot_idx=it.idx,
                        t_start_s=it.t_start_s, t_end_s=it.t_end_s,
                        status=it.status, reason=it.reason, result=brief))
                elif row.status != it.status:
                    row.status = it.status
                    row.reason = it.reason
                    row.result = brief
                    row.updated_at = db.utcnow()
    except Exception as e:  # noqa: BLE001 — best-effort đúng nghĩa đen
        print(f"[poolcoach] WARNING: khong sync duoc analyzer_shots: "
              f"{type(e).__name__}: {e}", flush=True)


def _collect_shots(video: str) -> AnalyzerShotsOut:
    """Dựng body danh sách cú của MỘT video — nguồn ghép theo thứ tự tin
    cậy: manifest + JSON per cú trên ĐĨA (bền, nguồn sự thật) → status key
    Redis (tiến độ job đang chạy) → "unknown" khi cả hai vắng (worker chết
    giữa chừng — nói thẳng, không đoán).

    Tách từ GET /shots (A2 phần 5) để export CSV/JSON dùng CHUNG — hai
    đường xuất và bảng phải cùng một số (gate A2.3/A2.5), không dựng lại.

    ĐỌC ĐĨA TRƯỚC (14/08, việc B): video ĐÃ có manifest trên đĩa thì phục vụ
    được kể cả khi không có hàng đợi nào — đúng điều module này vẫn tuyên bố
    ("Redis chỉ là kênh tiến độ, đĩa mới là nguồn sự thật", BG29b/BG31), và
    là thứ giữ cho video mẫu bundled mở được ngay cả khi tầng CV chết. Chỉ
    khi CHƯA có manifest mới cần hàng đợi để nói video đang ở đâu — không có
    thì 503 nguyên văn như cũ.
    """
    if not _valid_video_id(video):
        raise HTTPException(422, "video phải là id 32 ký tự hex do "
                                 "/api/analyzer/videos trả về.")
    vdir = _analyzer_video_dir(video)
    manifest = astore.read_json(astore.manifest_path(vdir))
    live = jobqueue.cv_enabled()
    if manifest is None and not live:
        raise HTTPException(503, "Tính năng phân tích video cần CV worker — "
                                 "server này chưa bật hàng đợi (REDIS_URL).")
    vst = None
    if live:
        try:
            vst = jobqueue.get_video_status(video)
        except jobqueue.QueueDown as e:
            if manifest is None:
                raise HTTPException(503, "Không kết nối được hàng đợi CV "
                                    "(Redis) — kiểm tra Redis rồi thử "
                                    "lại.") from e
            live = False        # có file rồi: Redis chết không cản đọc kết quả

    if manifest is None:
        # chưa có file: video đang xếp hàng/đang scan/lỗi — theo Redis
        if vst is None:
            if vdir.exists():
                info = AnalyzerVideoInfo(
                    id=video, status="error",
                    message="Mất dấu job quét video (không còn trạng thái "
                            "trong hàng đợi, chưa có danh sách cú trên đĩa) "
                            "— worker có chết giữa chừng không?")
            else:
                raise HTTPException(404, "Không thấy video này — id sai "
                                         "hoặc chưa từng upload.")
        else:
            info = AnalyzerVideoInfo(
                id=video, status=vst.get("status", "queued"),
                progress=vst.get("progress"), stage=vst.get("stage"),
                message=vst.get("message"))
        return AnalyzerShotsOut(video=info, shots=[], n_done=0, n_total=0)

    if manifest.get("status") == "error":
        info = AnalyzerVideoInfo(id=video, status="error",
                                 message=manifest.get("message"))
        _sync_analyzer_db(video, "error", None, [])
        return AnalyzerShotsOut(video=info, shots=[], n_done=0, n_total=0)

    shots_out: list[AnalyzerShotItem] = []
    n_done = 0
    for sh in manifest.get("shots", []):
        idx = int(sh["idx"])
        item = {
            "idx": idx,
            "t_start_s": sh["t_start_s"], "t_end_s": sh["t_end_s"],
            "t_onset_s": sh.get("t_onset_s"),
            "t_settle_s": sh.get("t_settle_s"),
            "status": "queued",
            "reason": sh.get("reason"),
            "thumb_url": (f"/api/analyzer/videos/{video}/shots/{idx}/thumb"
                          if sh.get("thumb") else None),
        }
        if sh.get("status") == "error":
            # cú segmentation đã kết án (mất cảnh, quá dài...) — không có
            # job analyze nào cho nó
            item["status"] = "error"
        else:
            item["result_url"] = (f"/api/analyzer/videos/{video}"
                                  f"/shots/{idx}/result")
            result = astore.read_json(astore.shot_json_path(vdir, idx))
            if result is None:
                # chưa có file — hỏi Redis tiến độ job per cú (chỉ khi còn
                # hàng đợi: đọc đĩa thuần thì không có ai để hỏi)
                rs = None
                if live:
                    try:
                        rs = jobqueue.get_analyze_status(
                            astore.shot_analyze_id(video, idx))
                    except jobqueue.QueueDown:
                        rs = None
                if rs is None:
                    item["status"] = "unknown"
                    item["reason"] = ("Không còn dấu vết job phân tích cú "
                                      "này (không file kết quả, hết hạn "
                                      "hàng đợi) — worker có chết giữa "
                                      "chừng không?")
                elif rs.get("status") == "running":
                    item["status"] = "running"
                    item["progress"] = rs.get("progress")
                    item["stage"] = rs.get("stage")
                elif rs.get("status") == "error":
                    item["status"] = "error"
                    item["reason"] = rs.get("message")
                elif rs.get("status") == "done" and rs.get("result"):
                    # file chưa kịp/không ghi được nhưng Redis còn — vẫn
                    # phục vụ số (đừng bắt người dùng đợi một file hỏng)
                    _shot_summary(item, rs["result"])
            elif result.get("error"):
                item["status"] = "error"
                item["reason"] = result.get("message")
            else:
                _shot_summary(item, result)
        # overlay per cú (A2 phần 3): file trên đĩa là nguồn sự thật —
        # có file là có nút phát/tải, kể cả cú bị van gắn cờ
        if astore.overlay_path(vdir, idx).exists():
            item["overlay_url"] = (f"/api/analyzer/videos/{video}"
                                   f"/shots/{idx}/overlay")
        if item["status"] == "done":
            n_done += 1
        shots_out.append(AnalyzerShotItem(**item))

    info = AnalyzerVideoInfo(
        id=video, status="done",
        n_shots=len(shots_out),
        duration_s=manifest.get("duration_s"),
        warnings=manifest.get("warnings") or None)
    _sync_analyzer_db(video, "done", len(shots_out), shots_out)
    return AnalyzerShotsOut(video=info, shots=shots_out,
                            n_done=n_done, n_total=len(shots_out))


@app.get("/api/analyzer/shots", response_model=AnalyzerShotsOut,
         response_model_exclude_none=True)
def api_analyzer_shots(video: str):
    """Danh sách cú của MỘT video — hiện DẦN theo tiến độ (FE poll).
    Sync def (threadpool) vì lệnh Redis blocking — nếp api_analyze_status.
    Toàn bộ logic ở _collect_shots (dùng chung với export A2 phần 5)."""
    return _collect_shots(video)


_ANV_STATUS_VN = {"done": "xong", "running": "đang phân tích",
                  "queued": "chờ phân tích", "unknown": "mất dấu",
                  "error": "không phân tích được"}


@app.get("/api/analyzer/videos/{video_id}/export.csv")
def api_analyzer_export_csv(video_id: str):
    """Xuất CSV toàn danh sách cú (A2 phần 5) — đủ cột của bảng + cờ van
    + RMSE, mỗi cú một hàng KỂ CẢ cú lỗi/cú bị van gắn cờ (không lọc).
    Ghi **UTF-8 BOM** (utf-8-sig) — tiếng Việt mở trong Excel không vỡ
    font (BRIEF A2 phần 5); CRLF cho Excel. Cùng nguồn _collect_shots với
    bảng — hai nơi không thể lệch nhau."""
    import csv
    import io

    from fastapi.responses import Response

    out = _collect_shots(video_id)
    buf = io.StringIO()
    w = csv.writer(buf, lineterminator="\r\n")
    w.writerow(["# cú", "t_onset_s", "t_start_s", "t_end_s", "trạng thái",
                "V0 gậy model (m/s)", "φ model (°)", "a", "b",
                "spin model", "tin cậy model",
                "V0 bi analytic (m/s)", "φ analytic (°)", "spin analytic",
                "tin cậy analytic", "RMSE resim (mm)", "bộ params RMSE",
                "nghi không phải cú đánh", "lý do van",
                "số va chạm", "số cảnh báo", "lý do lỗi"])
    for s in out.shots:
        sn = s.shotnet
        sus = s.suspect_not_shot
        w.writerow([
            s.idx, s.t_onset_s, s.t_start_s, s.t_end_s,
            _ANV_STATUS_VN.get(s.status, s.status),
            sn.v0_cue_mps if sn else None,
            sn.phi_deg if sn else None,
            sn.a if sn else None, sn.b if sn else None,
            f"{sn.spin_vert}+{sn.spin_side}" if sn else None,
            sn.confidence if sn else None,
            s.v0_mps, s.phi_deg, s.spin_class, s.spin_confidence,
            s.rmse_mm, s.rmse_set,
            (None if sus is None else ("có" if sus.flagged else "không")),
            "; ".join(sus.reasons) if sus and sus.reasons else None,
            s.n_collisions, s.n_warnings, s.reason,
        ])
    return Response(
        content=buf.getvalue().encode("utf-8-sig"),
        media_type="text/csv; charset=utf-8",
        headers={"Content-Disposition":
                 f'attachment; filename="analyzer_{video_id[:8]}_shots.csv"'})


@app.get("/api/analyzer/videos/{video_id}/export.json")
def api_analyzer_export_json(video_id: str):
    """Xuất JSON toàn danh sách cú (A2 phần 5) — đúng body của GET /shots
    (cùng _collect_shots), trả dạng attachment để lưu file."""
    from fastapi.responses import JSONResponse

    out = _collect_shots(video_id)
    return JSONResponse(
        out.model_dump(exclude_none=True),
        headers={"Content-Disposition":
                 f'attachment; filename="analyzer_{video_id[:8]}_shots.json"'})


@app.get("/api/analyzer/videos/{video_id}/shots/{idx}/result")
def api_analyzer_shot_result(video_id: str, idx: int):
    """JSON kết quả THÔ của một cú — trang chi tiết lát A1 (design §6:
    "click ra JSON thô là đủ"; trang đẹp là A2)."""
    from fastapi.responses import JSONResponse

    if not _valid_video_id(video_id):
        raise HTTPException(422, "video_id phải là id 32 ký tự hex.")
    data = astore.read_json(
        astore.shot_json_path(_analyzer_video_dir(video_id), idx))
    if data is None:
        raise HTTPException(404, "Chưa có kết quả cho cú này — cú đang chờ "
                                 "phân tích, hoặc id/cú sai.")
    return JSONResponse(data)


@app.get("/api/analyzer/videos/{video_id}/shots/{idx}/thumb")
def api_analyzer_shot_thumb(video_id: str, idx: int):
    """Thumbnail frame đầu cú (worker lưu lúc scan) — cột đầu bảng §3.1."""
    from fastapi.responses import FileResponse

    if not _valid_video_id(video_id):
        raise HTTPException(422, "video_id phải là id 32 ký tự hex.")
    p = astore.thumb_path(_analyzer_video_dir(video_id), idx)
    if not p.exists():
        raise HTTPException(404, "Không có thumbnail cho cú này.")
    return FileResponse(p, media_type="image/jpeg")


@app.get("/api/analyzer/videos/{video_id}/shots/{idx}/overlay")
def api_analyzer_shot_overlay(video_id: str, idx: int):
    """overlay.mp4 per cú (lát A2 phần 3) — worker render trong job
    analyze, trước khi xoá clip. Trang chi tiết phát bằng <video>; nút
    tải dùng chính URL này (thuộc tính download same-origin). Chứa frame
    broadcast — tính năng local/demo, file nằm data/ ngoài git."""
    from fastapi.responses import FileResponse

    if not _valid_video_id(video_id):
        raise HTTPException(422, "video_id phải là id 32 ký tự hex.")
    p = astore.overlay_path(_analyzer_video_dir(video_id), idx)
    if not p.exists():
        raise HTTPException(404, "Chưa có overlay cho cú này — cú chưa "
                                 "phân tích xong, hoặc kết quả thuộc lần "
                                 "chạy trước A2 (overlay không render hồi "
                                 "tố được vì clip đã xoá).")
    return FileResponse(p, media_type="video/mp4")


# --------------------------------------------------------- /api/trajectory

def _trajectory_sync(balls, phi, v0, side, vert):
    """Một `simulate_shot_multi(render=True)` + trích quỹ đạo. Chạy trong
    executor (pooltool nặng, và `env_h` dùng chung nên phải nằm trong
    `_search_lock` như mọi lần sim khác).

    `validate_full` gọi ở đây chứ không ở route: nó là CÙNG cửa vào mà
    `recommend_v2` dùng, nên input rác cho ra ĐÚNG message tiếng Việt của
    `/api/recommend`, không phải một bộ message thứ hai.

    `min_disp=TRAJ_MIN_DISP` — đúng tham số `recommend_v2` dùng khi render;
    thiếu nó thì quỹ đạo cú thay thế có thêm 5-7 bi đứng yên mà cú rank 1
    không có, và G6.2 sẽ đỏ vì một khác biệt không ai muốn.
    """
    from poolcoach_rl.recommend import (TRAJ_MIN_DISP, extract_trajectories,
                                        simulate_shot_multi, validate_full)

    validate_full(state.env_h, balls)
    rr = simulate_shot_multi(state.env_h, balls, phi, v0, side, vert,
                             render=True)
    if rr is None:
        return None
    return (extract_trajectories(rr["system"], min_disp=TRAJ_MIN_DISP),
            rr["balls_final"])


@app.post("/api/trajectory", response_model=TrajectoryResponse)
async def api_trajectory(req: TrajectoryRequest):
    """Quỹ đạo của MỘT cú đã biết tham số — phần lười của `/api/recommend`.

    Không nhận `engine`/`alternatives`: đây không phải một lần search, nó là
    một lần sim. Cú nào đáng vẽ đã do `/api/recommend` quyết.

    GIỮ IN-PROCESS kể cả khi mode queue bật — QUYẾT ĐỊNH (BRIEF 04/08 bàn
    giao 14, việc 1.5), không phải bỏ sót: mỗi request đúng 1 sim nhẹ, còn
    search 99–198 sim của recommend mới đáng đẩy qua worker.
    """
    import numpy as np

    if state.boot_error:
        raise HTTPException(500, f"Server lỗi khởi động: {state.boot_error}")
    if not state.jit_ready or state.env_h is None:
        raise HTTPException(503, "Đang khởi động vật lý (JIT ~40s) — thử lại sau")

    balls = {bid: np.array([p.x, p.y]) for bid, p in req.balls.items()}
    call = functools.partial(_trajectory_sync, balls, req.phi, req.v0,
                             req.side, req.vert)
    loop = asyncio.get_running_loop()
    try:
        async with _search_lock:
            out = await loop.run_in_executor(None, call)
    except ValueError as e:   # validate_full: ngoài bàn / chồng bi / id lạ
        raise HTTPException(422, str(e)) from e

    if out is None:
        # pooltool ném → `simulate_shot_multi` trả None. Nói THẲNG là sim
        # hỏng; trả quỹ đạo rỗng 200 thì FE vẽ bàn trống và không ai biết vì sao.
        raise HTTPException(422, "Không mô phỏng được cú này (pooltool ném "
                                 "lỗi) — kiểm lại tham số cú.")
    traj, balls_final = out
    return TrajectoryResponse(
        trajectories=traj,
        balls_final={bid: _pt(xy) for bid, xy in balls_final.items()})


class NoCacheStaticFiles(StaticFiles):
    """StaticFiles + ``Cache-Control: no-cache`` cho ``.js``/``.css``/``.html``.

    Nguyên nhân gốc bug tối 30/07: ``index.html`` nạp ``app.js`` không
    cache-busting và mount này không set header → trình duyệt giữ ``app.js``
    cũ hàng giờ sau khi Space đã deploy bản mới (alt không có quỹ đạo cho tới
    Ctrl+F5). ``no-cache`` chứ KHÔNG phải ``no-store``: trình duyệt vẫn được
    giữ bản sao và revalidate 304 bằng ETag/Last-Modified (starlette sẵn có),
    chỉ cấm dùng bản cũ mà không hỏi server.

    Subclass thay vì middleware toàn app, cố ý: header chỉ thuộc về tầng
    static, response ``/api/*`` không việc gì phải đi qua thêm một lớp. Set
    trên CẢ response 304 (``file_response`` của starlette trả
    ``NotModifiedResponse`` với bộ header đã chốt TRƯỚC khi mình kịp chèn —
    nên phải chèn sau khi ``super()`` trả về, không phải trên FileResponse).
    """

    def file_response(self, full_path, stat_result, scope, status_code=200):
        resp = super().file_response(full_path, stat_result, scope,
                                     status_code)
        if str(full_path).lower().endswith((".js", ".css", ".html")):
            resp.headers["Cache-Control"] = "no-cache"
        return resp


# TV tại bàn (bàn giao 15): WS /display/{id} + POST /api/displays/pair.
# Router phải vào TRƯỚC mount static — mount "/" nuốt mọi path còn lại.
app.include_router(displays.router)

# FE static — mount SAU các route /api để không nuốt chúng
app.mount("/", NoCacheStaticFiles(directory=str(STATIC_DIR), html=True),
          name="static")