File size: 52,140 Bytes
bdbcf73
 
 
 
 
ea04e9e
bdbcf73
ea04e9e
 
66cec0d
ea04e9e
66cec0d
 
ea04e9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7eef37e
ea04e9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66cec0d
 
bdbcf73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51caca0
 
 
 
 
 
 
 
 
 
 
 
 
bdbcf73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea04e9e
bdbcf73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8b5a53
bdbcf73
 
c8b5a53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdbcf73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
# utils/db.py (top of file)
import os
import json
import certifi
from contextlib import contextmanager
from datetime import date, timedelta

# password hashing
import bcrypt

# --- Feature flag: DB off by default in frontend Space ---
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"

# Import mysql connector only when DB is enabled
MYSQL_AVAILABLE = False
if not DISABLE_DB:
    try:
        import mysql.connector  # provided by mysql-connector-python
        from mysql.connector import Error  # noqa: F401
        MYSQL_AVAILABLE = True
    except Exception:
        MYSQL_AVAILABLE = False  # will raise a friendly error if used

def _db_disabled_error():
    raise RuntimeError(
        "Database access is disabled in this frontend (DISABLE_DB=1). "
        "Route calls through your backend Space instead."
    )

def get_db_connection():
    if DISABLE_DB or not MYSQL_AVAILABLE:
        _db_disabled_error()
    ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
    ssl_ca = certifi.where() if ssl_enabled else None
    return mysql.connector.connect(
        host=os.getenv("TIDB_HOST"),
        port=int(os.getenv("TIDB_PORT", 4000)),
        user=os.getenv("TIDB_USER"),
        password=os.getenv("TIDB_PASSWORD"),
        database=os.getenv("TIDB_DATABASE", "fin_ed_agentic"),
        ssl_ca=ssl_ca,
        ssl_verify_cert=ssl_enabled,
        autocommit=True,
    )

@contextmanager
def cursor(dict_rows=True):
    if DISABLE_DB or not MYSQL_AVAILABLE:
        _db_disabled_error()
    conn = get_db_connection()
    try:
        cur = conn.cursor(dictionary=dict_rows)
        yield cur
        conn.commit()
    finally:
        cur.close()
        conn.close()



# password hashing
import bcrypt  

# ----------- label <-> slug mappers for UI selects -----------
COUNTRY_SLUG = {
    "Jamaica": "jamaica", "USA": "usa", "UK": "uk",
    "India": "india", "Canada": "canada", "Other": "other", "N/A": "na"
}
LEVEL_SLUG = {
    "Beginner": "beginner", "Intermediate": "intermediate", "Advanced": "advanced", "N/A": "na"
}
ROLE_SLUG = {"Student": "student", "Teacher": "teacher"}

def _slug(s: str) -> str:
    return (s or "").strip().lower()

def hash_password(plain: str) -> bytes:
    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt())

def verify_password(plain: str, hashed: bytes | None) -> bool:
    if not plain or not hashed:
        return False
    try:
        return bcrypt.checkpw(plain.encode("utf-8"), hashed)
    except Exception:
        return False

def _ensure_na_slugs():
    """
    Make sure 'na' exists in countries/levels for teacher rows.
    Harmless if already present.
    """
    with cursor() as cur:
        cur.execute("INSERT IGNORE INTO countries(slug,label) VALUES('na','N/A')")
        cur.execute("INSERT IGNORE INTO levels(slug,label) VALUES('na','N/A')")
 

# def get_db_connection():
#     ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
#     ssl_ca = certifi.where() if ssl_enabled else None
#     return mysql.connector.connect(
#         host=os.getenv("TIDB_HOST"),
#         port=int(os.getenv("TIDB_PORT", 4000)),
#         user=os.getenv("TIDB_USER"),
#         password=os.getenv("TIDB_PASSWORD"),
#         database=os.getenv("TIDB_DATABASE", "agenticfinance"),
#         ssl_ca=ssl_ca,
#         ssl_verify_cert=ssl_enabled,
#         autocommit=True,
#     )

@contextmanager
def cursor(dict_rows=True):
    conn = get_db_connection()
    try:
        cur = conn.cursor(dictionary=dict_rows)
        yield cur
        conn.commit()
    finally:
        cur.close()
        conn.close()

# ---------- USERS ----------
def create_user(name:str, email:str, country:str, level:str, role:str):
    
    slug = lambda s: s.strip().lower()
    with cursor() as cur:
        cur.execute("""
        INSERT INTO users(name,email,country_slug,level_slug,role_slug)
        VALUES (%s,%s,%s,%s,%s)
        """, (name, email.strip().lower(), slug(country), slug(level), slug(role)))
    return True

# role-specific creators
def create_student(*, name:str, email:str, password:str, level_label:str, country_label:str) -> bool:
    """
    level_label/country_label are UI labels (e.g., 'Beginner', 'Jamaica').
    """
    level_slug = LEVEL_SLUG.get(level_label, _slug(level_label))
    country_slug = COUNTRY_SLUG.get(country_label, _slug(country_label))
    with cursor() as cur:
        cur.execute("""
        INSERT INTO users (name,email,password_hash,title,country_slug,level_slug,role_slug)
        VALUES (%s,%s,%s,NULL,%s,%s,'student')
        """, (name.strip(), email.strip().lower(), hash_password(password), country_slug, level_slug))
    return True

def create_teacher(*, title:str, name:str, email:str, password:str) -> bool:
    """
    Teachers do not provide level/country; we store 'na' for both.
    """
    _ensure_na_slugs()
    with cursor() as cur:
        cur.execute("""
        INSERT INTO users (title,name,email,password_hash,country_slug,level_slug,role_slug)
        VALUES (%s,%s,%s,%s,'na','na','teacher')
        """, (title.strip(), name.strip(), email.strip().lower(), hash_password(password)))
    return True


def get_user_by_email(email:str):
    with cursor() as cur:
        cur.execute("""
        SELECT 
          u.user_id, u.title, u.name, u.email, u.password_hash,
          u.country_slug, c.label AS country,
          u.level_slug,   l.label AS level,
          u.role_slug,    r.label AS role
        FROM users u
        JOIN countries c ON c.slug = u.country_slug
        JOIN levels    l ON l.slug = u.level_slug
        JOIN roles     r ON r.slug = u.role_slug
        WHERE u.email=%s
        LIMIT 1
        """, (email.strip().lower(),))
        u = cur.fetchone()
        if not u:
            return None
        
        u["role"] = "Teacher" if u["role_slug"] == "teacher" else "Student"
        return u

