File size: 97,082 Bytes
35a451f
d99d6ac
 
 
 
35a451f
 
7bb8b22
 
e99da7d
7bb8b22
e99da7d
d99d6ac
 
 
 
 
35a451f
 
 
 
 
d99d6ac
35a451f
d99d6ac
 
 
35a451f
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ef0814
6878841
 
 
 
 
c24834c
 
 
 
 
 
 
8a595a2
 
 
 
 
 
 
 
 
 
 
 
c24834c
 
07371a7
c24834c
 
 
07371a7
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07371a7
c24834c
b706b85
 
 
c24834c
 
 
 
 
 
 
 
 
 
3bbeb18
 
 
 
 
 
 
 
c24834c
 
e99da7d
 
 
298d773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e99da7d
 
 
 
 
 
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b706b85
 
 
 
 
 
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e99da7d
 
 
 
 
 
 
 
 
 
 
 
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
e99da7d
 
d99d6ac
 
 
 
 
 
 
 
 
8a595a2
d99d6ac
 
8a595a2
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
e99da7d
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e99da7d
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
e99da7d
 
 
 
d99d6ac
 
 
 
 
 
e99da7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298d773
d99d6ac
 
 
 
3bbeb18
 
 
d99d6ac
 
298d773
 
d99d6ac
c24834c
 
d99d6ac
b706b85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bbeb18
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
3bbeb18
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bbeb18
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
3bbeb18
 
 
 
d99d6ac
3bbeb18
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e99da7d
d99d6ac
 
 
357dbea
0a62151
d99d6ac
 
 
 
b0b7e2a
d99d6ac
 
 
 
 
e99da7d
d99d6ac
b0b7e2a
d99d6ac
357dbea
d99d6ac
 
35a451f
 
d99d6ac
c24834c
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
c24834c
d99d6ac
 
c24834c
 
 
 
 
d99d6ac
 
 
 
 
 
c24834c
d99d6ac
 
 
 
 
 
 
 
c24834c
d99d6ac
c24834c
35a451f
 
d99d6ac
c24834c
d99d6ac
b706b85
 
 
 
 
 
d99d6ac
 
 
c24834c
d99d6ac
 
c24834c
d99d6ac
e99da7d
d99d6ac
 
e99da7d
c24834c
8a595a2
 
d99d6ac
 
e99da7d
 
 
 
 
 
 
 
 
d99d6ac
c24834c
d99d6ac
e99da7d
d99d6ac
e99da7d
d99d6ac
 
 
e99da7d
 
 
 
 
 
 
 
 
 
 
298d773
e99da7d
d99d6ac
 
 
 
 
 
 
 
298d773
 
 
d99d6ac
e99da7d
d99d6ac
b706b85
 
 
 
 
c24834c
d99d6ac
 
 
 
 
298d773
 
 
 
d99d6ac
e99da7d
d99d6ac
 
b706b85
 
 
 
 
e99da7d
 
d99d6ac
 
c24834c
d99d6ac
 
 
 
 
 
c24834c
d99d6ac
 
298d773
d99d6ac
c24834c
d99d6ac
c24834c
d99d6ac
c24834c
d99d6ac
c24834c
298d773
d99d6ac
 
 
 
 
c24834c
d99d6ac
c24834c
35a451f
d99d6ac
35a451f
298d773
b0b7e2a
d99d6ac
c24834c
 
 
b0b7e2a
c24834c
d99d6ac
35a451f
 
 
d99d6ac
298d773
 
35a451f
3bbeb18
d99d6ac
c24834c
3bbeb18
 
d99d6ac
 
 
 
 
 
 
c24834c
35a451f
 
d99d6ac
 
c24834c
d99d6ac
e99da7d
d99d6ac
 
e99da7d
c24834c
8a595a2
 
d99d6ac
 
e99da7d
 
 
 
d99d6ac
c24834c
35a451f
e99da7d
d99d6ac
e99da7d
d99d6ac
 
 
 
 
e99da7d
 
 
 
 
 
298d773
e99da7d
d99d6ac
 
 
 
 
 
 
 
 
298d773
 
 
d99d6ac
e99da7d
d99d6ac
 
e99da7d
 
d99d6ac
c24834c
298d773
d99d6ac
b0b7e2a
c24834c
d99d6ac
c24834c
d99d6ac
 
 
 
 
 
 
 
298d773
b0b7e2a
c24834c
d99d6ac
c24834c
d99d6ac
 
 
298d773
d99d6ac
c24834c
d99d6ac
 
e99da7d
d99d6ac
35a451f
 
d99d6ac
 
 
c24834c
d99d6ac
 
 
298d773
b0b7e2a
d99d6ac
35a451f
c24834c
d99d6ac
 
 
 
e99da7d
 
d99d6ac
 
 
e99da7d
 
 
 
 
 
 
 
 
298d773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
c24834c
 
d99d6ac
 
 
 
c24834c
 
 
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
f562180
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c24834c
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35a451f
c24834c
 
35a451f
d99d6ac
 
 
 
c24834c
 
 
 
d99d6ac
c24834c
d99d6ac
 
c24834c
d99d6ac
c24834c
8a595a2
 
d99d6ac
 
 
 
 
 
 
c24834c
d99d6ac
35a451f
 
 
d99d6ac
c24834c
d99d6ac
 
c24834c
 
 
 
 
 
 
d99d6ac
c24834c
b706b85
d99d6ac
c24834c
d99d6ac
 
 
35a451f
 
 
d99d6ac
c24834c
d99d6ac
 
c24834c
 
b0b7e2a
c24834c
 
d99d6ac
c24834c
 
 
298d773
d99d6ac
 
 
 
c24834c
d99d6ac
 
 
 
 
298d773
 
 
 
 
e99da7d
 
c24834c
d99d6ac
c24834c
d99d6ac
 
 
 
 
 
 
 
 
c24834c
d99d6ac
 
 
 
 
c24834c
d99d6ac
 
 
 
 
 
 
 
c24834c
d99d6ac
c24834c
 
 
 
 
d99d6ac
c24834c
d99d6ac
 
 
c24834c
 
 
d99d6ac
 
 
 
35a451f
298d773
c24834c
35a451f
 
d99d6ac
35a451f
d99d6ac
 
 
e99da7d
 
d99d6ac
 
 
 
 
 
e99da7d
 
d99d6ac
35a451f
 
 
 
d99d6ac
 
 
e99da7d
 
d99d6ac
 
 
 
 
c24834c
d99d6ac
35a451f
 
d99d6ac
 
 
 
298d773
d99d6ac
 
c24834c
d99d6ac
 
 
 
c24834c
d99d6ac
 
 
c24834c
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298d773
d99d6ac
 
b0b7e2a
d99d6ac
 
 
 
 
 
 
 
c24834c
e99da7d
d99d6ac
 
 
 
c24834c
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298d773
d99d6ac
 
b0b7e2a
d99d6ac
 
 
 
 
 
 
 
 
e99da7d
d99d6ac
c24834c
35a451f
 
298d773
 
 
 
 
 
 
 
 
 
 
35a451f
298d773
 
 
e99da7d
298d773
 
 
 
 
 
e99da7d
298d773
35a451f
 
298d773
 
 
 
c24834c
 
 
298d773
c24834c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0b7e2a
c24834c
 
 
 
 
298d773
 
c24834c
 
 
 
 
 
298d773
c24834c
 
 
 
b0b7e2a
298d773
c24834c
 
 
 
 
 
d99d6ac
c24834c
298d773
 
 
c24834c
 
 
 
d99d6ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
import os
import re
import json
import inspect
import traceback
import gradio as gr
from b2sdk.v2 import *
# Newer b2sdk versions no longer export the exceptions through "import *": import it by name
try:
    from b2sdk.v2.exception import NonExistentBucket, RestrictedBucket
except ImportError:
    from b2sdk.exception import NonExistentBucket, RestrictedBucket
import dropbox
import gspread
import gspread.exceptions
from google.oauth2.service_account import Credentials


# ---------- LOAD .env ----------
try:
    from dotenv import load_dotenv
    load_dotenv()
    print(".env file loaded")
except ImportError:
    print("python-dotenv not installed")

print("B2_KEY_ID set:", bool(os.getenv("B2_KEY_ID")))

# ---------- LOAD CONFIG ----------
# One B2 bucket per Author/channel — resolved dynamically per upload, not fixed
# at startup. Only authenticate here; bucket resolution happens per-Author later.
B2_KEY_ID = os.getenv("B2_KEY_ID")
B2_APP_KEY = os.getenv("B2_APP_KEY")

info = InMemoryAccountInfo()
b2_api = B2Api(info)
b2_api.authorize_account("production", B2_KEY_ID, B2_APP_KEY)

# ---------- DROPBOX ----------
# A raw DROPBOX_ACCESS_TOKEN (from the App Console's "Generate access token"
# button) expires after a few hours - that's what was causing the intermittent
# failures. The refresh-token flow below lets the SDK silently mint new access
# tokens forever, so it never expires. Run get_dropbox_refresh_token.py once,
# locally, to obtain DROPBOX_APP_KEY / DROPBOX_APP_SECRET / DROPBOX_REFRESH_TOKEN.
DROPBOX_APP_KEY = os.getenv("DROPBOX_APP_KEY")
DROPBOX_APP_SECRET = os.getenv("DROPBOX_APP_SECRET")
DROPBOX_REFRESH_TOKEN = os.getenv("DROPBOX_REFRESH_TOKEN")
DROPBOX_TOKEN = os.getenv("DROPBOX_ACCESS_TOKEN")  # legacy fallback only - expires in ~4h

if DROPBOX_REFRESH_TOKEN and DROPBOX_APP_KEY and DROPBOX_APP_SECRET:
    dbx = dropbox.Dropbox(
        oauth2_refresh_token=DROPBOX_REFRESH_TOKEN,
        app_key=DROPBOX_APP_KEY,
        app_secret=DROPBOX_APP_SECRET,
    )
elif DROPBOX_TOKEN:
    print(
        "Warning: using a short-lived DROPBOX_ACCESS_TOKEN (expires in a few hours). "
        "Set DROPBOX_APP_KEY / DROPBOX_APP_SECRET / DROPBOX_REFRESH_TOKEN instead "
        "for a connection that doesn't expire - see get_dropbox_refresh_token.py."
    )
    dbx = dropbox.Dropbox(DROPBOX_TOKEN)
else:
    raise RuntimeError(
        "No Dropbox credentials configured. Set DROPBOX_REFRESH_TOKEN + "
        "DROPBOX_APP_KEY + DROPBOX_APP_SECRET (recommended) or DROPBOX_ACCESS_TOKEN."
    )


# ============================================================
# TRANSLATIONS (interface language dropdown)
# Every text is a tuple in this order: English, Português, Nederlands,
# Français, Italiano, Español. T(lang, key, **values) returns the text.
# Status messages keep "✓" / "⚠" at the start so the Status box colouring works
# in every language.
# ============================================================
LANG_CODES = ("en", "pt", "nl", "fr", "it", "es")
LANG_CHOICES = [("English", "en"), ("Português", "pt"), ("Nederlands", "nl"),
                ("Français", "fr"), ("Italiano", "it"), ("Español", "es")]
DEFAULT_LANG = "en"

