File size: 31,369 Bytes
fb8c550
 
8f9cafc
 
 
bd279b4
4939f56
9125057
8f9cafc
fb8c550
9125057
89fd26d
 
9125057
 
01cadbb
fb8c550
8f9cafc
 
 
 
9125057
8f9cafc
 
 
 
9125057
 
 
 
 
 
8f9cafc
9125057
8f9cafc
9125057
01cadbb
 
8f9cafc
 
 
 
9125057
 
 
 
a8dee3b
 
9125057
a8dee3b
 
 
9125057
 
 
 
a8dee3b
 
fb8c550
 
8f9cafc
 
 
e47db42
 
 
 
 
 
 
8f9cafc
 
 
 
e47db42
8f9cafc
 
 
 
 
 
 
 
e47db42
 
 
 
 
 
 
 
 
 
5bb077e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
 
9125057
8f9cafc
9125057
fe05da9
9125057
 
fe05da9
8f9cafc
 
fe05da9
9125057
fe05da9
 
8f9cafc
89fd26d
8f9cafc
 
 
9bf8068
 
 
8f9cafc
 
 
 
89fd26d
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb8c550
8f9cafc
9125057
 
8f9cafc
 
9125057
8f9cafc
 
9125057
 
8f9cafc
 
9125057
 
fe05da9
8be46a8
8f9cafc
 
 
 
 
 
 
 
5bb077e
 
 
8f9cafc
 
 
 
 
5bb077e
8f9cafc
 
 
 
 
 
 
 
 
 
 
4320faf
 
 
8f9cafc
 
 
4939f56
 
9125057
8f9cafc
e47db42
 
 
 
8f9cafc
89fd26d
e47db42
 
8f9cafc
 
8be46a8
8f9cafc
 
 
 
 
 
9125057
 
fb8c550
 
8f9cafc
e86bafa
8f9cafc
 
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
cd2115a
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
e86bafa
 
 
 
 
 
 
 
8f9cafc
e86bafa
8f9cafc
 
 
e86bafa
 
 
 
8f9cafc
 
 
e86bafa
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
e86bafa
 
8f9cafc
e86bafa
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
e86bafa
 
 
 
 
8f9cafc
 
 
e86bafa
8f9cafc
e86bafa
 
8f9cafc
e86bafa
8f9cafc
 
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
e86bafa
 
 
 
 
 
 
 
 
8f9cafc
e86bafa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e86bafa
8f9cafc
 
 
 
 
 
 
 
a3d3e7d
bd279b4
9125057
 
fb8c550
89fd26d
 
 
 
9125057
fb8c550
9125057
8f9cafc
15b7414
9125057
 
8f9cafc
89fd26d
fee674e
 
 
 
 
 
 
2e692a0
 
fee674e
15b7414
1c8e7f8
 
 
 
fee674e
 
 
 
1c8e7f8
 
15b7414
8f9cafc
9125057
 
e619a9b
89fd26d
9125057
89fd26d
9125057
 
8f9cafc
 
 
 
89fd26d
8f9cafc
e47db42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9cafc
 
 
 
 
 
9125057
 
4939f56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9125057
89fd26d
9125057
 
 
 
89fd26d
9125057
 
 
 
 
e47db42
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
from __future__ import annotations

import json
import math
import re
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from typing import Any, Optional

from app.services.supabase import get_client
from app.services.auth import require_user_id
from app.services.ssrf import validate_url
router = APIRouter(prefix="/api/docking", tags=["Docking"])
_TABLE = "docking_jobs"


# ---------------------------------------------------------------------------
# Request / response schemas (match frontend DockingResult type)
# ---------------------------------------------------------------------------

class DockingJobCreate(BaseModel):
    pdb_id: str = ""
    smiles: str
    pdb_url: str = ""
    grid_center: Optional[list[float]] = None
    grid_size: list[float] = Field(default_factory=lambda: [20.0, 20.0, 20.0])
    exhaustiveness: int = 8
    num_modes: int = 9