def check_password(email: str, plain_password: str) -> dict | None:
    """
    Returns the user dict if password is correct, else None.
    """
    user = get_user_by_email(email)
    if not user:
        return None
    if verify_password(plain_password, user.get("password_hash")):
        return user
    return None


# ---------- CLASSES ----------
import random, string
def _code():
    return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))

def create_class(teacher_id:int, name:str):
    # ensure unique code
    for _ in range(20):
        code = _code()
        with cursor() as cur:
            cur.execute("SELECT 1 FROM classes WHERE code=%s", (code,))
            if not cur.fetchone():
                cur.execute("INSERT INTO classes(teacher_id,name,code) VALUES(%s,%s,%s)",
                            (teacher_id, name, code))
                cur.execute("SELECT LAST_INSERT_ID() AS id")
                cid = cur.fetchone()["id"]
                return {"class_id": cid, "code": code}
    raise RuntimeError("Could not generate unique class code")



def join_class_by_code(student_id:int, code:str):
    with cursor() as cur:
        cur.execute("SELECT class_id FROM classes WHERE code=%s", (code.strip().upper(),))
        row = cur.fetchone()
        if not row:
            raise ValueError("Invalid class code")
        cur.execute("INSERT IGNORE INTO class_students(class_id,student_id) VALUES(%s,%s)",
                    (row["class_id"], student_id))
        return row["class_id"]

def list_students_in_class(class_id:int):
    with cursor() as cur:
        cur.execute("""
        SELECT 
          u.user_id, u.name, u.email, u.level_slug, 
          cs.joined_at,          -- <- show true join date
          u.created_at
        FROM class_students cs 
        JOIN users u ON u.user_id = cs.student_id
        WHERE cs.class_id = %s
        ORDER BY u.name
        """, (class_id,))
        return cur.fetchall()

def class_analytics(class_id:int):
    """
    Returns:
      class_avg   -> 0..1 average quiz score for the class (from v_class_stats)
      total_xp    -> sum of xp_log.delta for students in this class
      lessons_completed -> count of completed lesson_progress entries for lessons assigned to this class
    """
    out = {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0}

    with cursor() as cur:
        # class average from view
        cur.execute("SELECT class_avg FROM v_class_stats WHERE class_id=%s", (class_id,))
        row = cur.fetchone()
        if row:
            out["class_avg"] = float(row["class_avg"] or 0)

        # total XP for all students in this class
        cur.execute("""
        SELECT COALESCE(SUM(x.delta),0) AS total_xp
        FROM xp_log x
        JOIN class_students cs ON cs.student_id = x.user_id
        WHERE cs.class_id = %s
        """, (class_id,))
        out["total_xp"] = int((cur.fetchone() or {"total_xp": 0})["total_xp"])

        # lessons completed that were actually assigned to this class
        cur.execute("""
        SELECT COUNT(*) AS n
        FROM lesson_progress lp
        JOIN class_students cs ON cs.student_id = lp.user_id
        JOIN assignments a ON a.lesson_id = lp.lesson_id
        WHERE cs.class_id = %s
          AND a.class_id = %s
          AND lp.status = 'completed'
        """, (class_id, class_id))
        out["lessons_completed"] = int((cur.fetchone() or {"n": 0})["n"])

    return out
    
# ---------- Teacher dash for real time data - Class Helpers  ----------
def class_content_counts(class_id:int):
    # counts of distinct lessons and quizzes assigned to this class
    with cursor() as cur:
        cur.execute("""
            SELECT 
              COUNT(DISTINCT lesson_id) AS lessons,
              COUNT(DISTINCT quiz_id)   AS quizzes
            FROM assignments
            WHERE class_id=%s
        """, (class_id,))
        row = cur.fetchone() or {"lessons": 0, "quizzes": 0}
    return row

def list_class_assignments(class_id:int):
    with cursor() as cur:
        cur.execute("""
            SELECT 
              a.assignment_id,
              a.created_at,
              l.lesson_id, l.title, l.subject, l.level,
              a.quiz_id
            FROM assignments a
            JOIN lessons l ON l.lesson_id = a.lesson_id
            WHERE a.class_id=%s
            ORDER BY a.created_at DESC
        """, (class_id,))
        return cur.fetchall()

def list_classes_by_teacher(teacher_id:int):
    with cursor() as cur:
        cur.execute("""
            SELECT s.*, c.code
            FROM v_class_stats s
            JOIN classes c USING (class_id)
            WHERE s.teacher_id=%s
            ORDER BY c.created_at DESC
        """, (teacher_id,))
        return cur.fetchall()

def get_class(class_id:int):
    with cursor() as cur:
        cur.execute("SELECT class_id, name, code, teacher_id FROM classes WHERE class_id=%s", (class_id,))
        return cur.fetchone()
    
def class_student_metrics(class_id: int):
    """
    Returns one row per student in the class with:
      name, email, joined_at, lessons_completed, total_assigned_lessons,
      avg_score (0..1), streak_days, total_xp
    """
    with cursor() as cur:
        cur.execute("""
        /* total assigned lessons for the class */
        WITH assigned AS (
          SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
        )
        SELECT
          cs.student_id,
          u.name,
          u.email,
          cs.joined_at,
          /* lessons completed by this student that were assigned to this class */
          COALESCE(
            (SELECT COUNT(*) FROM lesson_progress lp
             WHERE lp.user_id = cs.student_id
               AND lp.status = 'completed'
               AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
            ), 0
          ) AS lessons_completed,
          /* total lessons assigned to this class */
          (SELECT COUNT(*) FROM assigned) AS total_assigned_lessons,
          /* average quiz score only for submissions tied to this class */
          COALESCE(sc.avg_score, 0) AS avg_score,
          /* streak days from streaks table */
          COALESCE(str.days, 0) AS streak_days,
          /* total XP across the app */
          COALESCE(xp.total_xp, 0) AS total_xp
        FROM class_students cs
        JOIN users u ON u.user_id = cs.student_id
        LEFT JOIN (
          SELECT s.student_id, AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_score
          FROM submissions s
          JOIN assignments a ON a.assignment_id = s.assignment_id
          WHERE a.class_id = %s
          GROUP BY s.student_id
        ) sc ON sc.student_id = cs.student_id
        LEFT JOIN streaks str ON str.user_id = cs.student_id
        LEFT JOIN (SELECT user_id, SUM(delta) AS total_xp FROM xp_log GROUP BY user_id) xp
               ON xp.user_id = cs.student_id
        WHERE cs.class_id = %s
        ORDER BY u.name;
        """, (class_id, class_id, class_id))
        return cur.fetchall()