STRINGS = {
    # ---- top bar ----
    "app_title": ("Upload Center", "Centro de Uploads", "Uploadcentrum", "Centre d'import",
                  "Centro di caricamento", "Centro de subidas"),
    "app_sub": ("Non-Shorts videos and shorts to Cloud Storage",
                "Vídeos longos e shorts para o Cloud Storage",
                "Lange video's en shorts naar Cloud Storage",
                "Vidéos longues et shorts vers Cloud Storage",
                "Video lunghi e short su Cloud Storage",
                "Vídeos largos y shorts a Cloud Storage"),
    "reset": ("↺  Reset All", "↺  Repor tudo", "↺  Alles resetten",
              "↺  Tout réinitialiser", "↺  Ripristina tutto", "↺  Restablecer todo"),

    # ---- section 1 ----
    "sec_author": ("### 1. Author & Upload Type", "### 1. Autor e tipo de upload", "### 1. Auteur & uploadtype",
                   "### 1. Auteur & type d'import", "### 1. Autore e tipo di caricamento",
                   "### 1. Autor y tipo de subida"),
    "author_label": ('Author Name * (6–20 characters, exact match, e.g. ADNAPLAYS; \\ / : * ? " < > | are not allowed)',
                     'Nome do autor * (6–20 caracteres, igual ao canal, ex.: ADNAPLAYS; \\ / : * ? " < > | não são permitidos)',
                     'Auteursnaam * (6–20 tekens, exact gelijk, bijv. ADNAPLAYS; \\ / : * ? " < > | zijn niet toegestaan)',
                     'Nom de l\'auteur * (6 à 20 caractères, exactement, ex. : ADNAPLAYS ; \\ / : * ? " < > | sont interdits)',
                     'Nome autore * (6–20 caratteri, esatto, es. ADNAPLAYS; \\ / : * ? " < > | non sono ammessi)',
                     'Nombre del autor * (6–20 caracteres, exacto, p. ej. ADNAPLAYS; \\ / : * ? " < > | no están permitidos)'),
    "m_author_length": ("⚠ Author Name must have between 6 and 20 characters (it has {n}).",
                        "⚠ O nome do autor tem de ter entre 6 e 20 caracteres (tem {n}).",
                        "⚠ De auteursnaam moet tussen 6 en 20 tekens hebben (nu {n}).",
                        "⚠ Le nom de l'auteur doit contenir entre 6 et 20 caractères (il en a {n}).",
                        "⚠ Il nome autore deve avere tra 6 e 20 caratteri (ne ha {n}).",
                        "⚠ El nombre del autor debe tener entre 6 y 20 caracteres (tiene {n})."),
    "mode_label": ("Select Upload Type", "Escolhe o tipo de upload", "Kies het uploadtype",
                   "Choisissez le type d'import", "Scegli il tipo di caricamento", "Elige el tipo de subida"),
    "mode_long": ("Non-Shorts", "Longo", "Lang", "Longue", "Lungo", "Largo"),
    "mode_shorts": ("Shorts", "Shorts", "Shorts", "Shorts", "Short", "Shorts"),

    # ---- section 2: long ----
    "sec_long": ("### 2. Non-Shorts Video Upload", "### 2. Upload do vídeo longo", "### 2. Lange video uploaden",
                 "### 2. Import de la vidéo longue", "### 2. Caricamento video lungo",
                 "### 2. Subida del vídeo largo"),
    "game_name": ("Game Name *", "Nome do jogo *", "Gamenaam *", "Nom du jeu *", "Nome del gioco *",
                  "Nombre del juego *"),
    "part": ("Part *", "Parte *", "Deel *", "Partie *", "Parte *", "Parte *"),
    "yt_playlist": ("YouTube Playlist URL *", "URL da playlist do YouTube *", "URL van de YouTube-afspeellijst *",
                    "URL de la playlist YouTube *", "URL della playlist YouTube *", "URL de la lista de YouTube *"),
    "game_url": ("Game URL (Steam or any) *", "URL do jogo (Steam ou outro) *", "Game-URL (Steam of andere) *",
                 "URL du jeu (Steam ou autre) *", "URL del gioco (Steam o altro) *", "URL del juego (Steam u otro) *"),
    "yt_synopsis": ("YouTube Synopsis", "Sinopse do YouTube", "YouTube-synopsis", "Synopsis YouTube",
                    "Sinossi YouTube", "Sinopsis de YouTube"),
    "yt_chapters": ("YouTube Chapters", "Capítulos do YouTube", "YouTube-hoofdstukken", "Chapitres YouTube",
                    "Capitoli YouTube", "Capítulos de YouTube"),
    "instructions": ("Video Instructions", "Instruções do vídeo", "Video-instructies", "Instructions vidéo",
                     "Istruzioni video", "Instrucciones del vídeo"),
    "mp4_long": ("Non-Short MP4 (≤ 800MB) *", "MP4 longo (≤ 800MB) *", "Lange MP4 (≤ 800MB) *",
                 "MP4 longue (≤ 800 Mo) *", "MP4 lungo (≤ 800MB) *", "MP4 largo (≤ 800MB) *"),
    "thumb": ("Thumbnail (any image — saved as PNG) *", "Miniatura (qualquer imagem — guardada em PNG) *",
              "Thumbnail (elke afbeelding — opgeslagen als PNG) *", "Miniature (toute image — enregistrée en PNG) *",
              "Miniatura (qualsiasi immagine — salvata in PNG) *", "Miniatura (cualquier imagen — guardada en PNG) *"),
    "save_file": ("Game Save File (.ZIP or .RAR, optional)", "Ficheiro de save do jogo (.ZIP ou .RAR, opcional)",
                  "Savegame-bestand (.ZIP of .RAR, optioneel)", "Fichier de sauvegarde (.ZIP ou .RAR, facultatif)",
                  "File di salvataggio (.ZIP o .RAR, facoltativo)",
                  "Archivo de partida guardada (.ZIP o .RAR, opcional)"),

    # ---- section 2: shorts ----
    "sec_shorts": ("### 2. Shorts Upload", "### 2. Upload dos shorts", "### 2. Shorts uploaden",
                   "### 2. Import des shorts", "### 2. Caricamento short", "### 2. Subida de shorts"),
    "video_synopsis": ("Video Synopsis", "Sinopse do vídeo", "Videosynopsis", "Synopsis de la vidéo",
                       "Sinossi del video", "Sinopsis del vídeo"),
    "short_file": ("{p} MP4 (≤ 100MB, optional)", "{p} MP4 (≤ 100MB, opcional)", "{p} MP4 (≤ 100MB, optioneel)",
                   "{p} MP4 (≤ 100 Mo, facultatif)", "{p} MP4 (≤ 100MB, facoltativo)", "{p} MP4 (≤ 100MB, opcional)"),
    "m_need_one_short": ("⚠ Add at least one short (INSTSTORIES, TIKTOK, YTBSTORIES or FACEBOOK).",
                         "⚠ Adiciona pelo menos um short (INSTSTORIES, TIKTOK, YTBSTORIES ou FACEBOOK).",
                         "⚠ Voeg minstens één short toe (INSTSTORIES, TIKTOK, YTBSTORIES of FACEBOOK).",
                         "⚠ Ajoutez au moins un short (INSTSTORIES, TIKTOK, YTBSTORIES ou FACEBOOK).",
                         "⚠ Aggiungi almeno uno short (INSTSTORIES, TIKTOK, YTBSTORIES o FACEBOOK).",
                         "⚠ Añade al menos un short (INSTSTORIES, TIKTOK, YTBSTORIES o FACEBOOK)."),

    # ---- upload button + result ----
    "uploading": ("⏳ Uploading… please keep this page open", "⏳ A carregar… mantém esta página aberta",
                  "⏳ Bezig met uploaden… houd deze pagina open", "⏳ Import en cours… gardez cette page ouverte",
                  "⏳ Caricamento… tieni aperta questa pagina", "⏳ Subiendo… mantén esta página abierta"),
    "up_check": ("Checking the files and the cloud storage", "A verificar os ficheiros e o armazenamento",
                 "Bestanden en cloudopslag controleren", "Vérification des fichiers et du stockage",
                 "Verifica dei file e dello spazio cloud", "Comprobando los archivos y el almacenamiento"),
    "up_file": ("Uploading {f}", "A carregar {f}", "{f} uploaden", "Import de {f}", "Caricamento di {f}", "Subiendo {f}"),
    "up_save": ("Uploading the game save to Dropbox", "A carregar o save do jogo para o Dropbox",
                "Savegame naar Dropbox uploaden", "Import de la sauvegarde vers Dropbox",
                "Caricamento del salvataggio su Dropbox", "Subiendo la partida guardada a Dropbox"),
    "up_sheet": ("Updating the Google Sheet", "A atualizar o Google Sheet", "Google Sheet bijwerken",
                 "Mise à jour du Google Sheet", "Aggiornamento del Google Sheet", "Actualizando el Google Sheet"),
    "up_meta": ("{d} of {t} · {e} elapsed", "{d} de {t} · {e} decorridos", "{d} van {t} · {e} verstreken",
                "{d} sur {t} · {e} écoulées", "{d} di {t} · {e} trascorsi", "{d} de {t} · {e} transcurridos"),
    "up_elapsed": ("{e} elapsed", "{e} decorridos", "{e} verstreken", "{e} écoulées", "{e} trascorsi", "{e} transcurridos"),
    "up_hint": ("The result appears here by itself — no need to refresh.",
                "O resultado aparece aqui sozinho — não é preciso atualizar.",
                "Het resultaat verschijnt hier vanzelf — niet vernieuwen nodig.",
                "Le résultat s'affiche ici tout seul — inutile d'actualiser.",
                "Il risultato appare qui da solo — non serve aggiornare.",
                "El resultado aparece aquí solo — no hace falta actualizar."),
    "up_done": ("✓ Finished in {e}", "✓ Concluído em {e}", "✓ Klaar in {e}", "✓ Terminé en {e}",
                "✓ Completato in {e}", "✓ Listo en {e}"),
    "up_busy": ("⏳ An upload is already running — its result will appear here when it's done.",
                "⏳ Já há um upload a decorrer — o resultado aparece aqui quando terminar.",
                "⏳ Er loopt al een upload — het resultaat verschijnt hier zodra die klaar is.",
                "⏳ Un import est déjà en cours — son résultat s'affichera ici à la fin.",
                "⏳ È già in corso un caricamento — il risultato apparirà qui al termine.",
                "⏳ Ya hay una subida en curso — el resultado aparecerá aquí cuando termine."),
    "up_lost": ("⚠ This upload was interrupted (the server restarted). Check the cloud storage and upload again if needed.",
                "⚠ Este upload foi interrompido (o servidor reiniciou). Verifica o armazenamento e volta a carregar se for preciso.",
                "⚠ Deze upload is onderbroken (de server is herstart). Controleer de opslag en upload opnieuw als dat nodig is.",
                "⚠ Cet import a été interrompu (le serveur a redémarré). Vérifiez le stockage et relancez l'import si besoin.",
                "⚠ Questo caricamento è stato interrotto (il server si è riavviato). Controlla lo spazio cloud e ricarica se serve.",
                "⚠ Esta subida se interrumpió (el servidor se reinició). Revisa el almacenamiento y vuelve a subir si hace falta."),
    "m_storage_full": ("⚠ Your cloud storage is full. Please wait for your currently scheduled posts to be published, or contact support for assistance.",
                       "⚠ O teu armazenamento na cloud está cheio. Aguarda que as publicações agendadas sejam publicadas, ou contacta o suporte para obter ajuda.",
                       "⚠ Je cloudopslag is vol. Wacht tot je ingeplande berichten zijn gepubliceerd, of neem contact op met support voor hulp.",
                       "⚠ Votre stockage cloud est plein. Attendez que vos publications programmées soient publiées, ou contactez le support pour obtenir de l'aide.",
                       "⚠ Il tuo spazio cloud è pieno. Attendi che i post programmati vengano pubblicati, oppure contatta l'assistenza.",
                       "⚠ Tu almacenamiento en la nube está lleno. Espera a que se publiquen tus publicaciones programadas o contacta con soporte para obtener ayuda."),
    "upload_ready": ("Upload", "Carregar", "Uploaden", "Importer", "Carica", "Subir"),
    "upload_waiting": ("Waiting for files to finish uploading…", "À espera que os ficheiros terminem de carregar…",
                       "Wachten tot de bestanden klaar zijn met uploaden…",
                       "En attente de la fin de l'import des fichiers…",
                       "In attesa che i file finiscano di caricarsi…",
                       "Esperando a que terminen de subirse los archivos…"),
    "sec_result": ("### 3. Result", "### 3. Resultado", "### 3. Resultaat", "### 3. Résultat",
                   "### 3. Risultato", "### 3. Resultado"),
    "status": ("Status", "Estado", "Status", "Statut", "Stato", "Estado"),
    "share_url": ("Shareable URL (game save on Dropbox)", "URL partilhável (save do jogo no Dropbox)",
                  "Deelbare URL (savegame op Dropbox)", "URL partageable (sauvegarde sur Dropbox)",
                  "URL condivisibile (salvataggio su Dropbox)", "URL para compartir (partida guardada en Dropbox)"),
    "copy_link": ("Copy Link", "Copiar link", "Link kopiëren", "Copier le lien", "Copia link", "Copiar enlace"),
    "copied": ("✓ Copied!", "✓ Copiado!", "✓ Gekopieerd!", "✓ Copié !", "✓ Copiato!", "✓ ¡Copiado!"),
    "no_link": ("No link", "Sem link", "Geen link", "Aucun lien", "Nessun link", "Sin enlace"),
    "copy_failed": ("⚠ Failed", "⚠ Falhou", "⚠ Mislukt", "⚠ Échec", "⚠ Non riuscito", "⚠ Error"),

    # ---- short field names used inside messages ----
    "f_author": ("Author Name", "Nome do autor", "Auteursnaam", "Nom de l'auteur", "Nome autore", "Nombre del autor"),
    "f_game": ("Game Name", "Nome do jogo", "Gamenaam", "Nom du jeu", "Nome del gioco", "Nombre del juego"),
    "f_part": ("Part", "Parte", "Deel", "Partie", "Parte", "Parte"),
    "f_playlist": ("YouTube Playlist URL", "URL da playlist do YouTube", "URL van de YouTube-afspeellijst",
                   "URL de la playlist YouTube", "URL della playlist YouTube", "URL de la lista de YouTube"),
    "f_game_url": ("Game URL", "URL do jogo", "Game-URL", "URL du jeu", "URL del gioco", "URL del juego"),
    "f_save": ("Game Save File", "Ficheiro de save", "Savegame-bestand", "Fichier de sauvegarde",
               "File di salvataggio", "Archivo de partida"),
    "f_share_link": ("share link", "link de partilha", "deellink", "lien de partage", "link di condivisione",
                     "enlace para compartir"),

    # ---- status messages ----
    "m_missing": ("⚠ Missing required field(s): {f}", "⚠ Campo(s) obrigatório(s) em falta: {f}",
                  "⚠ Verplichte velden ontbreken: {f}", "⚠ Champ(s) obligatoire(s) manquant(s) : {f}",
                  "⚠ Campi obbligatori mancanti: {f}", "⚠ Faltan campos obligatorios: {f}"),
    "m_not_ready": ("⚠ {n} doesn't look fully uploaded yet. Please wait a moment for the upload to finish and try again.",
                    "⚠ {n} ainda não parece ter carregado por completo. Espera um momento que o carregamento termine e tenta outra vez.",
                    "⚠ {n} lijkt nog niet volledig geüpload. Wacht even tot de upload klaar is en probeer het opnieuw.",
                    "⚠ {n} ne semble pas encore entièrement importé. Attendez la fin de l'import et réessayez.",
                    "⚠ {n} non sembra ancora caricato del tutto. Attendi che il caricamento finisca e riprova.",
                    "⚠ {n} aún no parece haberse subido por completo. Espera a que termine la subida e inténtalo de nuevo."),
    "m_bad_image": ("⚠ The thumbnail could not be read as an image ({e}). Please use JPG, PNG, WEBP, GIF, BMP, TIFF or HEIC.",
                    "⚠ Não foi possível ler a miniatura como imagem ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF ou HEIC.",
                    "⚠ De thumbnail kon niet als afbeelding worden gelezen ({e}). Gebruik JPG, PNG, WEBP, GIF, BMP, TIFF of HEIC.",
                    "⚠ La miniature n'a pas pu être lue comme image ({e}). Utilisez JPG, PNG, WEBP, GIF, BMP, TIFF ou HEIC.",
                    "⚠ Impossibile leggere la miniatura come immagine ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF o HEIC.",
                    "⚠ No se pudo leer la miniatura como imagen ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF o HEIC."),
    "m_mp4_size": ("⚠ MP4 must be 800MB or lower.", "⚠ O MP4 tem de ter 800MB ou menos.",
                   "⚠ De MP4 mag maximaal 800MB zijn.", "⚠ Le MP4 doit faire 800 Mo maximum.",
                   "⚠ L'MP4 deve essere al massimo 800MB.", "⚠ El MP4 debe pesar 800MB o menos."),
    "m_save_ext": ("⚠ Game Save File must be a .ZIP or .RAR file.", "⚠ O ficheiro de save tem de ser .ZIP ou .RAR.",
                   "⚠ Het savegame-bestand moet een .ZIP- of .RAR-bestand zijn.",
                   "⚠ Le fichier de sauvegarde doit être un .ZIP ou .RAR.",
                   "⚠ Il file di salvataggio deve essere .ZIP o .RAR.",
                   "⚠ El archivo de partida debe ser .ZIP o .RAR."),
    "m_save_size": ("⚠ Game Save File exceeds 50MB limit.", "⚠ O ficheiro de save ultrapassa o limite de 50MB.",
                    "⚠ Het savegame-bestand is groter dan de limiet van 50MB.",
                    "⚠ Le fichier de sauvegarde dépasse la limite de 50 Mo.",
                    "⚠ Il file di salvataggio supera il limite di 50MB.",
                    "⚠ El archivo de partida supera el límite de 50MB."),
    "m_short_size": ("⚠ {n} exceeds 100MB limit", "⚠ {n} ultrapassa o limite de 100MB",
                     "⚠ {n} is groter dan de limiet van 100MB", "⚠ {n} dépasse la limite de 100 Mo",
                     "⚠ {n} supera il limite di 100MB", "⚠ {n} supera el límite de 100MB"),
    "m_invalid_chars": ("⚠ Author Name / Game Name / Part must contain at least one valid character.",
                        "⚠ Nome do autor / Nome do jogo / Parte têm de ter pelo menos um carácter válido.",
                        "⚠ Auteursnaam / Gamenaam / Deel moeten minstens één geldig teken bevatten.",
                        "⚠ Nom de l'auteur / Nom du jeu / Partie doivent contenir au moins un caractère valide.",
                        "⚠ Nome autore / Nome del gioco / Parte devono contenere almeno un carattere valido.",
                        "⚠ Nombre del autor / Nombre del juego / Parte deben contener al menos un carácter válido."),
    "m_key_restricted": ("⚠ The B2 application key set in this Space only opens the bucket '{a}', so it can't reach '{b}'. "
                         "Create a B2 key with access to all buckets and put it in B2_KEY_ID / B2_APP_KEY.",
                         "⚠ A chave de aplicação B2 configurada neste Space só abre o bucket '{a}', por isso não chega a '{b}'. "
                         "Cria uma chave B2 com acesso a todos os buckets e coloca-a em B2_KEY_ID / B2_APP_KEY.",
                         "⚠ De B2-applicatiesleutel van deze Space opent alleen bucket '{a}', dus '{b}' is niet bereikbaar. "
                         "Maak een B2-sleutel met toegang tot alle buckets en zet die in B2_KEY_ID / B2_APP_KEY.",
                         "⚠ La clé d'application B2 de ce Space n'ouvre que le bucket '{a}', elle ne peut donc pas atteindre '{b}'. "
                         "Créez une clé B2 avec accès à tous les buckets et mettez-la dans B2_KEY_ID / B2_APP_KEY.",
                         "⚠ La chiave applicativa B2 di questo Space apre solo il bucket '{a}', quindi non raggiunge '{b}'. "
                         "Crea una chiave B2 con accesso a tutti i bucket e inseriscila in B2_KEY_ID / B2_APP_KEY.",
                         "⚠ La clave de aplicación B2 de este Space solo abre el bucket '{a}', así que no llega a '{b}'. "
                         "Crea una clave B2 con acceso a todos los buckets y ponla en B2_KEY_ID / B2_APP_KEY."),
    "m_no_bucket": ("⚠ No B2 bucket named '{b}' was found. Please create it (with Non-Shorts and Shorts subfolders) before uploading.",
                    "⚠ Não foi encontrado nenhum bucket B2 chamado '{b}'. Cria-o (com as subpastas Non-Shorts e Shorts) antes de fazer upload.",
                    "⚠ Er is geen B2-bucket met de naam '{b}' gevonden. Maak hem aan (met de submappen Non-Shorts en Shorts) voordat je uploadt.",
                    "⚠ Aucun bucket B2 nommé '{b}' n'a été trouvé. Créez-le (avec les sous-dossiers Non-Shorts et Shorts) avant d'importer.",
                    "⚠ Nessun bucket B2 chiamato '{b}' trovato. Crealo (con le sottocartelle Non-Shorts e Shorts) prima di caricare.",
                    "⚠ No se encontró ningún bucket B2 llamado '{b}'. Créalo (con las subcarpetas Non-Shorts y Shorts) antes de subir."),
    "m_mp4_failed": ("⚠ MP4 upload to B2 failed: {e}\nNothing else was uploaded — please try again.",
                     "⚠ O upload do MP4 para o B2 falhou: {e}\nMais nada foi carregado — tenta outra vez.",
                     "⚠ Upload van de MP4 naar B2 mislukt: {e}\nEr is verder niets geüpload — probeer het opnieuw.",
                     "⚠ Échec de l'import du MP4 vers B2 : {e}\nRien d'autre n'a été importé — réessayez.",
                     "⚠ Caricamento dell'MP4 su B2 non riuscito: {e}\nNient'altro è stato caricato — riprova.",
                     "⚠ Falló la subida del MP4 a B2: {e}\nNo se subió nada más — inténtalo de nuevo."),
    "m_uploaded": ("✓ Uploaded: {x}", "✓ Carregado: {x}", "✓ Geüpload: {x}", "✓ Importé : {x}",
                   "✓ Caricato: {x}", "✓ Subido: {x}"),
    "m_exists": ("⚠ Already exists (not re-uploaded): {x}", "⚠ Já existe (não foi carregado de novo): {x}",
                 "⚠ Bestaat al (niet opnieuw geüpload): {x}", "⚠ Existe déjà (non réimporté) : {x}",
                 "⚠ Esiste già (non ricaricato): {x}", "⚠ Ya existe (no se volvió a subir): {x}"),
    "m_errors": ("⚠ Errors: {x}", "⚠ Erros: {x}", "⚠ Fouten: {x}", "⚠ Erreurs : {x}", "⚠ Errori: {x}",
                 "⚠ Errores: {x}"),
    "m_sheet_ok": ("✓ Google Sheet updated (tab '{t}', row {r})",
                   "✓ Google Sheet atualizado (separador '{t}', linha {r})",
                   "✓ Google Sheet bijgewerkt (tabblad '{t}', rij {r})",
                   "✓ Google Sheet mis à jour (onglet '{t}', ligne {r})",
                   "✓ Google Sheet aggiornato (scheda '{t}', riga {r})",
                   "✓ Google Sheet actualizado (pestaña '{t}', fila {r})"),
    "m_sheet_fail": ("⚠ Google Sheet update failed: {e}", "⚠ A atualização do Google Sheet falhou: {e}",
                     "⚠ Bijwerken van Google Sheet mislukt: {e}", "⚠ Échec de la mise à jour du Google Sheet : {e}",
                     "⚠ Aggiornamento del Google Sheet non riuscito: {e}",
                     "⚠ Falló la actualización del Google Sheet: {e}"),
    "m_files_wait": ("⚠ One or more required files haven't finished uploading yet. Please wait for the upload to complete and try again.",
                     "⚠ Um ou mais ficheiros obrigatórios ainda não terminaram de carregar. Espera que o carregamento termine e tenta outra vez.",
                     "⚠ Een of meer verplichte bestanden zijn nog niet klaar met uploaden. Wacht tot de upload klaar is en probeer het opnieuw.",
                     "⚠ Un ou plusieurs fichiers obligatoires n'ont pas fini de s'importer. Attendez la fin de l'import et réessayez.",
                     "⚠ Uno o più file obbligatori non hanno ancora finito di caricarsi. Attendi la fine del caricamento e riprova.",
                     "⚠ Uno o más archivos obligatorios aún no han terminado de subirse. Espera a que termine la subida e inténtalo de nuevo."),
    "m_invalid_mode": ("⚠ Invalid mode", "⚠ Modo inválido", "⚠ Ongeldige modus", "⚠ Mode invalide",
                       "⚠ Modalità non valida", "⚠ Modo no válido"),
}