class DockingJobResponse(BaseModel):
    job_id: str
    status: str
    result: Optional[dict[str, Any]] = None
    error: Optional[str] = None


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _prune_old(supabase, max_rows: int = 200):
    try:
        rows = (
            supabase.table(_TABLE)
            .select("id")
            .order("created_at", desc=True)
            .range(max_rows, max_rows + 1000)
            .execute()
            .data
        )
        if rows:
            supabase.table(_TABLE).delete().in_(
                "id", [r["id"] for r in rows]
            ).execute()
    except Exception:
        pass


def _row_to_response(row: dict) -> dict:
    """Convert a Supabase row to the frontend DockingResult shape."""
    result = None

    # Prefer Storage URL (Phase 0c)
    storage_url = row.get("storage_url")
    if storage_url:
        from app.services.artifact_storage import download_json
        result = download_json(storage_url)
    elif row.get("result_sdf"):
        try:
            result = json.loads(row["result_sdf"])
        except Exception:
            pass

    return {
        "job_id": row["id"],
        "status": row["status"],
        "result": result,
        "error": row.get("error"),
    }


def _row_to_list_response(row: dict) -> dict:
    """Lightweight row conversion for list views — skips Storage downloads."""
    return {
        "job_id": row["id"],
        "status": row["status"],
        "result": None,
        "error": row.get("error"),
    }


def _ligand_properties(smiles: str) -> dict:
    """Compute essential ligand properties from SMILES using RDKit.

    Uses the same descriptor conventions as the ADMET tool (HBA = N+O count).
    """
    try:
        from rdkit import Chem
        from rdkit.Chem import Descriptors, Lipinski, rdMolDescriptors
        mol = Chem.MolFromSmiles(smiles)
        if mol is None:
            return {}
        hydrogen_count = sum(a.GetTotalNumHs() for a in mol.GetAtoms())
        return {
            "molecular_formula": rdMolDescriptors.CalcMolFormula(mol),
            "molecular_weight": round(Descriptors.MolWt(mol), 2),
            "heavy_atoms": int(mol.GetNumHeavyAtoms()),
            "hydrogen_count": int(hydrogen_count),
            "total_atoms": int(mol.GetNumHeavyAtoms() + hydrogen_count),
            "rotatable_bonds": int(Lipinski.NumRotatableBonds(mol)),
            "tpsa": round(Descriptors.TPSA(mol, includeSandP=True), 2),
            "hbd": int(Lipinski.NumHDonors(mol)),
            "hba": int(rdMolDescriptors.CalcNumLipinskiHBA(mol)),
            "logp": round(Descriptors.MolLogP(mol), 2),
        }
    except Exception:
        return {}


# ---------------------------------------------------------------------------
# Background worker
# ---------------------------------------------------------------------------