def level_from_xp(total_xp: int) -> int:
    try:
        xp = int(total_xp or 0)
    except Exception:
        xp = 0
    return 1 + xp // 500


def list_classes_for_student(student_id: int):
    with cursor() as cur:
        cur.execute("""
            SELECT c.class_id, c.name, c.code, c.teacher_id,
                   t.name AS teacher_name, cs.joined_at
            FROM class_students cs
            JOIN classes c ON c.class_id = cs.class_id
            JOIN users   t ON t.user_id = c.teacher_id
            WHERE cs.student_id = %s
            ORDER BY cs.joined_at DESC
        """, (student_id,))
        return cur.fetchall()

def leave_class(student_id: int, class_id: int):
    with cursor() as cur:
        cur.execute("DELETE FROM class_students WHERE student_id=%s AND class_id=%s",
                    (student_id, class_id))
    return True

def student_class_progress(student_id: int, class_id: int):
    """
    Per-student view of progress inside ONE class.
    Returns: dict(overall_progress 0..1, lessons_completed int,
                  total_assigned_lessons int, avg_score 0..1)
    """
    with cursor() as cur:
        # total distinct lessons assigned to this class
        cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s",
                    (class_id,))
        total_assigned = int((cur.fetchone() or {"n": 0})["n"])

        # lessons completed among the class's assigned lessons
        cur.execute("""
          WITH assigned AS (SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s)
          SELECT COUNT(*) AS n
          FROM lesson_progress lp
          WHERE lp.user_id = %s
            AND lp.status = 'completed'
            AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
        """, (class_id, student_id))
        completed = int((cur.fetchone() or {"n": 0})["n"])

        # student’s avg quiz score but only for submissions tied to this class
        cur.execute("""
          SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
          FROM submissions s
          JOIN assignments a ON a.assignment_id = s.assignment_id
          WHERE a.class_id = %s AND s.student_id = %s
        """, (class_id, student_id))
        avg_score = float((cur.fetchone() or {"avg_ratio": 0.0})["avg_ratio"] or 0.0)

    overall = (completed / float(total_assigned)) if total_assigned else 0.0
    return dict(
        overall_progress=overall,
        lessons_completed=completed,
        total_assigned_lessons=total_assigned,
        avg_score=avg_score
    )

def student_assignments_for_class(student_id: int, class_id: int):
    """
    All assignments in a class, annotated with THIS student's status/progress
    and (if applicable) their quiz score for that assignment.
    Deduplicates by lesson_id (keeps the most recent assignment per lesson).
    """
    with cursor() as cur:
        cur.execute("""
          SELECT
            a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
            a.quiz_id, a.due_at,
            COALESCE(lp.status,'not_started') AS status,
            lp.current_pos,
            /* student's latest submission on this assignment (if any) */
            (SELECT MAX(s.submitted_at) FROM submissions s
              WHERE s.assignment_id = a.assignment_id AND s.student_id = %s) AS last_submit_at,
            (SELECT s2.score FROM submissions s2
              WHERE s2.assignment_id = a.assignment_id AND s2.student_id = %s
              ORDER BY s2.submitted_at DESC LIMIT 1) AS score,
            (SELECT s3.total FROM submissions s3
              WHERE s3.assignment_id = a.assignment_id AND s3.student_id = %s
              ORDER BY s3.submitted_at DESC LIMIT 1) AS total
          FROM (
            SELECT
              a.*,
              ROW_NUMBER() OVER (
                PARTITION BY a.lesson_id
                ORDER BY a.created_at DESC, a.assignment_id DESC
              ) AS rn
            FROM assignments a
            WHERE a.class_id = %s
          ) AS a
          JOIN lessons l ON l.lesson_id = a.lesson_id
          LEFT JOIN lesson_progress lp
                 ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
          WHERE a.rn = 1
          ORDER BY a.created_at DESC
        """, (student_id, student_id, student_id, class_id, student_id))
        return cur.fetchall()


    

def update_quiz(quiz_id:int, teacher_id:int, title:str, items:list[dict], settings:dict|None=None) -> bool:
    with cursor() as cur:
        # only the teacher who owns the linked lesson can edit
        cur.execute("""
            SELECT 1
            FROM quizzes q
            JOIN lessons l ON l.lesson_id = q.lesson_id
            WHERE q.quiz_id = %s AND l.teacher_id = %s
            LIMIT 1
        """, (quiz_id, teacher_id))
        if not cur.fetchone():
            return False

        cur.execute("UPDATE quizzes SET title=%s, settings=%s WHERE quiz_id=%s",
                    (title, json.dumps(settings or {}), quiz_id))

        cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
        for i, it in enumerate(items, start=1):
            cur.execute("""
                INSERT INTO quiz_items(quiz_id, position, question, options, answer_key, points)
                VALUES (%s, %s, %s, %s, %s, %s)
            """, (
                quiz_id, i,
                it["question"],
                json.dumps(it.get("options", [])),
                json.dumps(it.get("answer_key")),  # single letter as JSON string
                int(it.get("points", 1))
            ))
    return True