def _lang_index(lang):
    return LANG_CODES.index(lang) if lang in LANG_CODES else 0


def T(lang, key, **values):
    """Text for `key` in the chosen interface language (falls back to English)."""
    entry = STRINGS.get(key)
    if entry is None:
        return key
    text = entry[_lang_index(lang)] or entry[0]
    return text.format(**values) if values else text


# texts used by the browser (Copy Link button)
JS_TEXT_KEYS = ("copied", "no_link", "copy_failed", "copy_link")
JS_I18N = {code: {k: STRINGS[k][i] for k in JS_TEXT_KEYS} for i, code in enumerate(LANG_CODES)}


# ---------- VALIDATION HELPER ----------
def find_missing_fields(fields: dict):
    """fields: {label: value}. Returns list of labels whose value is empty/blank."""
    return [label for label, value in fields.items() if not value or not str(value).strip()]

# ---------- FILENAME SANITIZATION ----------
# Characters that Windows (and B2/Dropbox in practice) don't allow in file/folder
# names: \ / : * ? " < > |
# also apostrophes (straight and curly) and backticks: "Assassin's" -> "Assassins"
INVALID_FILENAME_CHARS = '\\/:*?"<>|' + "'\u2018\u2019\u02bc`\u00b4"

def strip_invalid_filename_chars(text):
    """Removes every character in INVALID_FILENAME_CHARS from text."""
    return "".join(ch for ch in (text or "") if ch not in INVALID_FILENAME_CHARS)

def sanitize_author_input(value):
    """Live-cleans the Author Name field as the user types: strips filesystem-
    invalid characters and forces uppercase, so e.g. 'adna/plays' becomes
    'ADNAPLAYS' immediately rather than being accepted with the slash."""
    cleaned = strip_invalid_filename_chars(value or "").upper()[:AUTHOR_MAX_CHARS]
    return gr.update(value=cleaned)

AUTHOR_MIN_CHARS = 6                     # the world's name (= its B2 bucket): 6 to 20 characters
AUTHOR_MAX_CHARS = 20


def author_length_error(safe_author, lang):
    """The message to show when the cleaned Author / world name is too short or too long, else None."""
    n = len(safe_author or "")
    if AUTHOR_MIN_CHARS <= n <= AUTHOR_MAX_CHARS:
        return None
    return T(lang, "m_author_length", n=n)


def sanitize_author_component(author_name):
    """'AdnaPlays' -> 'ADNAPLAYS' | 'Adna Plays' -> 'ADNA_PLAYS'"""
    cleaned = strip_invalid_filename_chars(author_name or "").strip()
    cleaned = re.sub(r"\s+", "_", cleaned)
    return cleaned.upper()

def clean_game_name(game_name):
    """The Game Name exactly as it is written to the Google Sheet AND used in the file names:
    'Tomb Raider/ 4' -> 'Tomb_Raider_4' | "Pokémon's Adventure" -> 'Pokémons_Adventure'"""
    words = strip_invalid_filename_chars(game_name or "").split()
    return "_".join(w.capitalize() for w in words if w)