def _run_docking_sync(job_id: str, payload: dict):
    """Run the full docking pipeline synchronously (in a thread)."""
    supabase = get_client()
    try:
        supabase.table(_TABLE).update({"status": "running"}).eq("id", job_id).execute()

        from app.tools.docking import (
            fetch_pdb_from_rcsb,
            compute_grid_center,
            smiles_to_pdbqt,
            pdb_to_pdbqt_receptor,
            run_vina,
        )
        import urllib.request
        from app.services.ssrf import validate_url

        pdb_id = payload.get("pdb_id", "").strip().upper()
        pdb_url = payload.get("pdb_url", "").strip()
        smiles = payload.get("ligand_smiles") or payload.get("smiles")
        if not smiles:
            raise ValueError("Missing ligand_smiles in job payload")

        # 1. Obtain PDB text
        pdb_text: str | None = None
        if pdb_url:
            validate_url(pdb_url)  # SSRF guard even in worker
            try:
                pdb_text = urllib.request.urlopen(pdb_url, timeout=30).read().decode("utf-8", errors="replace")
            except Exception:
                pass
        if not pdb_text and pdb_id:
            pdb_text = fetch_pdb_from_rcsb(pdb_id)

        if not pdb_text:
            raise RuntimeError(
                "Could not obtain a PDB structure. "
                "Provide a valid pdb_id or pdb_url."
            )

        # 2. Strip heteroatoms (keep protein backbone for receptor)
        protein_lines = [
            l for l in pdb_text.splitlines()
            if l.startswith("ATOM") or l.startswith("TER") or l.startswith("END")
        ]
        protein_pdb = "\n".join(protein_lines) if protein_lines else pdb_text

        # 3. Compute grid center if not provided
        grid_center = payload.get("grid_center")
        if not grid_center or all(v == 0 for v in grid_center):
            grid_center = compute_grid_center(protein_pdb)
            # Add a small offset so the center isn't dead on a backbone atom
            grid_center = [round(c + 2.0, 3) for c in grid_center]

        grid_size = payload.get("grid_size", [20.0, 20.0, 20.0])

        # 4. Prepare receptor
        protein_pdbqt = pdb_to_pdbqt_receptor(protein_pdb)

        # 5. Prepare ligand
        lig_pdbqt = smiles_to_pdbqt(smiles)

        # 6. Run AutoDock Vina
        vina_result = run_vina(
            protein_pdbqt=protein_pdbqt,
            ligand_pdbqt=lig_pdbqt,
            grid_center=grid_center,
            grid_size=grid_size,
            exhaustiveness=payload.get("exhaustiveness", 8),
            num_modes=payload.get("num_modes", 9),
        )

        # 7. Compute interaction summary for best pose
        interactions = _compute_interactions(
            protein_pdb, vina_result["ligand_pdb"]
        )
        pose_interactions = _summarize_pose_interactions(
            protein_pdb, vina_result.get("result_sdf", "")
        )

        # 8. Ligand essential data (from SMILES, RDKit)
        ligand_properties = _ligand_properties(smiles)

        result_obj = {
            "pdb_id": pdb_id,
            "smiles": smiles,
            "poses": vina_result["poses"],
            "num_poses": vina_result["num_poses"],
            "ligand_properties": ligand_properties,
            "box_center": {
                "x": grid_center[0],
                "y": grid_center[1],
                "z": grid_center[2],
            },
            "box_size": {
                "x": grid_size[0],
                "y": grid_size[1],
                "z": grid_size[2],
            },
            "vina_log": vina_result.get("vina_log", ""),
            "vina_version": vina_result.get("vina_version", ""),
            "vina_seed": (vina_result.get("vina_meta") or {}).get("random_seed"),
            "vina_exhaustiveness": (vina_result.get("vina_meta") or {}).get("exhaustiveness"),
            "interactions": interactions,
            "pose_interactions": pose_interactions,
            "ligand_pdb": vina_result.get("ligand_pdb", ""),
            "result_sdf": vina_result.get("result_sdf", ""),
            "receptor_pdb": protein_pdb,
        }

        # Offload to Supabase Storage; DB keeps only the URL
        from app.services.artifact_storage import upload_json
        storage_url = upload_json(job_id, "result", result_obj)

        supabase.table(_TABLE).update({
            "status": "complete",
            "storage_url": storage_url,
            "result_sdf": None,  # cleared — data lives in Storage now
        }).eq("id", job_id).execute()

    except Exception as exc:
        import traceback
        tb = traceback.format_exc()
        supabase.table(_TABLE).update({
            "status": "failed",
            "error": f"{exc}\n\n{tb}"[:4000],
        }).eq("id", job_id).execute()
    finally:
        _prune_old(supabase)


# ---------------------------------------------------------------------------
# Geometric interaction detector (H-bonds, hydrophobic, pi-stacking, salt bridges)
# ---------------------------------------------------------------------------

    # Protein atom classification
_HYDROPHOBIC_RES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "GLY"}
_AROMATIC_RES = {"PHE", "TRP", "TYR", "HIS"}

# Atoms in aromatic rings by residue (PDB atom names)
_AROMATIC_RING_ATOMS = {
    "PHE": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
    "TYR": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
    "HIS": ["CG", "ND1", "CD2", "CE1", "NE2"],
    "TRP": ["CG", "CD1", "CD2", "NE1", "CE2", "CE3", "CZ2", "CZ3", "CH2"],
}

# Two-ring centroids for TRP (5-membered + 6-membered)
_TRP_RING_ATOMS = {
    "five": ["CD1", "NE1", "CE2", "CG", "CD2"],
    "six": ["CE2", "CD2", "CZ2", "CH2", "CZ3", "CE3"],
}