def class_weekly_activity(class_id:int):
    start = date.today() - timedelta(days=6)
    with cursor() as cur:
        cur.execute("""
            SELECT DATE(lp.last_accessed) d, COUNT(*) n
            FROM lesson_progress lp
            JOIN class_students cs ON cs.student_id = lp.user_id
            WHERE cs.class_id=%s AND lp.last_accessed >= %s
            GROUP BY DATE(lp.last_accessed)
        """, (class_id, start))
        lessons = {r["d"]: r["n"] for r in cur.fetchall()}

        cur.execute("""
            SELECT DATE(s.submitted_at) d, COUNT(*) n
            FROM submissions s
            JOIN assignments a ON a.assignment_id = s.assignment_id
            WHERE a.class_id=%s AND s.submitted_at >= %s
            GROUP BY DATE(s.submitted_at)
        """, (class_id, start))
        quizzes = {r["d"]: r["n"] for r in cur.fetchall()}

        cur.execute("""
            SELECT DATE(g.started_at) d, COUNT(*) n
            FROM game_sessions g
            JOIN class_students cs ON cs.student_id = g.user_id
            WHERE cs.class_id=%s AND g.started_at >= %s
            GROUP BY DATE(g.started_at)
        """, (class_id, start))
        games = {r["d"]: r["n"] for r in cur.fetchall()}

    out = []
    for i in range(7):
        d = start + timedelta(days=i)
        out.append({
            "date": d,
            "lessons": lessons.get(d, 0),
            "quizzes": quizzes.get(d, 0),
            "games": games.get(d, 0),
        })
    return out




def update_lesson(lesson_id:int, teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]) -> bool:
    with cursor() as cur:
        # ownership check
        cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
        if not cur.fetchone():
            return False

        cur.execute("""
          UPDATE lessons
          SET title=%s, description=%s, subject=%s, level=%s
          WHERE lesson_id=%s AND teacher_id=%s
        """, (title, description, subject, level_slug, lesson_id, teacher_id))

        # simplest and safest: rebuild sections in order
        cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
        for i, sec in enumerate(sections, start=1):
            cur.execute("""
              INSERT INTO lesson_sections(lesson_id,position,title,content)
              VALUES(%s,%s,%s,%s)
            """, (lesson_id, i, sec.get("title"), sec.get("content")))
    return True


# --- Class progress overview (overall progress, quiz performance, totals)
def class_progress_overview(class_id: int):
    """
    Returns:
      {
        "overall_progress": 0..1,
        "quiz_performance": 0..1,
        "lessons_completed": int,
        "class_xp": int
      }
    """
    with cursor() as cur:
        # total distinct lessons assigned to this class
        cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", (class_id,))
        total_assigned = int((cur.fetchone() or {"n": 0})["n"])

        # number of enrolled students
        cur.execute("SELECT COUNT(*) AS n FROM class_students WHERE class_id=%s", (class_id,))
        num_students = int((cur.fetchone() or {"n": 0})["n"])

        # sum of completed lessons by all students (for assigned lessons)
        cur.execute("""
            WITH assigned AS (
              SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
            ), enrolled AS (
              SELECT student_id FROM class_students WHERE class_id = %s
            ), per_student AS (
              SELECT e.student_id,
                     COUNT(DISTINCT CASE
                       WHEN lp.status='completed' AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
                       THEN lp.lesson_id END) AS completed
              FROM enrolled e
              LEFT JOIN lesson_progress lp ON lp.user_id = e.student_id
              GROUP BY e.student_id
            )
            SELECT COALESCE(SUM(completed),0) AS total_completed
            FROM per_student
        """, (class_id, class_id))
        total_completed = int((cur.fetchone() or {"total_completed": 0})["total_completed"] or 0)

        # quiz performance: average percentage for submissions tied to this class
        cur.execute("""
            SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
            FROM submissions s
            JOIN assignments a ON a.assignment_id = s.assignment_id
            WHERE a.class_id = %s
        """, (class_id,))
        quiz_perf_row = cur.fetchone() or {"avg_ratio": 0}
        quiz_perf = float(quiz_perf_row["avg_ratio"] or 0)

        # total class XP (sum of xp for enrolled students)
        cur.execute("""
            SELECT COALESCE(SUM(x.delta),0) AS xp
            FROM xp_log x
            WHERE x.user_id IN (SELECT student_id FROM class_students WHERE class_id=%s)
        """, (class_id,))
        class_xp = int((cur.fetchone() or {"xp": 0})["xp"] or 0)

    if total_assigned and num_students:
        denominator = float(total_assigned * num_students)
        overall = float(total_completed) / denominator
    else:
        overall = 0.0

    return dict(
        overall_progress=float(overall),
        quiz_performance=float(quiz_perf),
        lessons_completed=int(total_completed),
        class_xp=int(class_xp),
    )

# --- Recent student activity with total_xp for level badge
def class_recent_activity(class_id:int, limit:int=6, days:int=30):
    """
    Returns latest activity rows with fields:
      ts, kind('lesson'|'quiz'|'game'), student_id, student_name, item_title, extra, total_xp
    """
    with cursor() as cur:
        cur.execute(f"""
            WITH enrolled AS (
              SELECT student_id FROM class_students WHERE class_id = %s
            ),
            xp AS (
              SELECT user_id, COALESCE(SUM(delta),0) AS total_xp
              FROM xp_log GROUP BY user_id
            )
            SELECT * FROM (
              /* completed lessons */
              SELECT lp.last_accessed AS ts,
                     'lesson'         AS kind,
                     u.user_id        AS student_id,
                     u.name           AS student_name,
                     l.title          AS item_title,
                     NULL             AS extra,
                     COALESCE(xp.total_xp,0) AS total_xp
              FROM lesson_progress lp
              JOIN enrolled e ON e.student_id = lp.user_id
              JOIN users u ON u.user_id = lp.user_id
              JOIN lessons l ON l.lesson_id = lp.lesson_id
              LEFT JOIN xp ON xp.user_id = u.user_id
              WHERE lp.status = 'completed' AND lp.last_accessed >= NOW() - INTERVAL {days} DAY

              UNION ALL

              /* quiz submissions */
              SELECT s.submitted_at AS ts,
                     'quiz'          AS kind,
                     u.user_id       AS student_id,
                     u.name          AS student_name,
                     l.title         AS item_title,
                     CONCAT(ROUND(s.score*100.0/NULLIF(s.total,0)),'%') AS extra,
                     COALESCE(xp.total_xp,0) AS total_xp
              FROM submissions s
              JOIN assignments a ON a.assignment_id = s.assignment_id AND a.class_id = %s
              JOIN users u ON u.user_id = s.student_id
              JOIN lessons l ON l.lesson_id = a.lesson_id
              LEFT JOIN xp ON xp.user_id = u.user_id
              WHERE s.submitted_at >= NOW() - INTERVAL {days} DAY

              UNION ALL

              /* games */
              SELECT g.started_at AS ts,
                     'game'        AS kind,
                     u.user_id     AS student_id,
                     u.name        AS student_name,
                     g.game_slug   AS item_title,
                     NULL          AS extra,
                     COALESCE(xp.total_xp,0) AS total_xp
              FROM game_sessions g
              JOIN enrolled e ON e.student_id = g.user_id
              JOIN users u ON u.user_id = g.user_id
              LEFT JOIN xp ON xp.user_id = u.user_id
              WHERE g.started_at >= NOW() - INTERVAL {days} DAY
            ) x
            ORDER BY ts DESC
            LIMIT %s
        """, (class_id, class_id, limit))
        return cur.fetchall()