def sanitize_game_part_component(game_name, part):
    """'Silent hill 2' + 'full game' -> 'Silent_Hill_2_Full_Game'"""
    combined = strip_invalid_filename_chars(f"{game_name or ''} {part or ''}").strip()
    words = combined.split()
    return "_".join(w.capitalize() for w in words if w)

def sanitize_filename_component(text):
    """Turns a free-text field (game name, part, etc.) into a safe filename
    component: strips invalid filesystem characters and replaces whitespace
    with underscores."""
    cleaned = strip_invalid_filename_chars(text or "").strip()
    cleaned = re.sub(r"\s+", "_", cleaned)
    return cleaned

# ---------- B2 BUCKET-PER-AUTHOR RESOLUTION ----------
class B2KeyRestricted(Exception):
    """The B2 application key in the Space settings only opens one bucket (not every Author's)."""
    def __init__(self, allowed):
        super().__init__(allowed)
        self.allowed = allowed


def get_author_bucket(safe_author):
    """Resolves the B2 bucket named exactly like the sanitized Author, or
    returns None if no such bucket exists."""
    try:
        return b2_api.get_bucket_by_name(safe_author)
    except NonExistentBucket:
        return None
    except RestrictedBucket as e:
        allowed = getattr(e, "bucket_name", None) or str(e).rsplit(":", 1)[-1].strip()
        print(f"B2: the application key only allows bucket {allowed!r}, not {safe_author!r}")
        raise B2KeyRestricted(allowed)

# ---------- DUPLICATE-FILE CHECK HELPERS ----------
def b2_existing_filenames(bucket):
    """Returns the set of file names currently stored in a B2 bucket."""
    return {file_version.file_name for file_version, _ in bucket.ls(recursive=True)}


# ---------- CLOUD STORAGE LIMIT (STORAGE_LIMIT_GB of each world) ----------
# The limit lives in /home/Gamifypt/Universes/<WORLD>/.env on admin.gamify.pt, which this Space
# can't read, so it asks the admin panel (/api/storage-limit/<BUCKET>) with a short-lived token
# signed with GAMIFY_GATE_SECRET (the same secret the access gate already uses). What is in use
# is measured here, live: Non-Shorts/ + Shorts/ of the Author's bucket.
import base64
import hashlib
import hmac
import time
import requests
from urllib.parse import quote as _url_quote

def _admin_base_url():
    """The admin panel address. Taken from GAMIFY_LOGIN_URL (already set for the access gate,
    e.g. admin.gamify.pt/home -> https://admin.gamify.pt); GAMIFY_ADMIN_URL overrides it if set."""
    from urllib.parse import urlsplit
    raw = (os.getenv("GAMIFY_ADMIN_URL") or os.getenv("GAMIFY_LOGIN_URL") or "https://admin.gamify.pt").strip()
    if "://" not in raw:
        raw = "https://" + raw
    parts = urlsplit(raw)
    return f"{parts.scheme}://{parts.netloc}"


GAMIFY_ADMIN_URL = _admin_base_url()
BYTES_PER_GB = 1024 ** 3                 # same unit the admin panel uses for STORAGE_LIMIT_GB
STORAGE_FOLDERS = ("Non-Shorts/", "Shorts/")


def b2_bucket_contents(bucket):
    """(set of file names, bytes used by Non-Shorts/ + Shorts/) — one listing of the bucket."""
    names, used = set(), 0
    for file_version, _ in bucket.ls(recursive=True):
        names.add(file_version.file_name)
        if file_version.file_name.startswith(STORAGE_FOLDERS):
            used += int(getattr(file_version, "size", 0) or 0)
    return names, used


