File size: 33,681 Bytes
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5ee82b
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bcdb36
 
 
 
 
 
 
7c6ffa6
 
 
 
3bcdb36
7c6ffa6
3bcdb36
 
7c6ffa6
3bcdb36
7c6ffa6
3bcdb36
 
7c6ffa6
3bcdb36
 
 
 
 
 
 
 
 
 
7c6ffa6
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
7c6ffa6
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c6ffa6
3bcdb36
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""DocDoe production preflight checks.

Run before deploying to verify the production environment is safe:

    python backend/scripts/production_preflight.py

Exit codes:
    0 β€” no FAIL (WARN entries allowed)
    1 β€” at least one FAIL

The script never prints secret values. Sensitive env vars are masked
to first 4 + last 4 characters with the middle replaced by ``****``.
"""
from __future__ import annotations

import ast
import os
import shutil
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Callable, Iterable

# Ensure the script can be run from anywhere β€” fall back to repo-relative imports.
HERE = Path(__file__).resolve().parent
BACKEND_DIR = HERE.parent
REPO_ROOT = BACKEND_DIR.parent
if str(BACKEND_DIR) not in sys.path:
    sys.path.insert(0, str(BACKEND_DIR))


# ── Result types ──────────────────────────────────────────────────────────────

Status = str  # "PASS" | "WARN" | "FAIL"


@dataclass(frozen=True)
class Check:
    section: str
    name: str
    status: Status
    detail: str


def _pass(section: str, name: str, detail: str = "") -> Check:
    return Check(section, name, "PASS", detail)


def _warn(section: str, name: str, detail: str) -> Check:
    return Check(section, name, "WARN", detail)


def _fail(section: str, name: str, detail: str) -> Check:
    return Check(section, name, "FAIL", detail)


# ── Secret masking ────────────────────────────────────────────────────────────

def mask_secret(value: str | None) -> str:
    if not value:
        return "(missing)"
    if len(value) <= 8:
        return "****"
    return f"{value[:4]}****{value[-4:]}"


# ── Helpers to read env ───────────────────────────────────────────────────────

def env(name: str, default: str | None = None) -> str | None:
    raw = os.environ.get(name)
    if raw is None:
        return default
    stripped = raw.strip()
    return stripped if stripped else default


def env_bool(name: str, default: bool = False) -> bool:
    raw = env(name)
    if raw is None:
        return default
    return raw.lower() in {"1", "true", "yes", "on"}


# ── Section 1 β€” Environment ───────────────────────────────────────────────────

DEFAULT_JWT_SECRET = "change-this-local-dev-secret"
LOCAL_HOST_PREFIXES = ("http://localhost", "http://127.0.0.1", "http://0.0.0.0")


def check_environment(is_prod: bool) -> Iterable[Check]:
    sec = "Environment"
    yield _pass(sec, "ENVIRONMENT", f"set to '{env('ENVIRONMENT') or 'development'}'")

    db_url = env("DATABASE_URL")
    if not db_url:
        yield _fail(sec, "DATABASE_URL", "missing")
    elif is_prod and db_url.startswith("sqlite"):
        yield _fail(sec, "DATABASE_URL", "SQLite not allowed in production β€” use PostgreSQL")
    elif db_url.startswith("postgresql"):
        yield _pass(sec, "DATABASE_URL", "PostgreSQL configured")
    else:
        yield _warn(sec, "DATABASE_URL", f"non-PostgreSQL driver ({db_url.split(':', 1)[0]})")

    frontend = env("FRONTEND_BASE_URL")
    if is_prod and frontend:
        if frontend.startswith(LOCAL_HOST_PREFIXES):
            yield _fail(sec, "FRONTEND_BASE_URL", "points at localhost in production")
        elif not frontend.startswith("https://"):
            yield _warn(sec, "FRONTEND_BASE_URL", "should use https in production")
        else:
            yield _pass(sec, "FRONTEND_BASE_URL", frontend)
    elif frontend:
        yield _pass(sec, "FRONTEND_BASE_URL", frontend)
    else:
        yield _warn(sec, "FRONTEND_BASE_URL", "not set")

    cors = env("CORS_ORIGINS")
    if is_prod:
        if not cors:
            yield _fail(sec, "CORS_ORIGINS", "missing in production")
        else:
            origins = [o.strip() for o in cors.split(",") if o.strip()]
            bad = [o for o in origins if o == "*" or o.startswith(LOCAL_HOST_PREFIXES)]
            if bad:
                yield _fail(sec, "CORS_ORIGINS", f"unsafe entries: {bad}")
            else:
                yield _pass(sec, "CORS_ORIGINS", f"{len(origins)} origin(s) configured")
    else:
        yield _pass(sec, "CORS_ORIGINS", cors or "(default)")

    jwt = env("JWT_SECRET_KEY")
    if not jwt or jwt == DEFAULT_JWT_SECRET:
        if is_prod:
            yield _fail(sec, "JWT_SECRET_KEY", "missing or default value β€” generate with `openssl rand -hex 32`")
        else:
            yield _warn(sec, "JWT_SECRET_KEY", "using dev default")
    elif len(jwt) < 32:
        yield _fail(sec, "JWT_SECRET_KEY", f"too short ({len(jwt)} chars, need >=32)")
    else:
        yield _pass(sec, "JWT_SECRET_KEY", f"strong ({len(jwt)} chars, {mask_secret(jwt)})")

    auth_enabled = env_bool("AUTH_ENABLED")
    if is_prod and not auth_enabled:
        yield _fail(sec, "AUTH_ENABLED", "must be true in production")
    else:
        yield _pass(sec, "AUTH_ENABLED", str(auth_enabled))

    auth_provider = (env("AUTH_PROVIDER") or "jwt").lower()
    if auth_provider not in {"jwt", "supabase"}:
        yield _fail(sec, "AUTH_PROVIDER", "must be 'jwt' or 'supabase' for real users")
    else:
        yield _pass(sec, "AUTH_PROVIDER", auth_provider)
    if auth_provider == "supabase":
        supabase_url = env("SUPABASE_URL")
        supabase_jwt = env("SUPABASE_JWT_SECRET")
        service_key = env("SUPABASE_SERVICE_ROLE_KEY")
        if not supabase_url:
            yield _fail(sec, "SUPABASE_URL", "required when AUTH_PROVIDER=supabase")
        else:
            yield _pass(sec, "SUPABASE_URL", supabase_url)
        if not supabase_jwt:
            yield _fail(sec, "SUPABASE_JWT_SECRET", "required to verify student sessions")
        elif len(supabase_jwt) < 32:
            yield _fail(sec, "SUPABASE_JWT_SECRET", "too short (need >=32 characters)")
        else:
            yield _pass(sec, "SUPABASE_JWT_SECRET", mask_secret(supabase_jwt))
        if not service_key:
            yield _fail(
                sec,
                "SUPABASE_SERVICE_ROLE_KEY",
                "required for authenticated account deletion; backend only",
            )
        else:
            yield _pass(sec, "SUPABASE_SERVICE_ROLE_KEY", mask_secret(service_key))

    rate_limit = env_bool("RATE_LIMIT_ENABLED")
    if is_prod and not rate_limit:
        yield _warn(sec, "RATE_LIMIT_ENABLED", "should be true in production")
    else:
        yield _pass(sec, "RATE_LIMIT_ENABLED", str(rate_limit))


# Ò”€Ò”€ Section 1b Ò€” Database recovery Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€

def check_database_recovery(is_prod: bool) -> Iterable[Check]:
    sec = "Database recovery"
    strategy = (env("DATABASE_BACKUP_STRATEGY") or "").lower()

    if not strategy:
        if is_prod:
            yield _fail(
                sec,
                "DATABASE_BACKUP_STRATEGY",
                "missing; use 'managed' for provider backups or 'pg_dump' for the bundled backup job",
            )
        else:
            yield _pass(sec, "DATABASE_BACKUP_STRATEGY", "not required in development")
        return
    if strategy not in {"managed", "pg_dump"}:
        yield _fail(sec, "DATABASE_BACKUP_STRATEGY", "must be 'managed' or 'pg_dump'")
        return
    yield _pass(sec, "DATABASE_BACKUP_STRATEGY", strategy)

    retention_raw = env("DATABASE_BACKUP_RETENTION_DAYS") or "0"
    try:
        retention_days = int(retention_raw)
    except ValueError:
        retention_days = 0
    if retention_days < 7:
        yield _fail(sec, "DATABASE_BACKUP_RETENTION_DAYS", "must be at least 7 days")
    else:
        yield _pass(sec, "DATABASE_BACKUP_RETENTION_DAYS", f"{retention_days} days")

    restore_tested_raw = env("DATABASE_RESTORE_TESTED_AT")
    if not restore_tested_raw:
        yield _fail(sec, "DATABASE_RESTORE_TESTED_AT", "missing; record the latest successful restore drill date")
    else:
        try:
            restore_date = date.fromisoformat(restore_tested_raw)
            age_days = (date.today() - restore_date).days
            if age_days < -1:
                yield _fail(sec, "DATABASE_RESTORE_TESTED_AT", "cannot be a future date")
            elif age_days > 90:
                yield _fail(sec, "DATABASE_RESTORE_TESTED_AT", f"restore drill is stale ({age_days} days old)")
            else:
                yield _pass(sec, "DATABASE_RESTORE_TESTED_AT", f"{restore_tested_raw} ({max(age_days, 0)} days ago)")
        except ValueError:
            yield _fail(sec, "DATABASE_RESTORE_TESTED_AT", "must be an ISO date in YYYY-MM-DD format")

    if strategy == "pg_dump":
        backup_dir = env("DATABASE_BACKUP_DIR")
        if not backup_dir:
            yield _fail(sec, "DATABASE_BACKUP_DIR", "required when DATABASE_BACKUP_STRATEGY=pg_dump")
        else:
            yield _pass(sec, "DATABASE_BACKUP_DIR", "configured")
        for binary in ("pg_dump", "pg_restore"):
            if shutil.which(binary):
                yield _pass(sec, binary, "available")
            else:
                yield _fail(sec, binary, "not available on PATH")


# ── Section 2 β€” Storage ───────────────────────────────────────────────────────

def check_storage(is_prod: bool) -> Iterable[Check]:
    sec = "Storage"
    provider = (env("STORAGE_PROVIDER") or "local").lower()

    if provider not in {"local", "r2", "s3", "cloudinary"}:
        yield _fail(sec, "STORAGE_PROVIDER",
                    f"invalid value '{provider}' β€” must be local/r2/s3/cloudinary")
        return

    yield _pass(sec, "STORAGE_PROVIDER", provider)

    if provider == "cloudinary":
        cloud_name = env("CLOUDINARY_CLOUD_NAME")
        api_key = env("CLOUDINARY_API_KEY")
        api_secret = env("CLOUDINARY_API_SECRET")
        if not cloud_name:
            yield _fail(sec, "CLOUDINARY_CLOUD_NAME", "missing")
        else:
            yield _pass(sec, "CLOUDINARY_CLOUD_NAME", cloud_name)
        if not api_key:
            yield _fail(sec, "CLOUDINARY_API_KEY", "missing")
        else:
            yield _pass(sec, "CLOUDINARY_API_KEY", mask_secret(api_key))
        if not api_secret:
            yield _fail(sec, "CLOUDINARY_API_SECRET", "missing β€” required for uploads")
        else:
            yield _pass(sec, "CLOUDINARY_API_SECRET", mask_secret(api_secret))
        try:
            import cloudinary  # noqa: F401
            yield _pass(sec, "cloudinary package", "installed")
        except ImportError:
            yield _fail(sec, "cloudinary package",
                        "not installed β€” add 'cloudinary' to backend/requirements.txt")
        return

    if provider == "local":
        if is_prod:
            yield _warn(sec, "Local storage in production",
                        "generated videos lost on redeploy β€” see backend/STORAGE_SETUP.md")
        else:
            yield _pass(sec, "Local storage", "OK for development")
        return

    # R2 / S3 required vars
    bucket = env("STORAGE_BUCKET")
    access_key = env("STORAGE_ACCESS_KEY_ID")
    secret_key = env("STORAGE_SECRET_ACCESS_KEY")
    endpoint = env("STORAGE_ENDPOINT_URL")
    public_base = env("STORAGE_PUBLIC_BASE_URL")
    region = env("STORAGE_REGION")

    if not bucket:
        yield _fail(sec, "STORAGE_BUCKET", "missing")
    else:
        yield _pass(sec, "STORAGE_BUCKET", bucket)

    if not access_key:
        yield _fail(sec, "STORAGE_ACCESS_KEY_ID", "missing")
    else:
        yield _pass(sec, "STORAGE_ACCESS_KEY_ID", mask_secret(access_key))

    if not secret_key:
        yield _fail(sec, "STORAGE_SECRET_ACCESS_KEY", "missing")
    else:
        yield _pass(sec, "STORAGE_SECRET_ACCESS_KEY", mask_secret(secret_key))

    if provider == "r2":
        if not endpoint:
            yield _fail(sec, "STORAGE_ENDPOINT_URL", "required for R2")
        elif "r2.cloudflarestorage.com" not in endpoint and "cloudflare" not in endpoint:
            yield _warn(sec, "STORAGE_ENDPOINT_URL", "does not look like an R2 endpoint")
        else:
            yield _pass(sec, "STORAGE_ENDPOINT_URL", endpoint)
    elif provider == "s3":
        if not region:
            yield _warn(sec, "STORAGE_REGION", "not set β€” boto3 will use AWS default")

    if is_prod and public_base:
        if public_base.startswith(LOCAL_HOST_PREFIXES):
            yield _fail(sec, "STORAGE_PUBLIC_BASE_URL", "points at localhost in production")
        elif not public_base.startswith("https://"):
            yield _warn(sec, "STORAGE_PUBLIC_BASE_URL", "should use https in production")
        else:
            yield _pass(sec, "STORAGE_PUBLIC_BASE_URL", public_base)
    elif public_base:
        yield _pass(sec, "STORAGE_PUBLIC_BASE_URL", public_base)
    else:
        yield _warn(sec, "STORAGE_PUBLIC_BASE_URL",
                    "not set β€” public URLs may fall back to provider default")

    # Optional: try to instantiate the boto3 provider without uploading
    if bucket and access_key and secret_key:
        try:
            import boto3  # noqa: F401
            yield _pass(sec, "boto3", "installed")
        except ImportError:
            yield _fail(sec, "boto3", "not installed β€” run `pip install boto3`")


# ── Section 3 β€” Video render dependencies ────────────────────────────────────

def _has_binary(name: str) -> bool:
    return shutil.which(name) is not None


def check_video_deps() -> Iterable[Check]:
    sec = "Video render deps"

    for binary in ("ffmpeg", "ffprobe"):
        if _has_binary(binary):
            yield _pass(sec, binary, f"available at {shutil.which(binary)}")
        else:
            yield _fail(sec, binary,
                        f"{binary} not found in PATH β€” video render/validation will fail")

    for binary in ("node", "npm"):
        if _has_binary(binary):
            yield _pass(sec, binary, f"available at {shutil.which(binary)}")
        else:
            yield _fail(sec, binary, f"{binary} not found in PATH β€” render CLI will fail")

    package_json = REPO_ROOT / "package.json"
    if not package_json.exists():
        yield _fail(sec, "package.json", "not found at repo root")
        return

    try:
        import json as _json
        data = _json.loads(package_json.read_text(encoding="utf-8"))
    except Exception as exc:
        yield _fail(sec, "package.json", f"could not parse: {exc.__class__.__name__}")
        return

    scripts = data.get("scripts", {})
    if "render:from-json" in scripts:
        yield _pass(sec, "render:from-json script", "defined in package.json")
    else:
        yield _fail(sec, "render:from-json script",
                    "missing from package.json β€” backend invokes this for render")

    deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
    if "remotion" in deps or "@remotion/cli" in deps:
        version = deps.get("remotion") or deps.get("@remotion/cli", "unknown")
        yield _pass(sec, "remotion package", f"declared ({version})")
    else:
        yield _fail(sec, "remotion package", "not declared in package.json")


# ── Section 4 β€” AI / TTS config ──────────────────────────────────────────────

def check_ai_config(is_prod: bool) -> Iterable[Check]:
    sec = "AI / TTS"

    ai_provider = (env("AI_PROVIDER") or "").lower()
    if not ai_provider:
        yield _fail(sec, "AI_PROVIDER", "not set")
    else:
        yield _pass(sec, "AI_PROVIDER", ai_provider)

    if is_prod and env_bool("AI_FALLBACK_TO_MOCK"):
        yield _fail(sec, "AI_FALLBACK_TO_MOCK",
                    "must be false in production β€” mock responses must not reach students")
    else:
        yield _pass(sec, "AI_FALLBACK_TO_MOCK", str(env_bool("AI_FALLBACK_TO_MOCK")))

    tts_provider = (env("TTS_PROVIDER") or "").lower()
    if tts_provider:
        yield _pass(sec, "TTS_PROVIDER", tts_provider)
    else:
        yield _warn(sec, "TTS_PROVIDER", "not set β€” falling back to default")

    video_tts_provider = (env("VIDEO_TTS_PROVIDER") or "ai4bharat").lower()
    if video_tts_provider:
        yield _pass(sec, "VIDEO_TTS_PROVIDER", video_tts_provider)
    else:
        yield _warn(sec, "VIDEO_TTS_PROVIDER", "not set - study video voice defaults to ai4bharat")

    if ai_provider == "openai":
        key = env("OPENAI_API_KEY")
        if not key:
            yield _fail(sec, "OPENAI_API_KEY", "required when AI_PROVIDER=openai")
        else:
            yield _pass(sec, "OPENAI_API_KEY", mask_secret(key))

    needs_sarvam = ai_provider == "sarvam" or tts_provider == "sarvam" or video_tts_provider == "sarvam"
    sarvam_key = env("SARVAM_API_KEY")
    if needs_sarvam:
        if not sarvam_key:
            yield _fail(sec, "SARVAM_API_KEY",
                        "required when AI_PROVIDER or TTS_PROVIDER is sarvam")
        else:
            yield _pass(sec, "SARVAM_API_KEY", mask_secret(sarvam_key))

    ai4bharat_names = {"ai4bharat", "indic_parler", "indic_parler_tts"}
    needs_ai4bharat = tts_provider in ai4bharat_names or video_tts_provider in ai4bharat_names
    if needs_ai4bharat:
        hf_key = env("HUGGINGFACE_API_KEY") or env("HF_TOKEN")
        if not hf_key:
            yield _fail(
                sec,
                "HUGGINGFACE_API_KEY",
                "required for the initial gated AI4Bharat model download",
            )
        else:
            yield _pass(sec, "HUGGINGFACE_API_KEY", mask_secret(hf_key))

    if ai_provider == "openrouter":
        key = env("OPENROUTER_API_KEY")
        if not key:
            yield _fail(sec, "OPENROUTER_API_KEY", "required when AI_PROVIDER=openrouter")
        else:
            yield _pass(sec, "OPENROUTER_API_KEY", mask_secret(key))

    if ai_provider == "gemini":
        key = env("GEMINI_API_KEY")
        if not key:
            yield _fail(sec, "GEMINI_API_KEY", "required when AI_PROVIDER=gemini")
        else:
            yield _pass(sec, "GEMINI_API_KEY", mask_secret(key))

    if ai_provider == "auto":
        configured = [
            name
            for name, key in (
                ("openai", env("OPENAI_API_KEY")),
                ("sarvam", sarvam_key),
                ("openrouter", env("OPENROUTER_API_KEY")),
                ("gemini", env("GEMINI_API_KEY")),
            )
            if key
        ]
        if configured:
            yield _pass(sec, "AI_PROVIDER_AUTO_KEYS", ",".join(configured))
        else:
            yield _fail(sec, "AI_PROVIDER_AUTO_KEYS", "AI_PROVIDER=auto requires at least one real AI key")

    # Expanded TTS provider coverage
    tts_providers = {
        "TTS_PROVIDER": env("TTS_PROVIDER"),
        "VIDEO_TTS_PROVIDER": env("VIDEO_TTS_PROVIDER") or "ai4bharat",
    }
    for k, v in tts_providers.items():
        if v:
            yield _pass(sec, f"{k}_set", v)
        else:
            yield _warn(sec, f"{k}_set", "falls back to default (ai4bharat)")

    # More providers sanity (edge, sarvam, mock, openai-tts etc recognized in code)
    known_tts = {
        "ai4bharat",
        "indic_parler",
        "indic_parler_tts",
        "edge",
        "sarvam",
        "mock",
        "openai",
        "gcp",
        "azure",
        "kokoro",
        "hybrid",
    }
    for k, v in tts_providers.items():
        if v and v.lower() not in known_tts:
            yield _warn(sec, f"{k}_unknown", f"'{v}' not in common known list; ensure provider impl exists")


# ── Section 5b β€” Billing / Stripe (if wired) ─────────────────────────────────
def check_billing_stripe(is_prod: bool) -> Iterable[Check]:
    """Fail closed when Stripe is partially configured.

    A fully absent Stripe configuration is a valid free-only deployment. Once
    any Stripe setting is supplied, Checkout, signed webhook activation, and
    both student-facing recurring Prices must all be ready together.
    """

    sec = "Billing / Stripe"
    stripe_key = env("STRIPE_SECRET_KEY")
    webhook_secret = env("STRIPE_WEBHOOK_SECRET")
    price_ids = env("STRIPE_PRICE_IDS")
    any_billing_config = bool(stripe_key or webhook_secret or price_ids)

    if not any_billing_config:
        detail = "not configured; paid checkout is safely disabled"
        if is_prod:
            yield _warn(sec, "Stripe billing", detail)
        else:
            yield _pass(sec, "Stripe billing", detail)
        return

    if not stripe_key:
        yield _fail(sec, "STRIPE_SECRET_KEY", "missing while other Stripe settings are present")
    elif is_prod and stripe_key.startswith(("sk_test_", "rk_test_")):
        yield _fail(sec, "STRIPE_SECRET_KEY", "test-mode key configured in production")
    elif is_prod and stripe_key.startswith("sk_live_"):
        yield _warn(
            sec,
            "STRIPE_SECRET_KEY",
            f"{mask_secret(stripe_key)}; prefer a restricted rk_live_ key",
        )
    else:
        yield _pass(sec, "STRIPE_SECRET_KEY", mask_secret(stripe_key))

    if not webhook_secret:
        yield _fail(
            sec,
            "STRIPE_WEBHOOK_SECRET",
            "missing; checkout remains disabled because entitlements cannot be activated safely",
        )
    elif not webhook_secret.startswith("whsec_"):
        yield _fail(sec, "STRIPE_WEBHOOK_SECRET", "must be a Stripe endpoint signing secret")
    else:
        yield _pass(sec, "STRIPE_WEBHOOK_SECRET", mask_secret(webhook_secret))

    mappings: dict[str, str] = {}
    for pair in (price_ids or "").split(","):
        if "=" not in pair:
            continue
        key, value = pair.split("=", 1)
        mappings[key.strip().lower()] = value.strip()
    required_plans = {"popular_299", "premium_599"}
    missing_plans = sorted(
        plan
        for plan in required_plans
        if not mappings.get(plan, "").startswith("price_")
    )
    if missing_plans:
        yield _fail(
            sec,
            "STRIPE_PRICE_IDS",
            f"missing valid recurring Price mapping(s): {', '.join(missing_plans)}",
        )
    else:
        yield _pass(sec, "STRIPE_PRICE_IDS", "Popular and Premium Prices configured")


# ── Section 5c β€” Diagram logic coverage (generalized video) β€” static for env robustness ──────────────────
def check_diagram_logic() -> Iterable[Check]:
    sec = "Diagram logic"
    vg_path = BACKEND_DIR / "app" / "routes" / "video_generator.py"
    if not vg_path.exists():
        yield _fail(sec, "diagram module import", "video_generator.py source not found")
        return
    try:
        with open(vg_path, encoding="utf-8") as f:
            tree = ast.parse(f.read(), filename=str(vg_path))

        # Collect defined function names (static, no import/runtime deps needed)
        func_names = {
            node.name
            for node in ast.walk(tree)
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
        }

        # Find _DIAGRAM_KEYWORDS assign and approximate length (static tuple/list)
        kw_len = 0
        for node in ast.walk(tree):
            if isinstance(node, ast.Assign):
                for t in node.targets:
                    if isinstance(t, ast.Name) and t.id == "_DIAGRAM_KEYWORDS":
                        if isinstance(node.value, (ast.Tuple, ast.List)):
                            kw_len = len(node.value.elts)
                        elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, (tuple, list)):
                            kw_len = len(node.value.value)
                        break

        has_needs = "_needs_diagram" in func_names
        has_ensure = "_ensure_diagram_scene" in func_names
        has_generic = "_generic_diagram_scene" in func_names
        has_keywords = kw_len > 5

        if has_needs and has_ensure and has_generic and has_keywords:
            yield _pass(sec, "_ensure_diagram_scene generalized", "present + _needs + _generic + keywords (static)")
            # Behavioral smoke skipped (would require full runtime env + deps); presence + keyword count is sufficient for preflight
            yield _pass(sec, "diagram keyword trigger", f"generalized keywords present (len={kw_len})")
        else:
            missing = []
            if not has_needs:
                missing.append("_needs_diagram")
            if not has_ensure:
                missing.append("_ensure_diagram_scene")
            if not has_generic:
                missing.append("_generic_diagram_scene")
            if not has_keywords:
                missing.append("_DIAGRAM_KEYWORDS (len<=5)")
            yield _fail(sec, "_ensure_diagram_scene generalized", f"missing or incomplete: {', '.join(missing)}")
    except SyntaxError as exc:
        yield _fail(sec, "diagram module import", f"ast parse error in video_generator.py: {exc}")
    except Exception as exc:  # noqa: BLE001
        yield _fail(sec, "diagram module import", f"could not statically inspect video_generator.py: {exc.__class__.__name__}")

# ── Section 5d β€” New 10kx features (spaced rep + streaming Ask) β€” static presence ──────────────────
def check_new_features() -> Iterable[Check]:
    sec = "New features (10kx)"
    # Spaced repetition
    fc_path = BACKEND_DIR / "app" / "routes" / "flashcards.py"
    try:
        fc_src = fc_path.read_text(encoding="utf-8") if fc_path.exists() else ""
        has_sm2 = "_sm2_update" in fc_src or "SM-2" in fc_src
        has_due = "/due" in fc_src or "get_due_cards" in fc_src
        has_review = "/review" in fc_src or "record_flashcard_review" in fc_src
        if has_sm2 and has_due and has_review:
            yield _pass(sec, "spaced repetition (SM-2)", "due/review endpoints + scheduler helpers present")
        else:
            yield _warn(sec, "spaced repetition (SM-2)", "helpers/endpoints may be incomplete")
    except Exception:
        yield _warn(sec, "spaced repetition (SM-2)", "could not inspect flashcards.py")

    # Streaming Ask / StudyCast
    ai_path = BACKEND_DIR / "app" / "services" / "ai_provider.py"
    ask_path = BACKEND_DIR / "app" / "routes" / "ask.py"
    try:
        ai_src = ai_path.read_text(encoding="utf-8") if ai_path.exists() else ""
        ask_src = ask_path.read_text(encoding="utf-8") if ask_path.exists() else ""
        has_gen_stream = "def generate_streaming" in ai_src or "generate_streaming" in ai_src
        has_ask_stream = "/ask/stream" in ask_src or "ask_stream" in ask_src
        if has_gen_stream and has_ask_stream:
            yield _pass(sec, "streaming Ask/StudyCast", "generate_streaming + /ask/stream present")
        else:
            yield _warn(sec, "streaming Ask/StudyCast", "streaming support may be partial")
    except Exception:
        yield _warn(sec, "streaming Ask/StudyCast", "could not inspect ai_provider/ask sources")


# ── Section 5 β€” URL safety ───────────────────────────────────────────────────

def check_url_safety(is_prod: bool) -> Iterable[Check]:
    if not is_prod:
        yield _pass("URL safety", "skipped", "not in production mode")
        return

    sec = "URL safety"
    candidates = {
        "FRONTEND_BASE_URL": env("FRONTEND_BASE_URL"),
        "STORAGE_PUBLIC_BASE_URL": env("STORAGE_PUBLIC_BASE_URL"),
        "GOOGLE_OAUTH_REDIRECT_URI": env("GOOGLE_OAUTH_REDIRECT_URI"),
    }
    for name, value in candidates.items():
        if not value:
            continue
        if value.startswith(LOCAL_HOST_PREFIXES):
            yield _fail(sec, name, f"localhost URL in production: {value}")
        elif value.startswith("http://"):
            yield _warn(sec, name, f"not https: {value}")
        else:
            yield _pass(sec, name, value)


# ── Section 6 β€” Deployment target ─────────────────────────────────────────────

def check_deploy_target(is_prod: bool) -> Iterable[Check]:
    sec = "Deployment target"
    target = (env("DEPLOY_TARGET") or "").lower()

    if not target:
        if is_prod:
            yield _warn(sec, "DEPLOY_TARGET",
                        "not set β€” recommend setting to huggingface/railway/flyio/docker")
        else:
            yield _pass(sec, "DEPLOY_TARGET", "not set (dev)")
        return

    if target not in {"huggingface", "railway", "flyio", "docker", "local"}:
        yield _warn(sec, "DEPLOY_TARGET", f"unrecognized value '{target}'")
        return

    yield _pass(sec, "DEPLOY_TARGET", target)

    if target == "huggingface":
        # HF Spaces sets PORT=7860 and expects 0.0.0.0 bind.
        port = env("PORT") or "7860"
        if port != "7860":
            yield _warn(sec, "PORT",
                        f"HF Spaces expects 7860; got {port} (HF auto-sets via $PORT)")
        else:
            yield _pass(sec, "PORT", "7860 (HF Spaces default)")

        # Frontend on Vercel β€” backend CORS should include vercel.app or custom domain.
        cors = env("CORS_ORIGINS") or ""
        if cors and not any(
            "vercel.app" in o or ".docdoe.ai" in o or "docdoe.ai" in o
            for o in [origin.strip() for origin in cors.split(",")]
        ):
            yield _warn(sec, "Vercel origin in CORS",
                        "no vercel.app or docdoe.ai origin in CORS_ORIGINS β€” frontend may not reach backend")

        frontend = env("FRONTEND_BASE_URL") or ""
        if frontend and "vercel.app" not in frontend and "docdoe.ai" not in frontend:
            yield _warn(sec, "Vercel frontend URL",
                        f"FRONTEND_BASE_URL '{frontend}' does not look like Vercel/docdoe.ai")


# ── Runner ────────────────────────────────────────────────────────────────────

def collect_all_checks() -> list[Check]:
    is_prod = (env("ENVIRONMENT") or "development").lower() == "production"
    sections: list[Callable[[], Iterable[Check]]] = [
        lambda: check_environment(is_prod),
        lambda: check_database_recovery(is_prod),
        lambda: check_storage(is_prod),
        lambda: check_video_deps(),
        lambda: check_ai_config(is_prod),
        lambda: check_billing_stripe(is_prod),
        lambda: check_diagram_logic(),
        lambda: check_new_features(),
        lambda: check_url_safety(is_prod),
        lambda: check_deploy_target(is_prod),
    ]
    out: list[Check] = []
    for fn in sections:
        out.extend(fn())
    return out


def render_table(checks: list[Check]) -> str:
    lines = []
    current_section = None
    width_status = 6
    width_name = max((len(c.name) for c in checks), default=24)
    width_name = min(max(width_name, 24), 48)
    for c in checks:
        if c.section != current_section:
            lines.append("")
            lines.append(f"-- {c.section} ".ljust(80, "-"))
            current_section = c.section
        icon = {"PASS": "[+]", "WARN": "[!]", "FAIL": "[x]"}[c.status]
        lines.append(
            f"  {icon} {c.status:<{width_status}} {c.name:<{width_name}} {c.detail}"
        )
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    # Allow load from .env file if present (dev convenience).
    # Skip in test mode (PREFLIGHT_SKIP_ENV_FILE=1) so tests aren't polluted.
    env_file = REPO_ROOT / "backend" / ".env"
    if env_file.exists() and not os.environ.get("PREFLIGHT_SKIP_ENV_FILE"):
        try:
            for raw_line in env_file.read_text(encoding="utf-8").splitlines():
                line = raw_line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, _, value = line.partition("=")
                key = key.strip()
                value = value.strip().strip('"').strip("'")
                os.environ.setdefault(key, value)
        except Exception:
            pass  # don't fail preflight on .env parse errors

    checks = collect_all_checks()
    print("DocDoe Production Preflight")
    print("=" * 80)
    print(render_table(checks))
    print()

    fail_count = sum(1 for c in checks if c.status == "FAIL")
    warn_count = sum(1 for c in checks if c.status == "WARN")
    pass_count = sum(1 for c in checks if c.status == "PASS")
    print(f"Summary: {pass_count} PASS Β· {warn_count} WARN Β· {fail_count} FAIL")

    if fail_count:
        print("\nFAILED β€” fix the items above before deploying.")
        return 1
    if warn_count:
        print("\nReady to deploy with WARNINGS. Review them above.")
    else:
        print("\nAll checks passed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())