def list_quizzes_by_teacher(teacher_id:int):
    with cursor() as cur:
        cur.execute("""
            SELECT q.quiz_id, q.title, q.created_at,
                   l.title AS lesson_title,
                   (SELECT COUNT(*) FROM quiz_items qi WHERE qi.quiz_id=q.quiz_id) AS num_items
            FROM quizzes q
            JOIN lessons l ON l.lesson_id=q.lesson_id
            WHERE l.teacher_id=%s
            ORDER BY q.created_at DESC
        """, (teacher_id,))
        return cur.fetchall()

def list_all_students_for_teacher(teacher_id:int):
    with cursor() as cur:
        cur.execute("""
            SELECT DISTINCT u.user_id, u.name, u.email
            FROM classes c
            JOIN class_students cs ON cs.class_id=c.class_id
            JOIN users u ON u.user_id=cs.student_id
            WHERE c.teacher_id=%s
            ORDER BY u.name
        """, (teacher_id,))
        return cur.fetchall()

# ----- ASSIGNEES (students) -----

def list_assigned_students_for_lesson(lesson_id:int):
    with cursor() as cur:
        cur.execute("""
            WITH direct AS (
              SELECT student_id FROM assignments
              WHERE lesson_id=%s AND student_id IS NOT NULL
            ),
            via_class AS (
              SELECT cs.student_id
              FROM assignments a
              JOIN class_students cs ON cs.class_id=a.class_id
              WHERE a.lesson_id=%s AND a.class_id IS NOT NULL
            ),
            all_students AS (
              SELECT student_id FROM direct
              UNION
              SELECT student_id FROM via_class
            )
            SELECT u.user_id, u.name, u.email
            FROM users u
            JOIN all_students s ON s.student_id=u.user_id
            ORDER BY u.name
        """, (lesson_id, lesson_id))
        return cur.fetchall()

def list_assigned_students_for_quiz(quiz_id:int):
    with cursor() as cur:
        cur.execute("""
            WITH direct AS (
              SELECT student_id FROM assignments
              WHERE quiz_id=%s AND student_id IS NOT NULL
            ),
            via_class AS (
              SELECT cs.student_id
              FROM assignments a
              JOIN class_students cs ON cs.class_id=a.class_id
              WHERE a.quiz_id=%s AND a.class_id IS NOT NULL
            ),
            all_students AS (
              SELECT student_id FROM direct
              UNION
              SELECT student_id FROM via_class
            )
            SELECT u.user_id, u.name, u.email
            FROM users u
            JOIN all_students s ON s.student_id=u.user_id
            ORDER BY u.name
        """, (quiz_id, quiz_id))
        return cur.fetchall()

# ----- ASSIGN ACTIONS -----

