""" Isnad chain endpoints — chain visualization data (D3-ready nodes + links). """ from fastapi import APIRouter, Query, Path, HTTPException from app.services.database import db from app.utils.arabic import normalize_name from app.models.schemas import ( IsnadChain, IsnadNode, IsnadLink, PaginatedResponse, PaginationMeta, ) router = APIRouter(prefix="/chains", tags=["Isnad Chains"]) def _paginate(total: int, page: int, per_page: int) -> PaginationMeta: pages = max(1, (total + per_page - 1) // per_page) return PaginationMeta(total=total, page=page, per_page=per_page, pages=pages) # ── Chain for a single hadith ────────────────────────────────────────────── @router.get("/hadith/{hadith_id}", response_model=IsnadChain) async def get_isnad_chain( hadith_id: str = Path(..., description="Hadith UUID"), ): """ Get the isnad chain for a hadith as a directed graph (nodes + links). Returns D3-compatible format for frontend visualization. """ hadith = db.neo4j_query_one(""" MATCH (h:Hadith {id: $hid}) RETURN h.id AS id, h.collection AS collection, h.hadith_number AS hadith_number """, {"hid": hadith_id}) if not hadith: raise HTTPException(status_code=404, detail="Hadith not found in graph") # Narrator nodes in the chain nodes_rows = db.neo4j_query(""" MATCH (n:Narrator)-[a:APPEARS_IN]->(h:Hadith {id: $hid}) RETURN n.name_arabic AS name_arabic, n.name_transliterated AS name_transliterated, n.entity_type AS entity_type, n.generation AS generation, n.reliability_grade AS reliability_grade ORDER BY a.chain_order """, {"hid": hadith_id}) # Transmission links — NARRATED_FROM edges store hadith_ids as array links_rows = db.neo4j_query(""" MATCH (a:Narrator)-[nf:NARRATED_FROM]->(b:Narrator) WHERE $hid IN nf.hadith_ids RETURN a.name_arabic AS source, b.name_arabic AS target, nf.transmission_verb AS transmission_verb ORDER BY a.name_arabic """, {"hid": hadith_id}) return IsnadChain( hadith_id=hadith_id, collection=hadith.get("collection"), hadith_number=hadith.get("hadith_number"), nodes=[IsnadNode(**r) for r in nodes_rows], links=[IsnadLink(**r) for r in links_rows], ) # ── All chains containing a narrator (paginated) ────────────────────────── @router.get("/narrator/{name_arabic}", response_model=PaginatedResponse) async def chains_by_narrator( name_arabic: str = Path(..., description="Narrator Arabic name"), page: int = Query(1, ge=1), per_page: int = Query(10, ge=1, le=50), ): """ All isnad chains containing a narrator. Useful for seeing how a narrator connects to the Prophet ﷺ across collections. """ q_norm = normalize_name(name_arabic) skip = (page - 1) * per_page total = db.neo4j_count(""" MATCH (n:Narrator)-[:APPEARS_IN]->(h:Hadith) WHERE toLower(n.name_arabic) CONTAINS toLower($name) RETURN count(DISTINCT h) AS count """, {"name": q_norm}) hadith_ids = db.neo4j_query(""" MATCH (n:Narrator)-[:APPEARS_IN]->(h:Hadith) WHERE toLower(n.name_arabic) CONTAINS toLower($name) RETURN DISTINCT h.id AS id ORDER BY h.id SKIP $skip LIMIT $limit """, {"name": q_norm, "skip": skip, "limit": per_page}) chains = [] for row in hadith_ids: chain = await get_isnad_chain(str(row["id"])) chains.append(chain) return PaginatedResponse( data=chains, meta=_paginate(total, page, per_page), ) # ── Common chains between two narrators (paginated) ─────────────────────── @router.get("/common", response_model=PaginatedResponse) async def find_common_chains( narrator_a: str = Query(..., description="First narrator (Arabic)"), narrator_b: str = Query(..., description="Second narrator (Arabic)"), page: int = Query(1, ge=1), per_page: int = Query(10, ge=1, le=50), ): """Find hadiths where both narrators appear in the same chain.""" a_norm = normalize_name(narrator_a) b_norm = normalize_name(narrator_b) skip = (page - 1) * per_page total = db.neo4j_count(""" MATCH (a:Narrator)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b:Narrator) WHERE toLower(a.name_arabic) CONTAINS toLower($a) AND toLower(b.name_arabic) CONTAINS toLower($b) AND a <> b RETURN count(DISTINCT h) AS count """, {"a": a_norm, "b": b_norm}) rows = db.neo4j_query(""" MATCH (a:Narrator)-[:APPEARS_IN]->(h:Hadith)<-[:APPEARS_IN]-(b:Narrator) WHERE toLower(a.name_arabic) CONTAINS toLower($a) AND toLower(b.name_arabic) CONTAINS toLower($b) AND a <> b RETURN DISTINCT h.id AS hadith_id, h.collection AS collection, h.hadith_number AS hadith_number, a.name_arabic AS narrator_a, b.name_arabic AS narrator_b ORDER BY h.collection, h.hadith_number SKIP $skip LIMIT $limit """, {"a": a_norm, "b": b_norm, "skip": skip, "limit": per_page}) return PaginatedResponse( data=[dict(r) for r in rows], meta=_paginate(total, page, per_page), )