def _admin_call_token():
    secret = os.getenv("GAMIFY_GATE_SECRET", "").strip()
    if not secret:
        return None
    b64 = lambda raw: base64.urlsafe_b64encode(raw).decode().rstrip("=")
    payload = b64(json.dumps({"u": "upload-center", "exp": int(time.time()) + 120, "src": "home"}).encode())
    sig = b64(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
    return f"{payload}.{sig}"


def storage_limit_bytes(bucket_name):
    """The world's STORAGE_LIMIT_GB in bytes, or None (no limit set, or the admin panel can't be
    reached — then the upload is NOT blocked, and the reason goes to the logs)."""
    token = _admin_call_token()
    if not token:
        print("STORAGE LIMIT: GAMIFY_GATE_SECRET is not set in this Space -- limit not checked")
        return None
    try:
        r = requests.get(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
                         headers={"X-Gamify-Gate": token}, timeout=15)
        data = r.json() if r.content else {}
        if not r.ok:
            print(f"STORAGE LIMIT: admin answered {r.status_code} for {bucket_name}: {data}")
            return None
        limit = data.get("limit_gb")
        return float(limit) * BYTES_PER_GB if limit is not None else None
    except Exception as e:
        print(f"STORAGE LIMIT: could not ask the admin panel for {bucket_name}: {e!r}")
        return None


def report_storage_used(bucket_name, used_bytes):
    """After an upload: tells the admin panel how full the bucket is now (STORAGE_LIMIT_USED +
    its 'almost full' alerts). Never fails the upload."""
    token = _admin_call_token()
    if not token:
        return
    try:
        requests.post(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
                      headers={"X-Gamify-Gate": token}, json={"used_bytes": int(used_bytes)}, timeout=15)
    except Exception as e:
        print(f"STORAGE LIMIT: could not report usage for {bucket_name}: {e!r}")


def storage_would_overflow(bucket_name, used_bytes, incoming_bytes):
    limit = storage_limit_bytes(bucket_name)
    if limit is None or incoming_bytes <= 0:
        return False
    print(f"STORAGE LIMIT: {bucket_name} uses {used_bytes / 2**20:.0f} MB of {limit / 2**20:.0f} MB, "
          f"upload needs {incoming_bytes / 2**20:.0f} MB")
    return used_bytes + incoming_bytes > limit

def dropbox_file_exists(path):
    """Returns True if a file already exists at the given Dropbox path."""
    try:
        dbx.files_get_metadata(path)
        return True
    except dropbox.exceptions.ApiError:
        return False
    except Exception:
        return False

# ---------- UPLOAD-READINESS PROTECTION ----------
# Gradio's file widgets upload to the Space's server in the background as soon as a
# file is picked, *before* the "Upload" button is clicked. There's a window where the
# button is clickable but a file hasn't finished arriving on the server yet. These
# helpers keep the Upload button locked until every file required by the selected
# mode has actually finished uploading, and double-check on the backend too.

def _file_is_ready(f):
    """True only if f is a real, fully-written temp file on the server (not mid-upload)."""
    if f is None:
        return False
    try:
        return os.path.exists(f.name) and os.path.getsize(f.name) > 0
    except Exception:
        return False

def files_ready_for_mode(selected_mode, mp4, png, insta, tiktok, yt, fb=None):
    if selected_mode == "Long":
        # save_long is optional, so it doesn't gate the button
        return _file_is_ready(mp4) and _file_is_ready(png)
    elif selected_mode == "Shorts":
        # shorts are optional: at least one, and every one that was picked has finished arriving
        shorts = (insta, tiktok, yt, fb)
        return any(_file_is_ready(f) for f in shorts) and all(f is None or _file_is_ready(f) for f in shorts)
    return False

def refresh_upload_button(selected_mode, mp4, png, insta, tiktok, yt, fb=None, lang=DEFAULT_LANG):
    ready = files_ready_for_mode(selected_mode, mp4, png, insta, tiktok, yt, fb)
    if ready:
        return gr.update(interactive=True, value=T(lang, "upload_ready"))
    return gr.update(interactive=False, value=T(lang, "upload_waiting"))

# ---------- THUMBNAIL: ANY IMAGE -> PNG ----------
# The thumbnail can be any image the user has (JPG, WEBP, HEIC from an iPhone, GIF, BMP, TIFF...).
# It is always stored in the cloud as a real PNG. A file that is already a PNG goes up untouched.
import tempfile
from PIL import Image, ImageOps

try:                                    # HEIC / HEIF (iPhone photos) and AVIF: optional, needs pillow-heif
    import pillow_heif
    pillow_heif.register_heif_opener()
    if hasattr(pillow_heif, "register_avif_opener"):
        pillow_heif.register_avif_opener()
except Exception:
    pass

THUMB_FILE_TYPES = ["image", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff",
                    ".heic", ".heif", ".avif", ".jfif", ".ico", ".svg"]


def thumbnail_as_png(path):
    """Returns (png_path, made_new_file). Raises ValueError when the file is not a readable image."""
    if os.path.splitext(path)[1].lower() == ".svg":
        try:
            import cairosvg
        except ImportError:
            raise ValueError("SVG needs the 'cairosvg' package on the server")
        out = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
        cairosvg.svg2png(url=path, write_to=out)
        return out, True

    try:
        with Image.open(path) as img:
            if img.format == "PNG" and not getattr(img, "is_animated", False):
                img.verify()                     # a real, complete PNG: upload the original as it is
                return path, False
            img.seek(0)                          # animated GIF / WEBP: the first frame
            img = ImageOps.exif_transpose(img)   # phone photos: keep them the right way up
            has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
            img = img.convert("RGBA" if has_alpha else "RGB")
            out = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
            img.save(out, "PNG", optimize=True)
            return out, True
    except ValueError:
        raise
    except Exception as e:
        raise ValueError(f"{type(e).__name__}: {e}")


# ---------- DROPBOX GAME SAVE UPLOAD ----------
def upload_game_save_file(save_file, dropbox_path):
    """
    Uploads a .zip/.rar game save file to Dropbox at the given path.
    The actual file upload and the "get a shareable link" step are kept separate
    on purpose: an old/incompatible dropbox SDK version can throw odd errors
    (e.g. "unexpected use of the catch-all tag 'other'") while parsing Dropbox's
    response, even though the bytes already reached Dropbox fine. That kind of
    error typically happens *after* the transfer completes, while the SDK is
    deserializing the confirmation - so we don't trust the exception alone; we
    check whether the file actually landed before deciding it really failed.

    Returns (share_url, link_error). share_url is "" if a link couldn't be
    obtained; link_error is None on full success, otherwise a short message.
    Raises only if the file genuinely isn't on Dropbox afterward.
    """
    try:
        with open(save_file.name, "rb") as f:
            dbx.files_upload(
                f.read(),
                dropbox_path,
                mode=dropbox.files.WriteMode.overwrite
            )
    except Exception as e:
        # Log the FULL traceback to the Space's console/Logs tab - the message we
        # show the user is deliberately short, but this is the only way to see
        # what Dropbox's API actually returned under the hood.
        print(f"[Dropbox upload error] {type(e).__name__}: {e}\n{traceback.format_exc()}")
        if not dropbox_file_exists(dropbox_path):
            raise RuntimeError(f"{type(e).__name__}: {e} (see Space logs for full traceback)") from e
        # else: the transfer actually succeeded despite the SDK error - continue on

    try:
        link = dbx.sharing_create_shared_link_with_settings(dropbox_path)
        return link.url, None
    except Exception:
        pass  # link may already exist, or SDK choked on the response - try the fallback

    try:
        links = dbx.sharing_list_shared_links(path=dropbox_path)
        if links.links:
            return links.links[0].url, None
        return "", "No shareable link was returned by Dropbox."
    except Exception as e:
        return "", f"file uploaded, but couldn't fetch a shareable link ({e})"

# ---------- GOOGLE SHEETS CONFIG ----------
def _spreadsheet_id(raw):
    """Accepts the bare ID or the whole spreadsheet address (…/spreadsheets/d/<ID>/edit…)."""
    raw = (raw or "").strip().strip('"').strip("'")
    m = re.search(r"/spreadsheets/d/([A-Za-z0-9_-]+)", raw)
    return m.group(1) if m else raw


# the Collector spreadsheet (same default as COLLECTOR_SHEET_ID on admin.gamify.pt)
SPREADSHEET_ID = _spreadsheet_id(os.getenv("SPREADSHEET_ID") or "1Aq9Oyij8xwUahIuz6AfMszvCi9HZNYFnSQQ4TBnGRuY")

# On Hugging Face Spaces there's no persistent local filesystem to point a file path
# at, and you shouldn't be committing the raw service_account.json into the repo
# anyway. Instead, paste the *contents* of the JSON key file into a Space secret
# named GSHEETS_CREDS_JSON (Settings -> Variables and secrets -> New secret).
# GSHEETS_CREDS_FILE is kept as a fallback for local/PythonAnywhere use only.
GSHEETS_CREDS_JSON = os.getenv("GSHEETS_CREDS_JSON")
GSHEETS_CREDS_FILE = os.getenv("GSHEETS_CREDS_FILE")

_gs_client = None
_gs_identity = {"email": ""}             # the service account's e-mail (shown in error messages)

def get_gsheets_client():
    global _gs_client
    if _gs_client is None:
        scopes = ["https://www.googleapis.com/auth/spreadsheets"]

        if GSHEETS_CREDS_JSON:
            try:
                info = json.loads(GSHEETS_CREDS_JSON)
            except json.JSONDecodeError as e:
                raise RuntimeError(
                    "GSHEETS_CREDS_JSON secret is not valid JSON. Make sure you "
                    "pasted the ENTIRE contents of the service_account.json file "
                    f"(including the outer {{ }}). Parse error: {e}"
                )
            creds = Credentials.from_service_account_info(info, scopes=scopes)
            _gs_identity["email"] = info.get("client_email", "")
        elif GSHEETS_CREDS_FILE and os.path.exists(GSHEETS_CREDS_FILE):
            creds = Credentials.from_service_account_file(GSHEETS_CREDS_FILE, scopes=scopes)
        else:
            raise RuntimeError(
                "No Google service account credentials found. On Hugging Face "
                "Spaces, add a secret named GSHEETS_CREDS_JSON containing the full "
                "contents of your service_account.json file."
            )

        _gs_client = gspread.authorize(creds)
    return _gs_client

def get_worksheet(tab_name):
    if not SPREADSHEET_ID:
        raise RuntimeError("SPREADSHEET_ID is not set in this Space (Settings -> Variables and secrets). "
                           "Use the ID of the Collector spreadsheet that holds the <WORLD>Non-Shorts / "
                           "<WORLD>Shorts tabs.")
    client = get_gsheets_client()
    who = _gs_identity["email"] or "the service account in GSHEETS_CREDS_JSON"
    try:
        sheet = client.open_by_key(SPREADSHEET_ID)
    except gspread.exceptions.SpreadsheetNotFound:
        raise RuntimeError(f"Google can't find the spreadsheet '{SPREADSHEET_ID}' for {who}. Check that "
                           f"SPREADSHEET_ID is the Collector spreadsheet's ID (the code between /d/ and /edit) "
                           f"and that the spreadsheet is shared with {who} as Editor.")
    except gspread.exceptions.APIError as e:
        status = getattr(getattr(e, "response", None), "status_code", None)
        if status in (403, 404):
            raise RuntimeError(f"Google refused access to the spreadsheet '{SPREADSHEET_ID}' ({status}). "
                               f"Share it with {who} as Editor, and check that SPREADSHEET_ID is right.")
        raise
    try:
        return sheet.worksheet(tab_name)
    except gspread.exceptions.WorksheetNotFound:
        available = [ws.title for ws in sheet.worksheets()]
        raise RuntimeError(
            f"No tab named '{tab_name}' found in the spreadsheet. "
            f"Available tabs: {', '.join(available) if available else '(none found)'}. "
            f"Check that the tab name is exact (case-sensitive, no extra spaces)."
        )

def find_target_row(worksheet, header_row=1):
    """
    Scans column A from the bottom of existing data upward.
    - If a row's column A is not empty, it's skipped (move up).
    - The first row found with an empty column A is the target
      (this also lets you reuse a blank row if one exists between filled rows).
    - If no blank is found among existing rows, appends right after the last filled row.
    """
    col_a = worksheet.col_values(1)  # includes header row; stops at last non-empty cell

    for idx in range(len(col_a) - 1, header_row - 1, -1):  # 0-based, walking upward
        if not col_a[idx].strip():
            return idx + 1  # 1-indexed row number

    return len(col_a) + 1  # no gap found -> append after the last filled row

# ---------- AUTHOR -> DESTINATION TAB LOOKUP ----------
def write_long_video_row(author_name, game_name, part, yt_playlist, game_url, synopsis, chapters, instructions):
    safe_author = sanitize_author_component(author_name)
    target_tab = f"{safe_author}Non-Shorts"
    ws = get_worksheet(target_tab)
    row = find_target_row(ws)
    ws.batch_update([
        {"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
        {"range": f"B{row}", "values": [[part]]},
        {"range": f"D{row}", "values": [[yt_playlist]]},
        {"range": f"E{row}", "values": [[game_url]]},
        {"range": f"I{row}", "values": [[synopsis or ""]]},
        {"range": f"AL{row}", "values": [[chapters or ""]]},
        {"range": f"AJ{row}", "values": [[instructions or ""]]},
    ])
    return row, target_tab

def write_shorts_row(author_name, game_name, part, yt_playlist, game_url, synopsis):
    safe_author = sanitize_author_component(author_name)
    target_tab = f"{safe_author}Shorts"
    ws = get_worksheet(target_tab)
    row = find_target_row(ws)
    ws.batch_update([
        {"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
        {"range": f"B{row}", "values": [[part]]},
        {"range": f"C{row}", "values": [[yt_playlist]]},
        {"range": f"D{row}", "values": [[game_url]]},
        {"range": f"J{row}", "values": [[synopsis or ""]]},
    ])
    return row, target_tab

# ---------- UPLOAD FUNCTIONS ----------
# Status messages: "✓" = done, "⚠" = needs attention (warnings are highlighted in the UI).
def _summary(lang, uploaded, already_exists, errors):
    parts = []
    if uploaded:
        parts.append(T(lang, "m_uploaded", x=", ".join(uploaded)))
    if already_exists:
        parts.append(T(lang, "m_exists", x=", ".join(already_exists)))
    if errors:
        parts.append(T(lang, "m_errors", x=", ".join(errors)))
    return parts


def upload_long(
    author_name, mp4, png, save_file,
    game_name, part, yt_playlist, game_url,
    synopsis, chapters, instructions, lang=DEFAULT_LANG
):
    missing = find_missing_fields({
        T(lang, "f_author"): author_name,
        T(lang, "f_game"): game_name,
        T(lang, "f_part"): part,
        T(lang, "f_playlist"): yt_playlist,
        T(lang, "f_game_url"): game_url,
    })
    if mp4 is None:
        missing.append("MP4")
    if png is None:
        missing.append("PNG")
    if missing:
        return T(lang, "m_missing", f=", ".join(missing)), ""

    author_name = strip_invalid_filename_chars(author_name).strip().upper()

    # ---- Defensive re-check: make sure the files actually finished uploading to
    # ---- the server before we start pushing them to B2. The button should already
    # ---- prevent this, but we never trust the frontend alone for something this costly.
    for label, f in (("MP4", mp4), ("PNG", png)):
        if not _file_is_ready(f):
            return T(lang, "m_not_ready", n=label), ""
    if save_file is not None and not _file_is_ready(save_file):
        return T(lang, "m_not_ready", n=T(lang, "f_save")), ""

    mp4_size = os.path.getsize(mp4.name)
    if mp4_size > 800 * 1024 * 1024:
        return T(lang, "m_mp4_size"), ""

    # any image type -> PNG (before anything goes to the cloud, so a bad image stops here)
    try:
        png_path, png_is_temp = thumbnail_as_png(png.name)
    except ValueError as e:
        return T(lang, "m_bad_image", e=e), ""

    if save_file is not None:
        save_ext = os.path.splitext(save_file.name)[1].lower()
        if save_ext not in (".zip", ".rar"):
            return T(lang, "m_save_ext"), ""
        save_size = os.path.getsize(save_file.name)
        if save_size > 50 * 1024 * 1024:
            return T(lang, "m_save_size"), ""

    # ---- Build the final filename: Game_Part.ext (inside the Author's bucket) ----
    safe_author = sanitize_author_component(author_name)
    safe_game_part = sanitize_game_part_component(game_name, part)
    if not safe_author or not safe_game_part or not clean_game_name(game_name):
        return T(lang, "m_invalid_chars"), ""
    if author_length_error(safe_author, lang):
        return author_length_error(safe_author, lang), ""

    # ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
    try:
        author_bucket = get_author_bucket(safe_author)
    except B2KeyRestricted as e:
        if png_is_temp:
            try:
                os.remove(png_path)
            except OSError:
                pass
        return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
    if author_bucket is None:
        return T(lang, "m_no_bucket", b=safe_author), ""

    existing_files, used_bytes = b2_bucket_contents(author_bucket)

    base_name = safe_game_part              # file names: Game_Part only (the bucket is already the author's)
    mp4_name = f"Non-Shorts/{base_name}.mp4"
    png_name = f"Non-Shorts/{base_name}.png"

    # ---- Cloud storage limit: what is already there + what this upload adds ----
    incoming = ((mp4_size if mp4_name not in existing_files else 0) +
                (os.path.getsize(png_path) if png_name not in existing_files else 0))
    if storage_would_overflow(safe_author, used_bytes, incoming):
        if png_is_temp:
            try:
                os.remove(png_path)
            except OSError:
                pass
        return T(lang, "m_storage_full"), ""
    added_bytes = 0
    _up_total(incoming)

    uploaded = []
    already_exists = []
    errors = []

    if mp4_name in existing_files:
        already_exists.append(f"MP4 ({mp4_name})")
    else:
        try:
            _up_stage("up_file", f="MP4")
            author_bucket.upload_local_file(mp4.name, mp4_name, progress_listener=_up_listener())
            _up_file_done(mp4_size)
            uploaded.append(f"MP4 ({mp4_name})")
            added_bytes += mp4_size
        except Exception as e:
            if png_is_temp:
                try:
                    os.remove(png_path)
                except OSError:
                    pass
            return T(lang, "m_mp4_failed", e=e), ""

    if png_name in existing_files:
        already_exists.append(f"PNG ({png_name})")
    else:
        try:
            _up_stage("up_file", f="PNG")
            author_bucket.upload_local_file(png_path, png_name, content_type="image/png",
                                            progress_listener=_up_listener())
            _up_file_done(os.path.getsize(png_path))
            uploaded.append(f"PNG ({png_name})")
            added_bytes += os.path.getsize(png_path)
        except Exception as e:
            errors.append(f"PNG ({png_name}) - {e}")
    if png_is_temp:
        try:
            os.remove(png_path)
        except OSError:
            pass
    if added_bytes:
        report_storage_used(safe_author, used_bytes + added_bytes)

    share_url = ""
    save_label = T(lang, "f_save")
    if save_file is not None:
        save_ext = os.path.splitext(save_file.name)[1].lower()
        save_remote_name = f"{base_name}{save_ext}"
        dropbox_path = f"/Game Saves/{save_remote_name}"

        if dropbox_file_exists(dropbox_path):
            already_exists.append(f"{save_label} ({save_remote_name})")
        else:
            try:
                _up_stage("up_save")
                share_url, link_error = upload_game_save_file(save_file, dropbox_path)
                uploaded.append(f"{save_label} ({save_remote_name})")
                if link_error:
                    errors.append(f"{save_label} {T(lang, 'f_share_link')} ({save_remote_name}) - {link_error}")
            except Exception as e:
                errors.append(f"{save_label} ({save_remote_name}) - {e}")

    message_parts = _summary(lang, uploaded, already_exists, errors)
    _up_stage("up_sheet")
    try:
        row, target_tab = write_long_video_row(
            author_name, game_name, part, yt_playlist, game_url,
            synopsis, chapters, instructions
        )
        message_parts.append(T(lang, "m_sheet_ok", t=target_tab, r=row))
    except Exception as e:
        message_parts.append(T(lang, "m_sheet_fail", e=e))

    return "\n".join(message_parts), share_url

def upload_shorts(author_name, insta, youtube, tiktok, facebook, game_name, part, yt_playlist, game_url, synopsis,
                  lang=DEFAULT_LANG):
    missing = find_missing_fields({
        T(lang, "f_author"): author_name,
        T(lang, "f_game"): game_name,
        T(lang, "f_part"): part,
        T(lang, "f_playlist"): yt_playlist,
        T(lang, "f_game_url"): game_url,
    })

    files = {
        "INSTSTORIES": insta,
        "TIKTOK": tiktok,
        "YTBSTORIES": youtube,
        "FACEBOOK": facebook,
    }
    files = {name: file for name, file in files.items() if file is not None}    # only the shorts picked
    if missing:
        return T(lang, "m_missing", f=", ".join(missing)), ""
    if not files:
        return T(lang, "m_need_one_short"), ""

    author_name = strip_invalid_filename_chars(author_name).strip().upper()

    # ---- Defensive re-check: same guard as upload_long, in case the button's
    # ---- readiness state ever gets out of sync with reality.
    for name, file in files.items():
        if not _file_is_ready(file):
            return T(lang, "m_not_ready", n=name), ""

    for name, file in files.items():
        file_size = os.path.getsize(file.name)
        if file_size > 100 * 1024 * 1024:
            return T(lang, "m_short_size", n=name), ""

    # ---- Build the final filenames: Game_Part_<PLATFORM>.mp4 (inside the Author's bucket) ----
    safe_author = sanitize_author_component(author_name)
    safe_game_part = sanitize_game_part_component(game_name, part)
    if not safe_author or not safe_game_part or not clean_game_name(game_name):
        return T(lang, "m_invalid_chars"), ""
    if author_length_error(safe_author, lang):
        return author_length_error(safe_author, lang), ""

    # ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
    try:
        author_bucket = get_author_bucket(safe_author)
    except B2KeyRestricted as e:
        return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
    if author_bucket is None:
        return T(lang, "m_no_bucket", b=safe_author), ""

    existing_files, used_bytes = b2_bucket_contents(author_bucket)

    base_name = safe_game_part              # file names: Game_Part only (the bucket is already the author's)
    remote_names = {
        name: f"Shorts/{base_name}_{name}.mp4"
        for name in files
    }

    # ---- Cloud storage limit: what is already there + what this upload adds ----
    incoming = sum(os.path.getsize(file.name) for name, file in files.items()
                   if remote_names[name] not in existing_files)
    if storage_would_overflow(safe_author, used_bytes, incoming):
        return T(lang, "m_storage_full"), ""
    added_bytes = 0
    _up_total(incoming)

    uploaded = []
    already_exists = []
    errors = []
    for name, file in files.items():
        remote_name = remote_names[name]
        if remote_name in existing_files:
            already_exists.append(f"{name} ({remote_name})")
        else:
            try:
                _up_stage("up_file", f=name)
                author_bucket.upload_local_file(file.name, remote_name, progress_listener=_up_listener())
                _up_file_done(os.path.getsize(file.name))
                uploaded.append(f"{name} ({remote_name})")
                added_bytes += os.path.getsize(file.name)
            except Exception as e:
                errors.append(f"{name} ({remote_name}) - {e}")
    if added_bytes:
        report_storage_used(safe_author, used_bytes + added_bytes)

    message_parts = _summary(lang, uploaded, already_exists, errors)
    _up_stage("up_sheet")
    try:
        row, target_tab = write_shorts_row(author_name, game_name, part, yt_playlist, game_url, synopsis)
        message_parts.append(T(lang, "m_sheet_ok", t=target_tab, r=row))
    except Exception as e:
        message_parts.append(T(lang, "m_sheet_fail", e=e))

    return "\n".join(message_parts), ""

def router(
    mode, author_name,
    mp4, png, save_long,
    game_name_long, part_long, yt_playlist_long, game_url_long,
    synopsis_long, chapters_long, instructions_long,
    insta, yt, tiktok, fb,
    game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
    lang=DEFAULT_LANG
):
    lang = lang if lang in LANG_CODES else DEFAULT_LANG
    # Final gate, right before doing any real work: even if the button was somehow
    # clickable, refuse to proceed unless the files this mode needs are actually
    # ready on the server.
    if not files_ready_for_mode(mode, mp4, png, insta, tiktok, yt, fb):
        return (
            T(lang, "m_files_wait"),
            "",
            gr.update(visible=False),
            gr.update(visible=True),
        )

    if mode == "Long":
        message, url = upload_long(
            author_name, mp4, png, save_long,
            game_name_long, part_long, yt_playlist_long, game_url_long,
            synopsis_long, chapters_long, instructions_long, lang
        )
    elif mode == "Shorts":
        message, url = upload_shorts(
            author_name, insta, yt, tiktok, fb,
            game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts, lang
        )
    else:
        message, url = (T(lang, "m_invalid_mode"), "")

    return (
        message,
        url,
        gr.update(visible=bool(url)),
        gr.update(visible=True),
    )


def router_safe(*args):
    """router, but an unexpected error still ends the upload with a message (and unlocks the page)."""
    try:
        return router(*args)
    except Exception as e:
        traceback.print_exc()
        return (f"⚠ {type(e).__name__}: {e}", "", gr.update(visible=False), gr.update(visible=True))


# ============================================================
# BACKGROUND UPLOAD + PROGRESS BAR
# Sending an 800 MB video to the cloud takes minutes. Upload now only STARTS a job on the server
# and returns at once; a light check every 2 s shows the progress bar and, at the end, the result.
# If the page reloads (or the connection drops), the job keeps going and comes back on its own.
# ============================================================
import threading
import uuid
import html as html_lib

try:
    from b2sdk.v2 import AbstractProgressListener as _B2ProgressListener
except Exception:
    try:
        from b2sdk.progress import AbstractProgressListener as _B2ProgressListener
    except Exception:
        _B2ProgressListener = None

UP_POLL_SECONDS = 2.0
UP_KEEP_HOURS = 24
UP_LOST_HOURS = 6
_UP_JOBS = {}
_UP_LOCK = threading.Lock()
_UP_LOCAL = threading.local()
_UP_ID_RE = re.compile(r"^[a-f0-9]{32}$")


def _up_job():
    return getattr(_UP_LOCAL, "job", None)


def _up_stage(key, **values):
    job = _up_job()
    if job is not None:
        job["stage"] = (key, values)


def _up_total(total_bytes):
    job = _up_job()
    if job is not None:
        job["total_bytes"] = int(total_bytes or 0)


def _up_file_done(size):
    job = _up_job()
    if job is not None:
        job["completed_bytes"] += int(size or 0)
        job["done_bytes"] = job["completed_bytes"]


if _B2ProgressListener is not None:
    class _UploadListener(_B2ProgressListener):
        """Tells the progress bar how many bytes of the current file reached the cloud."""

        def __init__(self, job):
            super().__init__()
            self.job = job
            self.base = job["completed_bytes"]

        def set_total_bytes(self, total_byte_count):
            pass

        def bytes_completed(self, byte_count):
            self.job["done_bytes"] = max(self.job["done_bytes"], self.base + int(byte_count or 0))

        def close(self):
            try:
                super().close()
            except Exception:
                pass


def _up_listener():
    job = _up_job()
    if job is None or _B2ProgressListener is None:
        return None
    return _UploadListener(job)


def _fmt_elapsed(seconds):
    seconds = int(max(seconds, 0))
    m, s = divmod(seconds, 60)
    h, m = divmod(m, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


def _fmt_size(n):
    n = float(n or 0)
    return f"{n / 2**30:.2f} GB" if n >= 2**30 else f"{n / 2**20:.0f} MB"


def _up_pct(job):
    if job["state"] == "done":
        return 100
    key = (job.get("stage") or ("up_check", {}))[0]
    if key == "up_check":
        return 2
    if key == "up_save":
        return 90
    if key == "up_sheet":
        return 95
    total = job.get("total_bytes") or 0
    done = min(job.get("done_bytes") or 0, total) if total else 0
    return int(5 + 85 * (done / total)) if total else 5


def upload_progress_html(job, lang):
    """The progress bar shown under the Upload button (empty when there is no job)."""
    if not job:
        return ""
    esc = html_lib.escape
    elapsed = (job.get("finished") or time.time()) - job["created"]
    pct = _up_pct(job)
    if job["state"] == "done":
        title, meta, fill, hint = T(lang, "up_done", e=_fmt_elapsed(elapsed)), "", "#3DDC84", ""
    else:
        key, values = job.get("stage") or ("up_check", {})
        title = T(lang, key, **values)
        total = job.get("total_bytes") or 0
        if key == "up_file" and total:
            meta = T(lang, "up_meta", d=_fmt_size(min(job.get("done_bytes") or 0, total)), t=_fmt_size(total),
                     e=_fmt_elapsed(elapsed))
        else:
            meta = T(lang, "up_elapsed", e=_fmt_elapsed(elapsed))
        fill = f"linear-gradient(90deg, {ACCENT}, {ACCENT_2})"
        hint = f"<div style='margin-top:6px;font-size:12px;color:{MUTED}'>{esc(T(lang, 'up_hint'))}</div>"
    return (
        f"<div class='uc-job' style='background:{SURFACE};border:1px solid {BORDER};border-radius:12px;padding:12px 14px;margin:8px 0'>"
        f"<div style='display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap'>"
        f"<div style='font-weight:700;color:{TEXT}'>{esc(title)}</div>"
        f"<div style='font-size:22px;font-weight:800;color:{TEXT};font-variant-numeric:tabular-nums'>{pct}%</div></div>"
        f"<div style='height:10px;border-radius:999px;background:{INPUT};overflow:hidden;margin-top:8px'>"
        f"<div style='height:100%;width:{pct}%;background:{fill};border-radius:999px;transition:width 1.8s linear'></div></div>"
        f"<div style='margin-top:6px;font-size:12px;color:{MUTED};font-variant-numeric:tabular-nums'>{esc(meta)}</div>"
        f"{hint}</div>"
    )


def _up_cleanup():
    cutoff = time.time() - UP_KEEP_HOURS * 3600
    with _UP_LOCK:
        for jid in [j for j, job in _UP_JOBS.items() if job["state"] == "done" and job.get("finished", 0) < cutoff]:
            _UP_JOBS.pop(jid, None)


def _run_upload_job(job, args):
    _UP_LOCAL.job = job
    try:
        job["result"] = router_safe(*args)
    finally:
        job["state"] = "done"
        job["finished"] = time.time()
        _UP_LOCAL.job = None
        print(f"UPLOAD JOB {job['id']} finished in {_fmt_elapsed(job['finished'] - job['created'])}")


def _up_from_box(value):
    raw = str(value or "").strip()
    jid, _, ts = raw.partition("|")
    if not _UP_ID_RE.match(jid):
        return None, "", None
    try:
        age = (time.time() - int(ts) / 1000) / 3600 if ts else None
    except ValueError:
        age = None
    with _UP_LOCK:
        return _UP_JOBS.get(jid), jid, age


def start_upload(*values):
    """Upload: starts the job and returns at once.
    Outputs: job_box, job_timer, upload_btn, progress_html, output, share_url, share_col, result_group."""
    *args, current = values
    lang = args[-1] if args and args[-1] in LANG_CODES else DEFAULT_LANG
    keep = gr.update()

    running, _, _ = _up_from_box(current)
    if running is not None and running["state"] == "running":
        return (keep, gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading")),
                upload_progress_html(running, lang), T(lang, "up_busy"), keep, keep, gr.update(visible=True))

    mode, mp4, png, insta, yt, tiktok, fb = args[0], args[2], args[3], args[12], args[13], args[14], args[15]
    if not files_ready_for_mode(mode, mp4, png, insta, tiktok, yt, fb):
        return (keep, gr.update(active=False), keep, "", T(lang, "m_files_wait"), "",
                gr.update(visible=False), gr.update(visible=True))

    _up_cleanup()
    jid = uuid.uuid4().hex
    job = {"id": jid, "state": "running", "created": time.time(), "stage": ("up_check", {}),
           "total_bytes": 0, "done_bytes": 0, "completed_bytes": 0, "result": None}
    with _UP_LOCK:
        _UP_JOBS[jid] = job
    threading.Thread(target=_run_upload_job, args=(job, list(args)), name=f"upload-{jid[:8]}", daemon=True).start()
    print(f"UPLOAD JOB {jid} started ({mode})")
    return (jid, gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading")),
            upload_progress_html(job, lang), "", "", gr.update(visible=False), gr.update(visible=False))


def _up_outputs(job, lang):
    """progress_html, output, share_url, share_col, result_group for a job."""
    if job["state"] == "done":
        message, url, share_u, _ = job["result"] or ("", "", gr.update(visible=False), None)
        return [upload_progress_html(job, lang), message, url, share_u, gr.update(visible=True)]
    keep = gr.update()
    return [upload_progress_html(job, lang), keep, keep, keep, gr.update(visible=False)]


def poll_upload(job_value, lang, *file_values):
    """Every 2 s while an upload runs. Outputs: job_timer, upload_btn + _up_outputs."""
    lang = lang if lang in LANG_CODES else DEFAULT_LANG
    job, _, _ = _up_from_box(job_value)
    keep = gr.update()
    button = refresh_upload_button(*file_values[:7], lang)
    if job is None:
        return [gr.update(active=False), button, "", T(lang, "up_lost"), keep, keep, gr.update(visible=True)]
    if job["state"] == "running":
        return [gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading"))] + _up_outputs(job, lang)
    return [gr.update(active=False), button] + _up_outputs(job, lang)


def recover_upload(job_value, lang):
    """Page (re)loaded: brings back this browser's last upload. Outputs: job_box, job_timer + _up_outputs."""
    lang = lang if lang in LANG_CODES else DEFAULT_LANG
    job, jid, age = _up_from_box(job_value)
    keep = gr.update()
    if job is None:
        lost = bool(jid) and age is not None and age < UP_LOST_HOURS
        return ["", gr.update(active=False), "", T(lang, "up_lost") if lost else keep, keep, keep,
                gr.update(visible=lost)]
    return [jid, gr.update(active=job["state"] == "running")] + _up_outputs(job, lang)


SAVE_UP_JS = """
(job) => { try { if (job) localStorage.setItem('uc_job', job + '|' + Date.now()); } catch (e) {} }
"""
READ_UP_JS = """
(job, lang) => {
  let j = '', l = lang;
  try { j = localStorage.getItem('uc_job') || ''; l = localStorage.getItem('uc_lang') || lang; } catch (e) {}
  return [j, l];
}
"""
CLEAR_UP_JS = """
() => { try { localStorage.removeItem('uc_job'); } catch (e) {} }
"""


# ============================================================
# THEME — dark editor look (same as the other apps)
# ============================================================
ACCENT = "#1ED6E0"     # CapCut cyan
ACCENT_2 = "#5C7CFF"   # gradient blue
BG = "#0D0E12"         # app background
PANEL = "#15161C"      # panels (sections, top bar)
SURFACE = "#1C1D25"    # cards / blocks
INPUT = "#23242E"      # fields
BORDER = "#272934"
TEXT = "#ECEDF2"
MUTED = "#8C8FA0"


def _params(fn):
    try:
        return inspect.signature(fn).parameters
    except (TypeError, ValueError):
        return {}


def _accepts(fn):
    ps = _params(fn)
    if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ps.values()):
        return lambda name: True
    return lambda name: name in ps


# same colours for light and dark mode: the app is always dark
PALETTE = {
    "body_background_fill": BG,
    "body_text_color": TEXT,
    "body_text_color_subdued": MUTED,
    "background_fill_primary": PANEL,
    "background_fill_secondary": SURFACE,
    "border_color_primary": BORDER,
    "border_color_accent": ACCENT,
    "color_accent": ACCENT,
    "color_accent_soft": "rgba(30, 214, 224, 0.14)",
    "block_background_fill": SURFACE,
    "block_border_color": BORDER,
    "block_border_width": "1px",
    "block_label_background_fill": SURFACE,
    "block_label_text_color": MUTED,
    "block_label_border_color": BORDER,
    "block_title_text_color": MUTED,
    "block_info_text_color": MUTED,
    "block_radius": "10px",
    "block_shadow": "none",
    "panel_background_fill": PANEL,
    "panel_border_color": BORDER,
    "input_background_fill": INPUT,
    "input_background_fill_focus": "#292A35",
    "input_background_fill_hover": "#292A35",
    "input_border_color": "#30323F",
    "input_border_color_focus": ACCENT,
    "input_border_color_hover": "#3B3E4D",
    "input_placeholder_color": "#5F6273",
    "input_radius": "8px",
    "button_primary_background_fill": f"linear-gradient(90deg, {ACCENT}, {ACCENT_2})",
    "button_primary_background_fill_hover": "linear-gradient(90deg, #45E2EA, #7690FF)",
    "button_primary_text_color": "#06131A",
    "button_primary_text_color_hover": "#06131A",
    "button_primary_border_color": "transparent",
    "button_secondary_background_fill": INPUT,
    "button_secondary_background_fill_hover": "#2D2E3A",
    "button_secondary_text_color": TEXT,
    "button_secondary_text_color_hover": TEXT,
    "button_secondary_border_color": "#30323F",
    "button_large_radius": "8px",
    "button_small_radius": "8px",
    "slider_color": ACCENT,
    "loader_color": ACCENT,
    "link_text_color": ACCENT,
    "link_text_color_hover": "#6BE9EF",
    "shadow_drop": "none",
    "shadow_drop_lg": "0 12px 32px rgba(0, 0, 0, 0.45)",
    "shadow_spread": "0px",
    "checkbox_background_color": INPUT,
    "checkbox_border_color": "#3B3E4D",
    "checkbox_background_color_selected": ACCENT,
    "checkbox_label_background_fill": INPUT,
    "checkbox_label_background_fill_selected": "rgba(30, 214, 224, 0.16)",
    "checkbox_label_border_color_selected": ACCENT,
    "checkbox_label_text_color": TEXT,
    "checkbox_label_text_color_selected": TEXT,
    "table_border_color": BORDER,
}


def make_theme():
    font = [gr.themes.GoogleFont("Manrope"), "ui-sans-serif", "system-ui", "sans-serif"]
    try:
        theme = gr.themes.Base(
            primary_hue=gr.themes.colors.cyan,
            secondary_hue=gr.themes.colors.blue,
            neutral_hue=gr.themes.colors.zinc,
            font=font,
            radius_size=gr.themes.sizes.radius_md,
            text_size=gr.themes.sizes.text_sm,
            spacing_size=gr.themes.sizes.spacing_md,
        )
    except Exception:
        theme = gr.themes.Base()
    ok = _accepts(theme.set)
    kw = {}
    for k, v in PALETTE.items():
        for name in (k, k + "_dark"):
            if ok(name):
                kw[name] = v
    try:
        return theme.set(**kw)
    except Exception:
        return theme


THEME = make_theme()

# Always dark + warnings stand out: the Status box turns amber when a message contains "⚠"
# (and cyan when everything went through).
PAGE_JS = """
() => {
  document.body.classList.add('dark');
  document.documentElement.classList.add('dark');
  setInterval(() => {
    const box = document.querySelector('#status_box');
    const ta = box && box.querySelector('textarea');
    if (!box || !ta) return;
    const v = ta.value || '';
    box.classList.toggle('ap-warn-box', v.includes('⚠'));
    box.classList.toggle('ap-ok-box', !v.includes('⚠') && v.includes('✓'));
  }, 600);
}
"""

# Browser-side translations (Copy Link button texts)
I18N_JS = """
() => {
  window.__ucI18n = %s;
  window.__ucL = (k) => {
    const all = window.__ucI18n;
    const d = all[window.__ucLang] || all.en;
    return (d && d[k]) || all.en[k] || k;
  };
}
""" % json.dumps(JS_I18N, ensure_ascii=False)

# remember the chosen language in this browser and restore it on the next visit
READ_LANG_JS = """
(lang) => {
  let l = lang;
  try { l = localStorage.getItem('uc_lang') || lang; } catch (e) {}
  window.__ucLang = l;
  document.documentElement.lang = l;
  return l;
}
"""
SET_LANG_JS = """
(lang) => {
  window.__ucLang = lang;
  document.documentElement.lang = lang;
  try { localStorage.setItem('uc_lang', lang); } catch (e) {}
}
"""


def brand_html(lang=DEFAULT_LANG):
    return f"""
<div class="ap-brand">
  <div class="ap-logo">
    <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 3l6 6h-4v6h-4V9H6z M5 17h14v3H5z" fill="#06131A"/>
    </svg>
  </div>
  <div>
    <div class="ap-title">{T(lang, "app_title")}</div>
    <div class="ap-sub">{T(lang, "app_sub")}</div>
  </div>
</div>
"""


def mode_choices(lang=DEFAULT_LANG):
    # label shown to the user, value stays "Long"/"Shorts" for the upload logic
    return [(T(lang, "mode_long"), "Long"), (T(lang, "mode_shorts"), "Shorts")]


css = f"""
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap');
:root {{
    --ap-bg: {BG}; --ap-panel: {PANEL}; --ap-surface: {SURFACE}; --ap-input: {INPUT};
    --ap-border: {BORDER}; --ap-text: {TEXT}; --ap-muted: {MUTED};
    --ap-accent: {ACCENT}; --ap-accent-2: {ACCENT_2}; --ap-on-accent: #06131A;
}}
html, body, gradio-app {{ background: var(--ap-bg) !important; }}
.gradio-container {{
    max-width: 1280px !important; width: 100% !important;
    margin-left: auto !important; margin-right: auto !important;
    padding: 14px 18px 28px !important;
    background: var(--ap-bg) !important; color: var(--ap-text);
}}
footer, .gradio-footer {{ display: none !important; visibility: hidden; height: 0; }}
::-webkit-scrollbar {{ width: 10px; height: 10px; }}
::-webkit-scrollbar-track {{ background: transparent; }}
::-webkit-scrollbar-thumb {{ background: #2B2C38; border-radius: 8px; border: 2px solid var(--ap-bg); }}

/* ---- top bar ---- */
#header_row {{
    background: var(--ap-panel); border: 1px solid var(--ap-border); border-radius: 14px;
    padding: 8px 10px 8px 14px !important; align-items: center !important; margin-bottom: 6px;
}}
#header_row .block, #header_row .form, #header_row .html-container {{
    background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important;
}}
.ap-brand {{ display: flex; align-items: center; gap: 12px; }}
.ap-logo {{
    width: 36px; height: 36px; border-radius: 10px; flex: none; display: grid; place-items: center;
    background: linear-gradient(135deg, var(--ap-accent), var(--ap-accent-2));
    box-shadow: 0 0 22px rgba(30, 214, 224, 0.28);
}}
.ap-title {{ font-weight: 800; font-size: 15px; color: var(--ap-text); line-height: 1.15; }}
.ap-sub {{ font-size: 12px; color: var(--ap-muted); line-height: 1.3; }}
#reset_btn {{ min-width: 130px; }}
#lang_col {{ min-width: 150px !important; max-width: 200px; }}
#lang_dd, #lang_dd .wrap, #lang_dd .wrap-inner {{ background: var(--ap-input) !important; border-radius: 8px !important; }}
#lang_dd {{ border: 1px solid #30323F !important; }}
#lang_dd input {{ font-weight: 700 !important; color: var(--ap-text) !important; cursor: pointer; }}

/* ---- section cards ---- */
.section-card {{
    background: var(--ap-panel) !important; border: 1px solid var(--ap-border) !important;
    border-radius: 14px !important; padding: 16px 18px !important; margin-bottom: 6px; gap: 12px !important;
}}
.section-card h3 {{ font-size: 15px !important; font-weight: 800 !important; color: var(--ap-text) !important; margin: 0 0 2px !important; }}
span[data-testid="block-info"] {{ font-size: 12px !important; font-weight: 600 !important; color: var(--ap-muted) !important; }}
.matched-height {{ height: 480px !important; overflow-y: auto !important; }}
#author_name_box input {{ text-transform: uppercase; font-weight: 700; letter-spacing: .3px; }}

/* Upload type as a segmented control */
#mode_radio .wrap {{ gap: 6px !important; }}
#mode_radio label {{
    background: var(--ap-input) !important; border: 1px solid #30323F !important;
    border-radius: 9px !important; padding: 8px 18px !important; font-weight: 700 !important;
}}
#mode_radio label.selected, #mode_radio label:has(input:checked) {{
    background: rgba(30, 214, 224, 0.14) !important; border-color: var(--ap-accent) !important;
    color: var(--ap-text) !important;
}}

/* ---- buttons ---- */
button.primary {{
    background: linear-gradient(90deg, var(--ap-accent), var(--ap-accent-2)) !important;
    color: var(--ap-on-accent) !important; border: none !important; font-weight: 800 !important;
}}
button.primary:hover {{ filter: brightness(1.08); }}
button.primary:disabled, button.primary[disabled] {{ filter: grayscale(.7) brightness(.6); cursor: not-allowed; }}
button:focus-visible, input:focus-visible, textarea:focus-visible {{
    outline: 2px solid var(--ap-accent) !important; outline-offset: 2px;
}}
#upload_row {{ justify-content: center; margin: 14px 0 6px; }}
#upload_btn {{ min-width: 360px; min-height: 52px; font-size: 16px !important; letter-spacing: .3px; border-radius: 12px !important; }}

/* ---- status: warnings stand out ---- */
#status_box textarea {{ font-weight: 600; line-height: 1.5; }}
#status_box.ap-warn-box textarea {{
    border-color: #ffaa28 !important; background: rgba(255, 170, 40, .08) !important; color: #ffe2b8 !important;
}}
#status_box.ap-ok-box textarea {{ border-color: var(--ap-accent) !important; }}
@media (max-width: 900px) {{ #header_row {{ flex-wrap: wrap; }} .matched-height {{ height: auto !important; }} }}
@media (prefers-reduced-motion: reduce) {{ * {{ transition: none !important; }} }}
"""

# Theme: Gradio 6 takes it in mount_gradio_app(); Gradio 4/5 in Blocks().
# CSS is injected with a <style> tag, which works in every version.
_MOUNT_TAKES_THEME = "theme" in _params(gr.mount_gradio_app)
BLOCKS_KWARGS = {"title": "Upload Center"}
if not _MOUNT_TAKES_THEME and "theme" in _params(gr.Blocks.__init__):
    BLOCKS_KWARGS["theme"] = THEME

L0 = DEFAULT_LANG          # language the page is built in (the dropdown switches it live)

# ---------- UI ----------
with gr.Blocks(**BLOCKS_KWARGS) as app:
    gr.HTML(f"<style>{css}</style>")
    with gr.Row(elem_id="header_row", equal_height=True):
        with gr.Column(scale=6):
            brand = gr.HTML(brand_html(L0))
        with gr.Column(scale=1, min_width=150, elem_id="lang_col"):
            lang_dd = gr.Dropdown(choices=LANG_CHOICES, value=L0, show_label=False, container=False,
                                  interactive=True, elem_id="lang_dd")
        with gr.Column(scale=1, min_width=140):
            refresh_all_btn = gr.Button(T(L0, "reset"), variant="secondary", elem_id="reset_btn")

    with gr.Group(elem_classes=["section-card"]):
        sec_author_md = gr.Markdown(T(L0, "sec_author"))
        author_name = gr.Textbox(
            label=T(L0, "author_label"),
            elem_id="author_name_box",
            **({"max_length": AUTHOR_MAX_CHARS} if "max_length" in _params(gr.Textbox.__init__) else {})
        )
        author_name.input(
            fn=sanitize_author_input,
            inputs=author_name,
            outputs=author_name
        )

        mode = gr.Radio(mode_choices(L0), label=T(L0, "mode_label"), value="Long", elem_id="mode_radio")

    # Long Upload Section
    long_col = gr.Column(visible=True)
    with long_col:
        with gr.Group(elem_classes=["section-card"]):
            sec_long_md = gr.Markdown(T(L0, "sec_long"))
            with gr.Row():
                with gr.Column(scale=1, elem_classes=["matched-height"]):
                    game_name_long = gr.Textbox(label=T(L0, "game_name"))
                    part_long = gr.Textbox(label=T(L0, "part"))
                    yt_playlist_long = gr.Textbox(label=T(L0, "yt_playlist"))
                    game_url_long = gr.Textbox(label=T(L0, "game_url"))
                    synopsis_long = gr.Textbox(label=T(L0, "yt_synopsis"), lines=4)
                    chapters_long = gr.Textbox(label=T(L0, "yt_chapters"), lines=4)
                    instructions_long = gr.Textbox(label=T(L0, "instructions"), lines=4)
                with gr.Column(scale=1, elem_classes=["matched-height"]):
                    mp4_long = gr.File(label=T(L0, "mp4_long"), file_types=[".mp4"])
                    png_long = gr.File(label=T(L0, "thumb"), file_types=THUMB_FILE_TYPES)
                    save_long = gr.File(
                        label=T(L0, "save_file"),
                        file_types=[".zip", ".rar"]
                    )

    # Shorts Upload Section
    shorts_col = gr.Column(visible=False)
    with shorts_col:
        with gr.Group(elem_classes=["section-card"]):
            sec_shorts_md = gr.Markdown(T(L0, "sec_shorts"))
            with gr.Row():
                with gr.Column(scale=1, elem_classes=["matched-height"]):
                    game_name_shorts = gr.Textbox(label=T(L0, "game_name"))
                    part_shorts = gr.Textbox(label=T(L0, "part"))
                    yt_playlist_shorts = gr.Textbox(label=T(L0, "yt_playlist"))
                    game_url_shorts = gr.Textbox(label=T(L0, "game_url"))
                    synopsis_shorts = gr.Textbox(label=T(L0, "video_synopsis"), lines=4)
                with gr.Column(scale=1, elem_classes=["matched-height"]):
                    insta = gr.File(label=T(L0, "short_file", p="INSTSTORIES"), file_types=[".mp4"])
                    tiktok = gr.File(label=T(L0, "short_file", p="TIKTOK"), file_types=[".mp4"])
                    yt = gr.File(label=T(L0, "short_file", p="YTBSTORIES"), file_types=[".mp4"])
                    fb = gr.File(label=T(L0, "short_file", p="FACEBOOK"), file_types=[".mp4"])

    # Upload Button (placed after file selection)
    with gr.Row(elem_id="upload_row"):
        upload_btn = gr.Button(
            T(L0, "upload_waiting"),
            variant="primary",
            interactive=False,
            elem_id="upload_btn"
        )

    # background upload: this browser's job id, the 2-second progress check and the bar
    job_box = gr.Textbox(value="", visible=False)
    job_timer = gr.Timer(UP_POLL_SECONDS, active=False)
    progress_html = gr.HTML("", elem_id="upload_progress")

    # Result: hidden until an upload finishes (success or a message to read)
    with gr.Group(elem_classes=["section-card"], visible=False) as result_group:
        sec_result_md = gr.Markdown(T(L0, "sec_result"))
        output = gr.Textbox(
            label=T(L0, "status"),
            interactive=False,
            lines=3,
            elem_id="status_box"
        )
        share_col = gr.Column(visible=False)

        with share_col:

            share_url = gr.Textbox(
                label=T(L0, "share_url"),
                interactive=False,
                elem_id="share_url_box"
            )

            copy_btn = gr.Button(
                T(L0, "copy_link"),
                elem_id="copy_button"
            )
            copy_btn.click(
                fn=None,
                inputs=share_url,
                outputs=[],
                js="""
                async (url) => {
                    const L = (k) => (window.__ucL ? window.__ucL(k) : k);
                    const button = document.querySelector('#copy_button') || document.activeElement;
                    const oldText = button.innerHTML;
                    const flash = (text) => {
                        button.innerHTML = text;
                        setTimeout(() => { button.innerHTML = oldText; }, 1500);
                    };

                    if (!url) { flash(L('no_link')); return; }

                    try {
                        await navigator.clipboard.writeText(url);
                        flash(L('copied'));
                    } catch (e) {
                        flash(L('copy_failed'));
                    }
                }
                """
            )

    file_inputs = [mode, mp4_long, png_long, insta, tiktok, yt, fb, lang_dd]

    # Show/Hide Logic
    def update_visibility(selected_mode):

        if selected_mode == "Long":
            return (
                gr.update(visible=True),   # long
                gr.update(visible=False),  # shorts
                gr.update(visible=False),  # share link (reset on mode change)
                gr.update(visible=False),  # result
            )

        else:  # Shorts
            return (
                gr.update(visible=False),
                gr.update(visible=True),
                gr.update(visible=False),
                gr.update(visible=False),
            )

    mode.change(
        fn=update_visibility,
        inputs=mode,
        outputs=[
            long_col,
            shorts_col,
            share_col,
            result_group,
        ]
    ).then(
        # Switching mode changes which files are actually required, so re-lock/
        # re-check the Upload button against the newly-relevant fields.
        fn=refresh_upload_button,
        inputs=file_inputs,
        outputs=upload_btn
    )

    # ---- Upload-readiness wiring ----
    # .upload() only fires once a file has FULLY arrived on the server, so it's the
    # right signal to re-check whether the button should unlock. .clear() re-locks
    # it if someone removes a file after selecting it.
    for comp in (mp4_long, png_long, insta, tiktok, yt, fb):
        comp.upload(
            fn=refresh_upload_button,
            inputs=file_inputs,
            outputs=upload_btn
        )
        comp.clear(
            fn=refresh_upload_button,
            inputs=file_inputs,
            outputs=upload_btn
        )

    # Reset All (keeps the chosen language)
    def refresh_all(lang):
        return (
            gr.update(value=""),     # author_name
            gr.update(value=None),   # mp4_long
            gr.update(value=None),   # png_long
            gr.update(value=None),   # save_long
            gr.update(value=""),     # game_name_long
            gr.update(value=""),     # part_long
            gr.update(value=""),     # yt_playlist_long
            gr.update(value=""),     # game_url_long
            gr.update(value=""),     # synopsis_long
            gr.update(value=""),     # chapters_long
            gr.update(value=""),     # instructions_long
            gr.update(value=None),   # insta
            gr.update(value=None),   # tiktok
            gr.update(value=None),   # yt
            gr.update(value=None),   # fb
            gr.update(value=""),     # game_name_shorts
            gr.update(value=""),     # part_shorts
            gr.update(value=""),     # yt_playlist_shorts
            gr.update(value=""),     # game_url_shorts
            gr.update(value=""),     # synopsis_shorts
            "",                      # output
            gr.update(value=None),   # share_url
            "Long",                  # mode
            gr.update(visible=True),   # long_col
            gr.update(visible=False),  # shorts_col
            gr.update(visible=False),  # share_col
            gr.update(interactive=False, value=T(lang, "upload_waiting")),  # upload_btn
            gr.update(visible=False),  # result_group
        )

    refresh_all_btn.click(
        fn=refresh_all,
        inputs=[lang_dd],
        outputs=[
            author_name,
            mp4_long,
            png_long,
            save_long,
            game_name_long,
            part_long,
            yt_playlist_long,
            game_url_long,
            synopsis_long,
            chapters_long,
            instructions_long,
            insta,
            tiktok,
            yt,
            fb,
            game_name_shorts,
            part_shorts,
            yt_playlist_shorts,
            game_url_shorts,
            synopsis_shorts,
            output,
            share_url,
            mode,
            long_col,
            shorts_col,
            share_col,
            upload_btn,
            result_group,
        ],
        js="(lang) => { window.scrollTo({ top: 0, behavior: 'smooth' }); return lang; }",
    )

    # Upload: starts the job (returns at once); job_timer shows the bar and, at the end, the result
    upload_inputs = [
        mode, author_name,
        mp4_long, png_long, save_long,
        game_name_long, part_long, yt_playlist_long, game_url_long,
        synopsis_long, chapters_long, instructions_long,
        insta, yt, tiktok, fb,
        game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
        lang_dd,
    ]
    result_outputs = [progress_html, output, share_url, share_col, result_group]
    upload_btn.click(
        fn=start_upload,
        inputs=upload_inputs + [job_box],
        outputs=[job_box, job_timer, upload_btn] + result_outputs,
        show_progress="hidden",
    ).then(fn=None, inputs=[job_box], outputs=None, js=SAVE_UP_JS)

    job_timer.tick(
        fn=poll_upload,
        inputs=[job_box, lang_dd, mode, mp4_long, png_long, insta, tiktok, yt, fb],
        outputs=[job_timer, upload_btn] + result_outputs,
        show_progress="hidden",
        concurrency_limit=None,
    )

    # Reset All forgets the upload shown on this page (one still running finishes on the server)
    refresh_all_btn.click(fn=lambda: ("", gr.update(active=False), ""), inputs=None,
                          outputs=[job_box, job_timer, progress_html], js=CLEAR_UP_JS, show_progress="hidden")

    # ------------------------------------------------------------
    # LANGUAGE: translate every label, title and button on the page
    # ------------------------------------------------------------
    def apply_language(lang, selected_mode, mp4, png, ins, tik, ytb, fbk):
        lang = lang if lang in LANG_CODES else DEFAULT_LANG
        u = gr.update
        return (
            brand_html(lang),                                           # brand
            u(value=T(lang, "reset")),                                  # refresh_all_btn
            T(lang, "sec_author"),                                      # sec_author_md
            u(label=T(lang, "author_label")),                           # author_name
            u(label=T(lang, "mode_label"), choices=mode_choices(lang), value=selected_mode),
            T(lang, "sec_long"),                                        # sec_long_md
            u(label=T(lang, "game_name")),
            u(label=T(lang, "part")),
            u(label=T(lang, "yt_playlist")),
            u(label=T(lang, "game_url")),
            u(label=T(lang, "yt_synopsis")),
            u(label=T(lang, "yt_chapters")),
            u(label=T(lang, "instructions")),
            u(label=T(lang, "mp4_long")),
            u(label=T(lang, "thumb")),
            u(label=T(lang, "save_file")),
            T(lang, "sec_shorts"),                                      # sec_shorts_md
            u(label=T(lang, "game_name")),
            u(label=T(lang, "part")),
            u(label=T(lang, "yt_playlist")),                            # yt_playlist_shorts
            u(label=T(lang, "game_url")),
            u(label=T(lang, "video_synopsis")),
            u(label=T(lang, "short_file", p="INSTSTORIES")),
            u(label=T(lang, "short_file", p="TIKTOK")),
            u(label=T(lang, "short_file", p="YTBSTORIES")),
            u(label=T(lang, "short_file", p="FACEBOOK")),
            refresh_upload_button(selected_mode, mp4, png, ins, tik, ytb, fbk, lang),   # upload_btn
            T(lang, "sec_result"),                                      # sec_result_md
            u(label=T(lang, "status")),                                 # output
            u(label=T(lang, "share_url")),                              # share_url
            u(value=T(lang, "copy_link")),                              # copy_btn
        )

    lang_inputs = [lang_dd, mode, mp4_long, png_long, insta, tiktok, yt, fb]
    lang_outputs = [
        brand, refresh_all_btn, sec_author_md, author_name, mode,
        sec_long_md, game_name_long, part_long, yt_playlist_long, game_url_long,
        synopsis_long, chapters_long, instructions_long, mp4_long, png_long, save_long,
        sec_shorts_md, game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
        insta, tiktok, yt, fb,
        upload_btn, sec_result_md, output, share_url, copy_btn,
    ]
    lang_dd.change(fn=None, inputs=[lang_dd], outputs=None, js=SET_LANG_JS)
    lang_dd.change(fn=apply_language, inputs=lang_inputs, outputs=lang_outputs, show_progress="hidden")

    # dark look + highlighted warnings + browser texts (all run in the browser)
    app.load(fn=None, inputs=None, outputs=None, js=PAGE_JS)
    app.load(fn=None, inputs=None, outputs=None, js=I18N_JS)
    # page (re)loaded during / after an upload: bring it back
    app.load(fn=recover_upload, inputs=[job_box, lang_dd], outputs=[job_box, job_timer] + result_outputs,
             js=READ_UP_JS, show_progress="hidden", concurrency_limit=None)
    # restore the language chosen on the last visit
    app.load(fn=lambda l: l if l in LANG_CODES else DEFAULT_LANG, inputs=[lang_dd], outputs=[lang_dd],
             js=READ_LANG_JS, show_progress="hidden").then(
        fn=apply_language, inputs=lang_inputs, outputs=lang_outputs, show_progress="hidden")

# ============================================================
# SERVER + ACCESS GATE (admin.gamify.pt)
# The app is served by FastAPI/uvicorn so the gate can check EVERY request.
# The gate is ON unless GAMIFY_GATE=off is set explicitly; if gamify_gate.py
# can't be loaded, everything is blocked (the app is never opened by accident).
# ============================================================
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse

server = FastAPI()


@server.get("/healthz")
def healthz():
    return {"status": "ok"}


_mount_kwargs = {"theme": THEME} if _MOUNT_TAKES_THEME else {}
server = gr.mount_gradio_app(server, app, path="/", **_mount_kwargs)

if os.environ.get("GAMIFY_GATE", "on").strip().lower() == "off":
    print("GAMIFY GATE: OFF — the app is open to everyone (GAMIFY_GATE=off)")
else:
    try:
        from gamify_gate import GamifyGate
        server.add_middleware(GamifyGate)
        print("GAMIFY GATE: ON — access only through admin.gamify.pt")
    except Exception as e:
        print(f"GAMIFY GATE: could not be loaded ({e}) — blocking all access")

        @server.middleware("http")
        async def _block_everything(request, call_next):
            if request.url.path == "/healthz":
                return await call_next(request)
            return PlainTextResponse("Access blocked: gamify_gate.py is missing from this Space.", status_code=503)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(server, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))