def assign_lesson_to_students(lesson_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
    # bulk insert; quiz_id stays NULL
    with cursor() as cur:
        for sid in student_ids:
            cur.execute("""
              INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
              VALUES(%s, NULL, %s, %s, %s)
              ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
            """, (lesson_id, sid, teacher_id, due_at))
    return True

def assign_quiz_to_students(quiz_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
    # get lesson_id for integrity
    with cursor() as cur:
        cur.execute("SELECT lesson_id FROM quizzes WHERE quiz_id=%s", (quiz_id,))
        row = cur.fetchone()
        if not row:
            raise ValueError("Quiz not found")
        lesson_id = row["lesson_id"]
        for sid in student_ids:
            cur.execute("""
              INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
              VALUES(%s, %s, %s, %s, %s)
              ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
            """, (lesson_id, quiz_id, sid, teacher_id, due_at))
    return True

# ----- SAFE DELETE -----

def delete_lesson(lesson_id:int, teacher_id:int):
    with cursor() as cur:
        # ownership check
        cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
        if not cur.fetchone():
            return False, "You can only delete  own lesson."
        # block if assigned or quizzed
        cur.execute("SELECT COUNT(*) AS n FROM assignments WHERE lesson_id=%s", (lesson_id,))
        if cur.fetchone()["n"] > 0:
            return False, "Remove assignments first."
        cur.execute("SELECT COUNT(*) AS n FROM quizzes WHERE lesson_id=%s", (lesson_id,))
        if cur.fetchone()["n"] > 0:
            return False, "Delete quizzes for this lesson first."
        # delete sections then lesson
        cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
        cur.execute("DELETE FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
        return True, "Deleted."

def delete_quiz(quiz_id:int, teacher_id:int):
    with cursor() as cur:
        cur.execute("""
            SELECT 1
            FROM quizzes q JOIN lessons l ON l.lesson_id=q.lesson_id
            WHERE q.quiz_id=%s AND l.teacher_id=%s
        """, (quiz_id, teacher_id))
        if not cur.fetchone():
            return False, "You can only delete  own quiz."
        cur.execute("SELECT COUNT(*) AS n FROM submissions WHERE quiz_id=%s", (quiz_id,))
        if cur.fetchone()["n"] > 0:
            return False, "This quiz has submissions. Deleting is blocked."
        cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
        cur.execute("DELETE FROM assignments WHERE quiz_id=%s", (quiz_id,))  
        cur.execute("DELETE FROM quizzes WHERE quiz_id=%s", (quiz_id,))
        return True, "Deleted."
    

def _bump_game_stats(user_id:int, slug:str, *, gained_xp:int, matched:int|None=None, level_inc:int=0):
    with cursor() as cur:
        cur.execute("""
            INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
            VALUES(%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              matches  = matches + VALUES(matches),
              level    = GREATEST(level, VALUES(level))
        """, (user_id, slug, int(gained_xp), int(matched or 1), level_inc))    

# ---------- LESSONS ----------
def create_lesson(teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]):
    with cursor() as cur:
        cur.execute("""
          INSERT INTO lessons(teacher_id,title,description,subject,level,duration_min)
          VALUES(%s,%s,%s,%s,%s,%s)
        """, (teacher_id, title, description, subject, level_slug, 60))
        cur.execute("SELECT LAST_INSERT_ID() AS id")
        lesson_id = cur.fetchone()["id"]
        for i, sec in enumerate(sections, start=1):
            cur.execute("""
              INSERT INTO lesson_sections(lesson_id,position,title,content)
              VALUES(%s,%s,%s,%s)
            """, (lesson_id, i, sec.get("title"), sec.get("content")))
    return lesson_id

def list_lessons_by_teacher(teacher_id:int):
    with cursor() as cur:
        cur.execute("SELECT * FROM lessons WHERE teacher_id=%s ORDER BY created_at DESC", (teacher_id,))
        return cur.fetchall()

def get_lesson(lesson_id:int):
    with cursor() as cur:
        cur.execute("SELECT * FROM lessons WHERE lesson_id=%s", (lesson_id,))
        lesson = cur.fetchone()
        cur.execute("SELECT * FROM lesson_sections WHERE lesson_id=%s ORDER BY position", (lesson_id,))
        sections = cur.fetchall()
    return {"lesson": lesson, "sections": sections}

# ---------- QUIZZES ----------
def create_quiz(lesson_id:int, title:str, items:list[dict], settings:dict|None=None):
    with cursor() as cur:
        cur.execute("INSERT INTO quizzes(lesson_id,title,settings) VALUES(%s,%s,%s)",
                    (lesson_id, title, json.dumps(settings or {})))
        cur.execute("SELECT LAST_INSERT_ID() AS id")
        quiz_id = cur.fetchone()["id"]
        for i, it in enumerate(items, start=1):
            cur.execute("""
            INSERT INTO quiz_items(quiz_id,position,question,options,answer_key,points)
            VALUES(%s,%s,%s,%s,%s,%s)
            """, (quiz_id, i, it["question"], json.dumps(it.get("options", [])),
                  json.dumps(it.get("answer_key")), int(it.get("points", 1))))
    return quiz_id

def get_quiz(quiz_id:int):
    with cursor() as cur:
        cur.execute("SELECT * FROM quizzes WHERE quiz_id=%s", (quiz_id,))
        quiz = cur.fetchone()
        cur.execute("SELECT * FROM quiz_items WHERE quiz_id=%s ORDER BY position", (quiz_id,))
        items = cur.fetchall()
    return {"quiz": quiz, "items": items}

# ---------- ASSIGNMENTS ----------
def assign_to_class(lesson_id:int, quiz_id:int|None, class_id:int, teacher_id:int, due_at:str|None=None):
    with cursor() as cur:
        cur.execute("""
          INSERT INTO assignments(lesson_id,quiz_id,class_id,assigned_by,due_at)
          VALUES(%s,%s,%s,%s,%s)
        """, (lesson_id, quiz_id, class_id, teacher_id, due_at))
        cur.execute("SELECT LAST_INSERT_ID() AS id")
        return cur.fetchone()["id"]

def assign_to_student(lesson_id:int, quiz_id:int|None, student_id:int, teacher_id:int, due_at:str|None=None):
    with cursor() as cur:
        cur.execute("""
          INSERT INTO assignments(lesson_id,quiz_id,student_id,assigned_by,due_at)
          VALUES(%s,%s,%s,%s,%s)
        """, (lesson_id, quiz_id, student_id, teacher_id, due_at))
        cur.execute("SELECT LAST_INSERT_ID() AS id")
        return cur.fetchone()["id"]

def list_assignments_for_student(student_id:int):
    with cursor() as cur:
        cur.execute("""
        SELECT
            a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
            a.quiz_id, a.due_at,
            COALESCE(lp.status,'not_started') AS status,
            lp.current_pos
        FROM (
            SELECT
                a.*,
                ROW_NUMBER() OVER (
                    PARTITION BY a.lesson_id
                    ORDER BY a.created_at DESC, a.assignment_id DESC
                ) AS rn
            FROM assignments a
            WHERE a.student_id = %s
               OR a.class_id IN (SELECT class_id FROM class_students WHERE student_id = %s)
        ) AS a
        JOIN lessons l ON l.lesson_id = a.lesson_id
        LEFT JOIN lesson_progress lp
               ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
        WHERE a.rn = 1
        ORDER BY a.created_at DESC
        """, (student_id, student_id, student_id))
        return cur.fetchall()


# ---------- PROGRESS and SUBMISSIONS ----------
def save_progress(user_id:int, lesson_id:int, current_pos:int, status:str):
    with cursor() as cur:
        cur.execute("""
        INSERT INTO lesson_progress(user_id,lesson_id,current_pos,status)
        VALUES(%s,%s,%s,%s)
        ON DUPLICATE KEY UPDATE current_pos=VALUES(current_pos), status=VALUES(status)
        """, (user_id, lesson_id, current_pos, status))
    return True

def submit_quiz(student_id:int, assignment_id:int, quiz_id:int, score:int, total:int, details:dict):
    with cursor() as cur:
        cur.execute("""
        INSERT INTO submissions(assignment_id,quiz_id,student_id,score,total,details)
        VALUES(%s,%s,%s,%s,%s,%s)
        ON DUPLICATE KEY UPDATE score=VALUES(score), total=VALUES(total), details=VALUES(details), submitted_at=CURRENT_TIMESTAMP
        """, (assignment_id, quiz_id, student_id, score, total, json.dumps(details)))
    
    return True

# ---------- DASHBOARD SHORTCUTS ----------
def teacher_tiles(teacher_id:int):
    with cursor() as cur:
        cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,))
        rows = cur.fetchall()
    total_students = sum(r["total_students"] for r in rows)
    lessons_created = _count_lessons(teacher_id)
    # use simple averages; adjust later as needed
    class_avg = round(sum(r["class_avg"] for r in rows)/len(rows), 2) if rows else 0
    active_students = sum(1 for r in rows if r.get("recent_submissions",0) > 0)
    return dict(total_students=total_students, class_avg=class_avg, lessons_created=lessons_created, active_students=active_students)