# Polar atoms eligible for H-bonding
_POLAR_ATOMS = {"N", "O", "S"}

# Residue-level charge groups for salt bridges
_ANIONIC_RES = {"ASP", "GLU"}
_CATIONIC_RES = {"LYS", "ARG", "HIS"}

# Atom names that define the charged group center
_ANIONIC_CARBONS = {"ASP": "CG", "GLU": "CD"}
_CATIONIC_NITROGENS = {"LYS": "NZ", "ARG": ["CZ", "NH1", "NH2"]}

_PDB_COORD_RE = re.compile(
    r"^(ATOM|HETATM)\s+\d+\s+(\S+)\s+(\S{3})\s+(\S)\s+(\d+)\s+"
    r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
)


def _parse_atom_coords(pdb_text: str) -> list[tuple[str, str, str, str, int, float, float, float]]:
    """Parse PDB into (record, atom_name, res_name, chain, res_seq, x, y, z)."""
    atoms = []
    for line in pdb_text.splitlines():
        m = _PDB_COORD_RE.match(line)
        if m:
            atoms.append((
                m.group(1), m.group(2), m.group(3), m.group(4),
                int(m.group(5)),
                float(m.group(6)), float(m.group(7)), float(m.group(8)),
            ))
    return atoms


def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))


def _angle(a: tuple[float, float, float], b: tuple[float, float, float],
           c: tuple[float, float, float]) -> float:
    """Angle at vertex b between segments b→a and b→c, in degrees."""
    ba = tuple(x - y for x, y in zip(a, b))
    bc = tuple(x - y for x, y in zip(c, b))
    dot = sum(x * y for x, y in zip(ba, bc))
    mag_ba = math.sqrt(sum(x * x for x in ba))
    mag_bc = math.sqrt(sum(x * x for x in bc))
    if mag_ba < 1e-9 or mag_bc < 1e-9:
        return 0.0
    cos_angle = max(-1.0, min(1.0, dot / (mag_ba * mag_bc)))
    return math.degrees(math.acos(cos_angle))


def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
    return (a[0] - b[0], a[1] - b[1], a[2] - b[2])


def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
    return (
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0],
    )


def _vec_norm(v: tuple[float, float, float]) -> float:
    return math.sqrt(sum(x * x for x in v))