def _count_lessons(teacher_id:int):
    with cursor() as cur:
        cur.execute("SELECT COUNT(*) AS n FROM lessons WHERE teacher_id=%s", (teacher_id,))
        return cur.fetchone()["n"]


# --- XP and streak helpers ---
def user_xp_and_level(user_id: int, base: int = 500):
    with cursor() as cur:
        cur.execute("SELECT COALESCE(SUM(delta),0) AS xp FROM xp_log WHERE user_id=%s", (user_id,))
        xp = int((cur.fetchone() or {"xp": 0})["xp"])
        cur.execute("SELECT COALESCE(days,0) AS days FROM streaks WHERE user_id=%s", (user_id,))
        streak = int((cur.fetchone() or {"days": 0})["days"])

    # level math
    level = max(1, xp // base + 1)
    start_of_level = (level - 1) * base
    into = xp - start_of_level
    need = base
    # exact boundary should flip level and reset progress
    if into == need:
        level += 1
        into = 0

    return {
        "xp": xp,          # lifetime XP from the DB
        "level": level,    # current level
        "into": into,      # XP inside this level
        "need": need,      # XP needed to reach next level
        "streak": streak,
    }

def recent_lessons_for_student(user_id:int, limit:int=5):
    with cursor() as cur:
        cur.execute("""
            SELECT l.title, 
                   CASE WHEN lp.status='completed' THEN 100
                        WHEN lp.current_pos IS NULL THEN 0 
                        ELSE LEAST(95, lp.current_pos * 10) 
                   END AS progress
            FROM lessons l
            LEFT JOIN lesson_progress lp
              ON lp.lesson_id=l.lesson_id AND lp.user_id=%s
            WHERE l.lesson_id IN (
                SELECT lesson_id FROM assignments 
                WHERE student_id=%s
                   OR class_id IN (SELECT class_id FROM class_students WHERE student_id=%s)
            )
            ORDER BY l.created_at DESC
            LIMIT %s
        """, (user_id, user_id, user_id, limit))
        return cur.fetchall()

def student_quiz_average(student_id: int) -> int:
    """
    Returns the student's average quiz percentage (0–100) using the latest
    submission per quiz from the `submissions` table.
    """
    with cursor() as cur:
        cur.execute("""
            WITH latest AS (
              SELECT quiz_id, MAX(submitted_at) AS last_ts
              FROM submissions
              WHERE student_id = %s
              GROUP BY quiz_id
            )
            SELECT ROUND(AVG(s.score * 100.0 / NULLIF(s.total,0))) AS pct
            FROM latest t
            JOIN submissions s
              ON s.quiz_id = t.quiz_id
             AND s.submitted_at = t.last_ts
            WHERE s.student_id = %s
        """, (student_id, student_id))
        row = cur.fetchone() or {}
    return int(row.get("pct") or 0)

# --- Generic XP bump and streak touch ---
def add_xp(user_id:int, delta:int, source:str, meta:dict|None=None):
    with cursor() as cur:
        cur.execute(
            "INSERT INTO xp_log(user_id,source,delta,meta) VALUES(%s,%s,%s,%s)",
            (user_id, source, int(delta), json.dumps(meta or {}))
        )
        # streak touch
        cur.execute("SELECT days, last_active FROM streaks WHERE user_id=%s", (user_id,))
        row = cur.fetchone()
        today = date.today()
        if not row:
            cur.execute("INSERT INTO streaks(user_id,days,last_active) VALUES(%s,%s,%s)", (user_id, 1, today))
        else:
            last = row["last_active"]
            days = int(row["days"] or 0)
            if last is None or last < today:
                # if we missed a day, reset to 1 else +1
                if last and (today - last) > timedelta(days=1):
                    days = 1
                else:
                    days = max(1, days + 1)
                cur.execute("UPDATE streaks SET days=%s,last_active=%s WHERE user_id=%s", (days, today, user_id))

# -- leaderboard helpders ---

def leaderboard_for_class(class_id: int, limit: int = 10):
    """
    Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
    Sorted by XP (desc) for students in a specific class.
    """
    with cursor() as cur:
        cur.execute("""
            SELECT 
              u.user_id,
              u.name,
              COALESCE(x.total_xp, 0) AS xp
            FROM class_students cs
            JOIN users u ON u.user_id = cs.student_id
            LEFT JOIN (
              SELECT user_id, SUM(delta) AS total_xp
              FROM xp_log
              GROUP BY user_id
            ) x ON x.user_id = u.user_id
            WHERE cs.class_id = %s
            ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
            LIMIT %s
        """, (class_id, limit))
        rows = cur.fetchall() or []
    # attach levels using  curve
    for r in rows:
        r["level"] = level_from_xp(r.get("xp", 0))
    return rows


def leaderboard_global(limit: int = 10):
    """
    Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
    Top students across the whole app by XP.
    """
    with cursor() as cur:
        cur.execute("""
            SELECT 
              u.user_id,
              u.name,
              COALESCE(x.total_xp, 0) AS xp
            FROM users u
            LEFT JOIN (
              SELECT user_id, SUM(delta) AS total_xp
              FROM xp_log
              GROUP BY user_id
            ) x ON x.user_id = u.user_id
            WHERE u.role_slug = 'student'
            ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
            LIMIT %s
        """, (limit,))
        rows = cur.fetchall() or []
    for r in rows:
        r["level"] = level_from_xp(r.get("xp", 0))
    return rows




# --- Game logging helpers ---
def record_money_match_play(user_id:int, *, target:int, total:int, elapsed_ms:int, matched:bool, gained_xp:int):
    with cursor() as cur:
        cur.execute("""
            INSERT INTO game_sessions(user_id,game_slug,target,total,elapsed_ms,matched,gained_xp,ended_at)
            VALUES(%s,'money_match',%s,%s,%s,%s,%s,NOW())
        """, (user_id, target, total, elapsed_ms, 1 if matched else 0, gained_xp))
        cur.execute("""
            INSERT INTO money_match_history(user_id,target,total,elapsed_ms,gained_xp,matched)
            VALUES(%s,%s,%s,%s,%s,%s)
        """, (user_id, target, total, elapsed_ms, gained_xp, 1 if matched else 0))
        cur.execute("""
            INSERT INTO money_match_stats(user_id,total_xp,matches,best_time_ms,best_target)
            VALUES(%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              matches  = matches  + VALUES(matches),
              best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms)),
              best_target  = COALESCE(best_target, VALUES(best_target))
        """, (user_id, gained_xp, 1 if matched else 0, elapsed_ms if matched else None, target if matched else None))

    _bump_game_stats(user_id, "money_match", gained_xp=gained_xp, matched=1 if matched else 0)
    add_xp(user_id, gained_xp, "game", {"game":"money_match","target":target,"total":total,"elapsed_ms":elapsed_ms,"matched":matched})

def record_budget_builder_save(user_id:int, *, weekly_allowance:int, allocations:list[dict]):
    total_allocated = sum(int(x.get("amount",0)) for x in allocations)
    remaining = int(weekly_allowance) - total_allocated
    gained_xp = 150 if remaining == 0 else 100 if remaining > 0 else 50
    with cursor() as cur:
        cur.execute("""
            INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
            VALUES(%s,'budget_builder',%s,NOW())
        """, (user_id, gained_xp))
        cur.execute("""
            INSERT INTO budget_builder_history(user_id,weekly_allowance,allocations,total_allocated,remaining,gained_xp)
            VALUES(%s,%s,%s,%s,%s,%s)
        """, (user_id, weekly_allowance, json.dumps(allocations), total_allocated, remaining, gained_xp))
        cur.execute("""
            INSERT INTO budget_builder_stats(user_id,total_xp,plays,best_balance)
            VALUES(%s,%s,1,%s)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              plays    = plays + 1,
              best_balance = GREATEST(COALESCE(best_balance, 0), VALUES(best_balance))
        """, (user_id, gained_xp, remaining))

    _bump_game_stats(user_id, "budget_builder", gained_xp=gained_xp, matched=1)
    add_xp(user_id, gained_xp, "game", {"game":"budget_builder","remaining":remaining})

def record_debt_dilemma_round(
    user_id:int, *,
    level:int, round_no:int,
    wallet:int, health:int, happiness:int, credit_score:int,
    event_json:dict, outcome:str, gained_xp:int
):
    with cursor() as cur:
        cur.execute("""
            INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
            VALUES(%s,'debt_dilemma',%s,NOW())
        """, (user_id, gained_xp))
        cur.execute("""
            INSERT INTO debt_dilemma_history(user_id,level,round_no,wallet,health,happiness,credit_score,event_json,outcome,gained_xp)
            VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
        """, (user_id, level, round_no, wallet, health, happiness, credit_score, json.dumps(event_json or {}), outcome, gained_xp))
        cur.execute("""
            INSERT INTO debt_dilemma_stats(user_id,total_xp,plays,highest_level,last_outcome)
            VALUES(%s,%s,1,%s,%s)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              plays    = plays + 1,
              highest_level = GREATEST(COALESCE(highest_level,0), VALUES(highest_level)),
              last_outcome  = VALUES(last_outcome)
        """, (user_id, gained_xp, level, outcome))

    # Treat a completed month/level as a "match"
    _bump_game_stats(user_id, "debt_dilemma", gained_xp=gained_xp, matched=1, level_inc=level)
    add_xp(user_id, gained_xp, "game", {
        "game":"debt_dilemma","level":level,"round":round_no,"outcome":outcome
    })


def record_profit_puzzle_result(
    user_id:int, *,
    scenario_id:str,
    title:str,
    units:int, price:int, cost:int,
    user_answer:float, actual_profit:float,
    is_correct:bool, gained_xp:int
):
    with cursor() as cur:
        # generic session row for cross-game views
        cur.execute("""
            INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
            VALUES(%s,'profit_puzzle',%s,NOW())
        """, (user_id, int(gained_xp)))

        # detailed history
        cur.execute("""
            INSERT INTO profit_puzzle_history
              (user_id,scenario_id,title,units,price,cost,user_answer,actual_profit,is_correct,gained_xp)
            VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
        """, (user_id, scenario_id, title, int(units), int(price), int(cost),
              float(user_answer), float(actual_profit), 1 if is_correct else 0, int(gained_xp)))

        # per-game stats
        cur.execute("""
            INSERT INTO profit_puzzle_stats(user_id,total_xp,plays,correct,last_score)
            VALUES(%s,%s,1,%s,%s)
            ON DUPLICATE KEY UPDATE
              total_xp   = total_xp + VALUES(total_xp),
              plays      = plays + 1,
              correct    = correct + VALUES(correct),
              last_score = VALUES(last_score),
              last_played = CURRENT_TIMESTAMP
        """, (user_id, int(gained_xp), 1 if is_correct else 0, int(gained_xp)))

        # game_stats rollup like  other games
        cur.execute("""
            INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
            VALUES(%s,'profit_puzzle',%s,%s,1)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              matches  = matches + VALUES(matches)
        """, (user_id, int(gained_xp), 1 if is_correct else 0))

    # global XP and streak
    add_xp(user_id, int(gained_xp), "game",
           {"game":"profit_puzzle","scenario":scenario_id,"correct":bool(is_correct)})

    # --- Profit Puzzle logging ---
def record_profit_puzzle_progress(user_id:int, *, scenario_title:str, correct:bool, gained_xp:int):
    """
    Log a Profit Puzzle step and bump XP.
    - Writes to generic game_sessions and game_stats
    - Writes to xp_log via add_xp
    """
    with cursor() as cur:
        # session line item
        cur.execute("""
            INSERT INTO game_sessions(user_id, game_slug, gained_xp, ended_at)
            VALUES(%s, 'profit_puzzle', %s, NOW())
        """, (user_id, int(gained_xp)))

        # aggregate by game
        cur.execute("""
            INSERT INTO game_stats(user_id, game_slug, total_xp, matches, level)
            VALUES(%s, 'profit_puzzle', %s, %s, 1)
            ON DUPLICATE KEY UPDATE
              total_xp = total_xp + VALUES(total_xp),
              matches  = matches + VALUES(matches)
        """, (user_id, int(gained_xp), 1 if correct else 0))

    add_xp(
        user_id,
        int(gained_xp),
        "game",
        {"game": "profit_puzzle", "scenario": scenario_title, "correct": bool(correct)}
    )