def _ring_centroid(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
    n = len(coords)
    if n == 0:
        return (0.0, 0.0, 0.0)
    return (
        sum(c[0] for c in coords) / n,
        sum(c[1] for c in coords) / n,
        sum(c[2] for c in coords) / n,
    )


def _ring_normal(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
    """Compute the normal vector of a planar ring via cross product of two edges."""
    if len(coords) < 3:
        return (0.0, 0.0, 1.0)
    v1 = _vec_sub(coords[1], coords[0])
    v2 = _vec_sub(coords[2], coords[0])
    cross = _vec_cross(v1, v2)
    n = _vec_norm(cross)
    if n < 1e-9:
        return (0.0, 0.0, 1.0)
    return (cross[0] / n, cross[1] / n, cross[2] / n)


def _build_residue_map(atoms: list[tuple]) -> dict[tuple[str, str, int], list[tuple]]:
    """Group atoms by (chain, res_name, res_seq)."""
    res_map: dict[tuple[str, str, int], list[tuple]] = {}
    for a in atoms:
        key = (a[3], a[2], a[4])  # chain, res_name, res_seq
        res_map.setdefault(key, []).append(a)
    return res_map


def _find_hydrogens(atoms: list[tuple]) -> list[tuple]:
    """Return only hydrogen atoms from parsed PDB."""
    return [a for a in atoms if a[1].startswith("H") or a[1] in ("1H", "2H", "3H")]


def _compute_interactions(protein_pdb: str, ligand_pdb: str) -> dict:
    """
    Compute protein-ligand interactions using proper geometry.

    H-bonds:     donor-H···acceptor angle > 120°, distance < 3.5Å
    Hydrophobic: ligand carbon near protein carbon in hydrophobic residue, < 4.5Å
    Pi-stacking: aromatic ring centroids, distance < 5.5Å, inter-ring angle
    Salt bridges: charged group centroids, distance < 4.0Å
    """
    if not ligand_pdb:
        return {"hbonds": [], "hydrophobic": [], "pi_stacking": [], "salt_bridges": []}

    prot_atoms = _parse_atom_coords(protein_pdb)
    lig_atoms = _parse_atom_coords(ligand_pdb)
    prot_h = _find_hydrogens(prot_atoms)
    lig_h = _find_hydrogens(lig_atoms)
    prot_heavy = [a for a in prot_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]
    lig_heavy = [a for a in lig_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]

    hbonds: list[dict] = []
    hydrophobic: list[dict] = []
    pi_stacking: list[dict] = []
    salt_bridges: list[dict] = []

    seen_hbonds: set[tuple] = set()
    seen_hydrophobic: set[tuple] = set()
    seen_salt: set[tuple] = set()

    # --- H-bonds with angle check ---
    for la in lig_heavy:
        l_elem = la[1][0] if la[1] else ""
        if l_elem not in _POLAR_ATOMS:
            continue
        lcoord = (la[5], la[6], la[7])

        # Find nearest H on ligand for angle reference
        lig_h_near = None
        min_h_dist = 1.5
        for h in lig_h:
            hd = _distance(lcoord, (h[5], h[6], h[7]))
            if hd < min_h_dist:
                min_h_dist = hd
                lig_h_near = (h[5], h[6], h[7])

        for pa in prot_heavy:
            p_elem = pa[1][0] if pa[1] else ""
            if p_elem not in _POLAR_ATOMS:
                continue
            pcoord = (pa[5], pa[6], pa[7])
            d = _distance(lcoord, pcoord)

            if d > 3.5 or d < 1.0:
                continue

            # Find nearest H on protein donor for angle check
            prot_h_near = None
            min_ph_dist = 1.5
            for h in prot_h:
                hd = _distance(pcoord, (h[5], h[6], h[7]))
                if hd < min_ph_dist:
                    min_ph_dist = hd
                    prot_h_near = (h[5], h[6], h[7])

            # Check angle if we have hydrogen positions
            angle_ok = True
            if lig_h_near and prot_h_near:
                # H-bond angle: ligand-H···protein or protein-H···ligand
                a1 = _angle(lig_h_near, lcoord, pcoord)
                a2 = _angle(prot_h_near, pcoord, lcoord)
                angle_ok = max(a1, a2) > 120.0
            elif lig_h_near:
                a1 = _angle(lig_h_near, lcoord, pcoord)
                angle_ok = a1 > 120.0
            elif prot_h_near:
                a1 = _angle(prot_h_near, pcoord, lcoord)
                angle_ok = a1 > 120.0
            # If no H found at all, accept based on distance + element only

            if not angle_ok:
                continue

            key = (la[4], pa[4])  # (lig_res_seq, prot_res_seq)
            if key in seen_hbonds:
                continue
            seen_hbonds.add(key)

            hbonds.append({
                "type": "hbond",
                "ligand_atom": la[1],
                "ligand_coords": [la[5], la[6], la[7]],
                "protein_residue": pa[2],
                "protein_residue_seq": pa[4],
                "protein_chain": pa[3],
                "protein_atom": pa[1],
                "protein_coords": [pa[5], pa[6], pa[7]],
                "distance": round(d, 2),
                "confidence": "high" if d < 3.0 else "medium",
            })
            if len(hbonds) >= 20:
                break
        if len(hbonds) >= 20:
            break

    # --- Hydrophobic contacts ---
    for la in lig_heavy:
        if la[1][0] != "C":
            continue
        lcoord = (la[5], la[6], la[7])
        for pa in prot_heavy:
            if pa[1][0] != "C":
                continue
            pres = pa[2]
            if pres not in _HYDROPHOBIC_RES:
                continue
            pcoord = (pa[5], pa[6], pa[7])
            d = _distance(lcoord, pcoord)
            if d < 4.5:
                key = (la[4], pa[4])
                if key in seen_hydrophobic:
                    continue
                seen_hydrophobic.add(key)
                hydrophobic.append({
                    "type": "hydrophobic",
                    "ligand_atom": la[1],
                    "ligand_coords": [la[5], la[6], la[7]],
                    "protein_residue": pres,
                    "protein_residue_seq": pa[4],
                    "protein_chain": pa[3],
                    "protein_atom": pa[1],
                    "protein_coords": [pa[5], pa[6], pa[7]],
                    "distance": round(d, 2),
                })
                if len(hydrophobic) >= 20:
                    break
        if len(hydrophobic) >= 20:
            break

    # --- Pi-stacking (aromatic ring centroid geometry) ---
    prot_res_map = _build_residue_map(prot_heavy)

    for res_key, res_atoms in prot_res_map.items():
        chain, res_name, res_seq = res_key
        if res_name not in _AROMATIC_RES:
            continue

        ring_atom_names = _AROMATIC_RING_ATOMS[res_name]
        ring_atoms_by_name = {a[1]: a for a in res_atoms}
        ring_coords = []
        for rn in ring_atom_names:
            if rn in ring_atoms_by_name:
                a = ring_atoms_by_name[rn]
                ring_coords.append((a[5], a[6], a[7]))

        if len(ring_coords) < 3:
            continue

        centroid = _ring_centroid(ring_coords)
        normal = _ring_normal(ring_coords)

        # For TRP, also check the 5-membered ring
        rings_to_check = [(ring_coords, centroid, normal)]
        if res_name == "TRP":
            for ring_name in ("five", "six"):
                ring_atom_names_2 = _TRP_RING_ATOMS[ring_name]
                coords_2 = []
                for rn in ring_atom_names_2:
                    if rn in ring_atoms_by_name:
                        a = ring_atoms_by_name[rn]
                        coords_2.append((a[5], a[6], a[7]))
                if len(coords_2) >= 3:
                    rings_to_check.append((coords_2, _ring_centroid(coords_2), _ring_normal(coords_2)))

        for ring_coords_r, centroid_r, normal_r in rings_to_check:
            # Find aromatic atoms in ligand (heuristic: C/N in a flat region)
            lig_aromatic_coords = []
            for la in lig_heavy:
                if la[1][0] in ("C", "N"):
                    lig_aromatic_coords.append((la[5], la[6], la[7]))

            if len(lig_aromatic_coords) < 3:
                continue

            # Use all ligand heavy atoms as a pseudo-centroid
            lig_centroid = _ring_centroid(lig_aromatic_coords)

            dist = _distance(centroid_r, lig_centroid)
            if dist > 6.5:
                continue

            # Compute angle between ring normal and vector to ligand centroid
            v_to_lig = _vec_sub(lig_centroid, centroid_r)
            v_norm = _vec_norm(v_to_lig)
            if v_norm < 1e-9:
                continue
            cos_angle = abs(sum(x * y for x, y in zip(normal_r, v_to_lig))) / (
                _vec_norm(normal_r) * v_norm
            )
            ring_angle = math.degrees(math.acos(max(0, min(1, cos_angle))))

            # Parallel: ring normal ~parallel to centroid-centroid vector (angle < 30°)
            # T-shaped: ring normal ~perpendicular (angle 60-90°)
            stacking_type = "unknown"
            if ring_angle < 30 and dist < 5.5:
                stacking_type = "parallel"
            elif 60 < ring_angle < 90 and dist < 6.5:
                stacking_type = "perpendicular"

            if stacking_type == "unknown":
                continue

            pi_stacking.append({
                "type": "pi_stacking",
                "protein_residue": res_name,
                "protein_residue_seq": res_seq,
                "protein_chain": chain,
                "ring_centroid": [round(c, 3) for c in centroid_r],
                "ring_normal": [round(c, 3) for c in normal_r],
                "ligand_centroid": [round(c, 3) for c in lig_centroid],
                "distance": round(dist, 2),
                "angle": round(ring_angle, 1),
                "stacking_type": stacking_type,
                "confidence": "high" if dist < 4.5 else "medium",
            })
            if len(pi_stacking) >= 10:
                break
        if len(pi_stacking) >= 10:
            break

    # --- Salt bridges (charged group centroid distance) ---
    for la in lig_heavy:
        l_elem = la[1][0] if la[1] else ""
        if l_elem not in ("N", "O", "S", "C"):
            continue
        lcoord = (la[5], la[6], la[7])

        for pa in prot_heavy:
            pres = pa[2]
            if pres in _ANIONIC_RES and pa[1] in ("OD1", "OD2", "OE1", "OE2"):
                d = _distance(lcoord, pa[1:8] if False else (pa[5], pa[6], pa[7]))
                if d < 4.0 and l_elem in ("N",):
                    key = (la[4], pa[4])
                    if key not in seen_salt:
                        seen_salt.add(key)
                        salt_bridges.append({
                            "type": "salt_bridge",
                            "ligand_atom": la[1],
                            "ligand_coords": [la[5], la[6], la[7]],
                            "protein_residue": pres,
                            "protein_residue_seq": pa[4],
                            "protein_chain": pa[3],
                            "protein_atom": pa[1],
                            "protein_coords": [pa[5], pa[6], pa[7]],
                            "distance": round(d, 2),
                            "charge_pair": "positive-negative",
                        })

            if pres in _CATIONIC_RES:
                cat_atoms = _CATIONIC_NITROGENS.get(pres, [])
                if isinstance(cat_atoms, str):
                    cat_atoms = [cat_atoms]
                if pa[1] in cat_atoms:
                    d = _distance(lcoord, (pa[5], pa[6], pa[7]))
                    if d < 4.0 and l_elem in ("O",):
                        key = (la[4], pa[4])
                        if key not in seen_salt:
                            seen_salt.add(key)
                            salt_bridges.append({
                                "type": "salt_bridge",
                                "ligand_atom": la[1],
                                "ligand_coords": [la[5], la[6], la[7]],
                                "protein_residue": pres,
                                "protein_residue_seq": pa[4],
                                "protein_chain": pa[3],
                                "protein_atom": pa[1],
                                "protein_coords": [pa[5], pa[6], pa[7]],
                                "distance": round(d, 2),
                                "charge_pair": "negative-positive",
                            })

    return {
        "hbonds": hbonds[:20],
        "hydrophobic": hydrophobic[:20],
        "pi_stacking": pi_stacking[:10],
        "salt_bridges": salt_bridges[:10],
    }


def _summarize_pose_interactions(protein_pdb: str, output_pdbqt: str) -> list[dict]:
    """Per-pose interaction summary."""
    if not output_pdbqt:
        return []
    models: dict[int, list[str]] = {}
    current: int | None = None
    for line in output_pdbqt.splitlines():
        if line.startswith("MODEL"):
            parts = line.split()
            if len(parts) >= 2:
                current = int(parts[1])
                models[current] = []
        elif line.startswith("ENDMDL"):
            current = None
        elif current is not None:
            models.setdefault(current, []).append(line)

    summaries = []
    for mid in sorted(models.keys()):
        lig_pdb = "\n".join(l for l in models[mid] if l.startswith("HETATM")) + "\nEND"
        inter = _compute_interactions(protein_pdb, lig_pdb)
        summaries.append({
            "model": mid,
            "hbonds": len(inter.get("hbonds", [])),
            "hydrophobic": len(inter.get("hydrophobic", [])),
            "pi_stacking": len(inter.get("pi_stacking", [])),
            "salt_bridges": len(inter.get("salt_bridges", [])),
        })
    return summaries


# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------

@router.post("/run", response_model=DockingJobResponse)
async def create_docking_job(request: Request, body: DockingJobCreate, user_id: str = Depends(require_user_id)):
    supabase = get_client()
    _prune_old(supabase)

    # SSRF validation on user-supplied URL
    if body.pdb_url:
        validate_url(body.pdb_url)

    import uuid, datetime
    job_id = str(uuid.uuid4())
    now = datetime.datetime.utcnow().isoformat()

    insert_row = {
        "id": job_id,
        "status": "queued",
        "ligand_smiles": body.smiles,
        "user_id": user_id,
        "payload": {
            "pdb_id": body.pdb_id,
            "pdb_url": body.pdb_url,
            "grid_center": body.grid_center or [0, 0, 0],
            "grid_size": body.grid_size,
            "exhaustiveness": body.exhaustiveness,
            "num_modes": body.num_modes,
            "smiles": body.smiles,
            "ligand_smiles": body.smiles,
        },
    }
    try:
        supabase.table(_TABLE).insert(insert_row).execute()
    except Exception as e:
        if "ligand_smiles" in str(e):
            supabase.table(_TABLE).insert({
                "id": job_id, "status": "queued", "user_id": user_id,
                "payload": insert_row["payload"],
            }).execute()
        else:
            raise

    return DockingJobResponse(job_id=job_id, status="queued", result=None)


@router.get("/status/{job_id}", response_model=DockingJobResponse)
async def get_docking_job(job_id: str, user_id: str = Depends(require_user_id)):
    supabase = get_client()
    result = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute()
    if not result.data:
        raise HTTPException(status_code=404, detail="Docking job not found")
    return DockingJobResponse(**_row_to_response(result.data))


@router.get("/result/{job_id}/pdb")
async def get_docking_pdb(job_id: str, user_id: str = Depends(require_user_id)):
    supabase = get_client()
    row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute()
    if not row.data:
        raise HTTPException(status_code=404, detail="Docking result not found")

    data = None
    if row.data.get("storage_url"):
        from app.services.artifact_storage import download_json
        data = download_json(row.data["storage_url"])
    elif row.data.get("result_sdf"):
        try:
            data = json.loads(row.data["result_sdf"])
        except Exception:
            pass

    if not data:
        raise HTTPException(status_code=404, detail="Docking result not found")
    ligand_pdb = data.get("ligand_pdb", "")
    if not ligand_pdb:
        raise HTTPException(status_code=404, detail="No ligand PDB available")
    from fastapi.responses import PlainTextResponse
    return PlainTextResponse(ligand_pdb, media_type="text/plain")


def _load_docking_result(job_id: str, user_id: str) -> dict:
    supabase = get_client()
    row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute()
    if not row.data:
        raise HTTPException(status_code=404, detail="Docking result not found")
    data = None
    if row.data.get("storage_url"):
        from app.services.artifact_storage import download_json
        data = download_json(row.data["storage_url"])
    elif row.data.get("result_sdf"):
        try:
            data = json.loads(row.data["result_sdf"])
        except Exception:
            pass
    if not data:
        raise HTTPException(status_code=404, detail="Docking result not found")
    return data


@router.get("/result/{job_id}/ligand.sdf")
async def get_docking_ligand_sdf(job_id: str, user_id: str = Depends(require_user_id)):
    """Ligand-only SDF of the docked poses (techspec §2)."""
    data = _load_docking_result(job_id, user_id)
    sdf = data.get("result_sdf", "")
    if not sdf:
        raise HTTPException(
            status_code=404,
            detail="No SDF stored for this job (older runs predate SDF persistence — re-run the docking job)",
        )
    return PlainTextResponse(
        sdf,
        media_type="chemical/x-mdl-molfile",
        headers={"Content-Disposition": f'attachment; filename="docked_{job_id[:8]}.sdf"'},
    )


@router.get("/result/{job_id}/complex.pdb")
async def get_docking_complex_pdb(job_id: str, user_id: str = Depends(require_user_id)):
    """Receptor + docked ligand merged into a single PDB (techspec §2)."""
    data = _load_docking_result(job_id, user_id)
    receptor = data.get("receptor_pdb", "")
    ligand = data.get("ligand_pdb", "")
    if not receptor or not ligand:
        raise HTTPException(
            status_code=404,
            detail="Complex export needs both receptor and ligand structures (older runs may lack them — re-run the job)",
        )
    complex_text = receptor.rstrip() + "\n" + ligand.rstrip() + "\nEND\n"
    return PlainTextResponse(
        complex_text,
        media_type="chemical/x-pdb",
        headers={"Content-Disposition": f'attachment; filename="complex_{job_id[:8]}.pdb"'},
    )


@router.get("")
async def list_docking_jobs(limit: int = 50, user_id: str = Depends(require_user_id)):
    supabase = get_client()
    rows = (
        supabase.table(_TABLE)
        .select("*")
        .eq("user_id", user_id)
        .order("created_at", desc=True)
        .limit(limit)
        .execute()
        .data
    )
    return {"jobs": [_row_to_list_response(r) for r in rows]}