"""
Timetable Builder Router — Supabase-first write architecture.
Supabase is the source of truth; Neo4j is a derived graph rebuildable at any time.
"""
import os
import json
from collections import defaultdict
from datetime import datetime, timedelta, date
from typing import Dict, Any, List, Optional, Tuple
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from modules.logger_tool import initialise_logger
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
import modules.database.tools.neo4j_driver_tools as driver_tools

logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
router = APIRouter()


# ─── Constants ────────────────────────────────────────────────────────────────

DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

DAY_TYPE_LABELS: Dict[str, str] = {
    "Academic": "AcademicDay",
    "Holiday": "HolidayDay",
    "Staff": "StaffDay",
    "OffTimetable": "OffTimetableDay",
}

PERIOD_TYPE_LABELS: Dict[str, str] = {
    "lesson": "AcademicPeriod",
    "break": "BreakPeriod",
    "registration": "RegistrationPeriod",
    "offtimetable": "OffTimetablePeriod",
}

PERIOD_DAY_RELS: Dict[str, str] = {
    "AcademicPeriod": "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD",
    "BreakPeriod": "ACADEMIC_DAY_HAS_BREAK_PERIOD",
    "RegistrationPeriod": "ACADEMIC_DAY_HAS_REGISTRATION_PERIOD",
    "OffTimetablePeriod": "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD",
}


# ─── Helpers ──────────────────────────────────────────────────────────────────

def _sb() -> SupabaseServiceRoleClient:
    return SupabaseServiceRoleClient()


def _iso_date(s: str) -> date:
    return datetime.strptime(s, "%Y-%m-%d").date()


def _academic_weeks(term_start: date, term_end: date):
    """Yield (week_number, monday_date) for each Mon–Sun block that overlaps the term."""
    current = term_start - timedelta(days=term_start.weekday())
    if current < term_start:
        current += timedelta(weeks=1)
    n = 1
    while current <= term_end:
        yield n, current
        current += timedelta(weeks=1)
        n += 1


def _query_teacher_uuid(db: str, email: str) -> Optional[str]:
    try:
        with driver_tools.get_session(database=db) as s:
            rec = s.run(
                "MATCH (t:Teacher) WHERE t.worker_email = $e RETURN t.uuid_string AS uuid LIMIT 1",
                e=email,
            ).single()
            return rec["uuid"] if rec else None
    except Exception:
        return None


def _scan_institute_dbs(user_email: str) -> Tuple[Optional[str], Optional[str]]:
    """Fallback: scan all institute DBs for a Teacher node with matching email."""
    if not user_email:
        return None, None
    try:
        with driver_tools.get_session(database="system") as s:
            dbs = [
                r["name"] for r in s.run(
                    "SHOW DATABASES YIELD name "
                    "WHERE name STARTS WITH 'cc.institutes.' "
                    "AND NOT name ENDS WITH '.curriculum' RETURN name"
                )
            ]
        for db in dbs:
            try:
                with driver_tools.get_session(database=db) as s:
                    rec = s.run(
                        "MATCH (t:Teacher) WHERE t.worker_email = $e "
                        "RETURN t.uuid_string AS uuid LIMIT 1",
                        e=user_email,
                    ).single()
                    if rec and rec["uuid"]:
                        return db, rec["uuid"]
            except Exception:
                continue
    except Exception as e:
        logger.warning(f"Institute DB scan failed: {e}")
    return None, None


def _resolve_institute(
    user_id: str, user_email: str
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
    """
    Returns (supabase_institute_id, neo4j_institute_db, neo4j_teacher_uuid).
    Supabase-first lookup with Neo4j email-scan fallback.
    """
    # Fast path: profiles.school_id → institutes.neo4j_uuid_string
    try:
        sb = _sb()
        p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
        school_id = (p.data or {}).get("school_id")
        if school_id:
            i = sb.supabase.table("institutes").select("id,neo4j_uuid_string").eq("id", str(school_id)).single().execute()
            inst = i.data or {}
            neo4j_uuid = inst.get("neo4j_uuid_string")
            if neo4j_uuid:
                db = f"cc.institutes.{neo4j_uuid}"
                teacher_uuid = _query_teacher_uuid(db, user_email)
                return str(school_id), db, teacher_uuid
    except Exception as e:
        logger.warning(f"Supabase-first institute resolve failed: {e}")

    # Fallback: scan Neo4j
    db, teacher_uuid = _scan_institute_dbs(user_email)
    supabase_id: Optional[str] = None
    if db:
        try:
            sb = _sb()
            p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
            sid = (p.data or {}).get("school_id")
            if sid:
                supabase_id = str(sid)
        except Exception:
            pass
    return supabase_id, db, teacher_uuid


# ─── Request models ───────────────────────────────────────────────────────────

class TermInput(BaseModel):
    name: str
    term_number: int
    start_date: str
    end_date: str
    notes: Optional[str] = None


class PeriodInput(BaseModel):
    code: str
    name: str
    start_time: str
    end_time: str
    period_type: str  # lesson | break | registration | offtimetable


class DayInput(BaseModel):
    date: str                                       # "2025-09-01"
    day_type: str = "Academic"                      # Academic | Holiday | Staff | OffTimetable
    week_cycle: str = "A"
    excluded_period_codes: Optional[List[str]] = None


class TermBreakInput(BaseModel):
    name: str       # "Christmas Break"
    start_date: str # "2025-12-20"
    end_date: str   # "2026-01-05"


class WeekCycleOverride(BaseModel):
    term_number: int
    week_number: int   # 1-based, within the term
    cycle: str         # "A" | "B"


class TimetableSetupRequest(BaseModel):
    year_start: str
    year_end: str
    terms: List[TermInput]
    periods: List[PeriodInput]
    days: Optional[List[DayInput]] = None           # None = auto-generate Mon–Fri as Academic
    term_breaks: Optional[List[TermBreakInput]] = None
    week_cycles: Optional[List[WeekCycleOverride]] = None


class SlotInput(BaseModel):
    day_of_week: str
    period_code: str
    subject_class: str
    start_time: str
    end_time: str
    week_cycle: str = ""  # '' = both A/B weeks; 'A' or 'B' = specific week


class SlotsRequest(BaseModel):
    timetable_id: str
    slots: List[SlotInput]


# ─── Supabase write helpers ───────────────────────────────────────────────────

def _sb_upsert_timetable(
    sb: SupabaseServiceRoleClient,
    institute_id: str,
    year_start: date,
    year_end: date,
    year_label: str,
    school_tt_id: str,
    periods: List[PeriodInput],
) -> str:
    res = sb.supabase.table("school_timetables").upsert(
        {
            "institute_id": institute_id,
            "year_label": year_label,
            "start_date": str(year_start),
            "end_date": str(year_end),
            "periods_template": [p.dict() for p in periods],
            "neo4j_node_id": school_tt_id,
        },
        on_conflict="institute_id,year_label",
    ).execute()
    return (res.data or [{}])[0].get("id", "")


def _sb_upsert_academic_year(
    sb: SupabaseServiceRoleClient,
    stt_sb_id: str,
    institute_id: str,
    year_label: str,
    ay_id: str,
) -> str:
    res = sb.supabase.table("academic_years").upsert(
        {
            "school_timetable_id": stt_sb_id,
            "institute_id": institute_id,
            "year_label": year_label,
            "neo4j_node_id": ay_id,
        },
        on_conflict="school_timetable_id,year_label",
    ).execute()
    return (res.data or [{}])[0].get("id", "")


def _sb_upsert_term_breaks(
    sb: SupabaseServiceRoleClient,
    institute_id: str,
    stt_sb_id: str,
    term_breaks: List[TermBreakInput],
) -> None:
    for tb in term_breaks:
        sb.supabase.table("academic_term_breaks").upsert(
            {
                "school_timetable_id": stt_sb_id,
                "institute_id": institute_id,
                "break_name": tb.name,
                "start_date": tb.start_date,
                "end_date": tb.end_date,
            },
            on_conflict="school_timetable_id,break_name",
        ).execute()


def _sb_upsert_terms_weeks_days(
    sb: SupabaseServiceRoleClient,
    ay_sb_id: str,
    institute_id: str,
    terms: List[TermInput],
    year_start: date,
    days_by_date: Dict[str, DayInput],
    week_cycles_map: Optional[Dict[Tuple[int, int], str]] = None,
) -> None:
    academic_day_number = 0
    for term in terms:
        t_id = f"term_{year_start.year}_{term.term_number}"
        t_start = _iso_date(term.start_date)
        t_end = _iso_date(term.end_date)

        t_upsert: Dict[str, Any] = {
            "academic_year_id": ay_sb_id,
            "institute_id": institute_id,
            "term_name": term.name,
            "term_number": term.term_number,
            "start_date": term.start_date,
            "end_date": term.end_date,
            "neo4j_node_id": t_id,
        }
        if term.notes is not None:
            t_upsert["notes"] = term.notes

        t_res = sb.supabase.table("academic_terms").upsert(
            t_upsert, on_conflict="academic_year_id,term_number",
        ).execute()
        t_sb_id = (t_res.data or [{}])[0].get("id", "")

        for wn, wstart in _academic_weeks(t_start, t_end):
            w_id = f"week_{t_id}_{wn}"
            # Use caller-provided cycle if given, else default alternating A/B within term
            if week_cycles_map and (term.term_number, wn) in week_cycles_map:
                week_cycle = week_cycles_map[(term.term_number, wn)]
            else:
                week_cycle = "A" if wn % 2 == 1 else "B"

            w_res = sb.supabase.table("academic_weeks").upsert(
                {
                    "academic_term_id": t_sb_id,
                    "institute_id": institute_id,
                    "week_number": wn,
                    "start_date": wstart.isoformat(),
                    "week_cycle": week_cycle,
                    "neo4j_node_id": w_id,
                },
                on_conflict="academic_term_id,week_number",
            ).execute()
            w_sb_id = (w_res.data or [{}])[0].get("id", "")

            for day_offset in range(5):  # Mon–Fri
                d = wstart + timedelta(days=day_offset)
                if d < t_start or d > t_end:
                    continue
                date_iso = d.isoformat()
                day_input = days_by_date.get(date_iso)
                day_type = day_input.day_type if day_input else "Academic"
                excl = (day_input.excluded_period_codes or []) if day_input else []

                if day_type == "Academic":
                    academic_day_number += 1
                    adn: Optional[int] = academic_day_number
                else:
                    adn = None

                d_id = f"day_{w_id}_{date_iso}"
                sb.supabase.table("academic_days").upsert(
                    {
                        "academic_week_id": w_sb_id,
                        "academic_term_id": t_sb_id,
                        "institute_id": institute_id,
                        "date": date_iso,
                        "day_of_week": DAY_NAMES[d.weekday()],
                        "day_type": day_type,
                        "academic_day_number": adn,
                        "excluded_period_codes": excl,
                        "neo4j_node_id": d_id,
                    },
                    on_conflict="institute_id,date",
                ).execute()


def _sb_upsert_teacher_timetable(
    sb: SupabaseServiceRoleClient,
    profile_id: str,
    institute_id: str,
    stt_sb_id: str,
    year_start: date,
    year_end: date,
    teacher_tt_id: str,
) -> str:
    res = sb.supabase.table("teacher_timetables").upsert(
        {
            "profile_id": profile_id,
            "institute_id": institute_id,
            "school_timetable_id": stt_sb_id,
            "start_date": str(year_start),
            "end_date": str(year_end),
            "neo4j_node_id": teacher_tt_id,
        },
        on_conflict="profile_id,school_timetable_id",
    ).execute()
    return (res.data or [{}])[0].get("id", "")


# ─── Neo4j build from Supabase ────────────────────────────────────────────────

def _build_neo4j_from_supabase(institute_id: str, institute_db: str) -> Dict[str, int]:
    """
    Read all academic calendar data from Supabase for institute_id and
    create/merge the full Neo4j node graph in institute_db.
    Returns counts of nodes created.
    """
    sb = _sb()

    stt_rows = sb.supabase.table("school_timetables").select("*").eq("institute_id", institute_id).execute().data or []
    if not stt_rows:
        raise ValueError(f"No school_timetable in Supabase for institute {institute_id}")
    stt = stt_rows[0]

    ay_rows = sb.supabase.table("academic_years").select("*").eq("school_timetable_id", stt["id"]).execute().data or []
    if not ay_rows:
        raise ValueError("No academic_year found for this timetable")
    ay = ay_rows[0]

    terms = sb.supabase.table("academic_terms").select("*").eq("academic_year_id", ay["id"]).order("term_number").execute().data or []
    term_ids = [t["id"] for t in terms]

    weeks: List[Dict] = []
    if term_ids:
        weeks = sb.supabase.table("academic_weeks").select("*").in_("academic_term_id", term_ids).order("start_date").execute().data or []

    days = sb.supabase.table("academic_days").select("*").eq("institute_id", institute_id).order("date").execute().data or []

    weeks_by_term: Dict[str, List[Dict]] = defaultdict(list)
    for w in weeks:
        weeks_by_term[w["academic_term_id"]].append(w)

    days_by_week: Dict[str, List[Dict]] = defaultdict(list)
    for d in days:
        days_by_week[d["academic_week_id"]].append(d)

    periods_template: List[Dict] = stt.get("periods_template") or []
    counts = {"terms": 0, "weeks": 0, "days": 0, "periods": 0}

    with driver_tools.get_session(database=institute_db) as s:
        # SchoolTimetable
        s.run("""
            MERGE (tt:SchoolTimetable {uuid_string: $id})
            SET tt.school_timetable_id = $id,
                tt.start_date = date($start), tt.end_date = date($end),
                tt.node_storage_path = $path,
                tt.periods_template = $periods
            WITH tt MATCH (sch:School)
            MERGE (sch)-[:HAS_TIMETABLE]->(tt)
        """,
            id=stt["neo4j_node_id"],
            start=str(stt["start_date"]),
            end=str(stt["end_date"]),
            path=f"timetable/{stt['neo4j_node_id']}",
            periods=json.dumps(periods_template),
        )

        # AcademicYear
        s.run("""
            MERGE (ay:AcademicYear {uuid_string: $id})
            SET ay.year = $year, ay.node_storage_path = $path
            WITH ay MATCH (tt:SchoolTimetable {uuid_string: $tt_id})
            MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay)
        """,
            id=ay["neo4j_node_id"],
            year=ay["year_label"],
            path=f"timetable/{ay['neo4j_node_id']}",
            tt_id=stt["neo4j_node_id"],
        )

        for term in terms:
            s.run("""
                MERGE (t:AcademicTerm {uuid_string: $id})
                SET t.term_name = $name, t.term_number = $num,
                    t.start_date = date($start), t.end_date = date($end),
                    t.node_storage_path = $path
                WITH t MATCH (ay:AcademicYear {uuid_string: $ay_id})
                MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t)
            """,
                id=term["neo4j_node_id"],
                name=term["term_name"],
                num=str(term["term_number"]),
                start=str(term["start_date"]),
                end=str(term["end_date"]),
                path=f"timetable/{term['neo4j_node_id']}",
                ay_id=ay["neo4j_node_id"],
            )
            counts["terms"] += 1

            for week in weeks_by_term[term["id"]]:
                s.run("""
                    MERGE (w:AcademicWeek {uuid_string: $id})
                    SET w.academic_week_number = $num,
                        w.start_date = date($start),
                        w.week_cycle = $cycle,
                        w.week_type = 'academic',
                        w.node_storage_path = $path
                    WITH w MATCH (t:AcademicTerm {uuid_string: $t_id})
                    MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w)
                """,
                    id=week["neo4j_node_id"],
                    num=str(week["week_number"]),
                    start=str(week["start_date"]),
                    cycle=week["week_cycle"],
                    path=f"timetable/{week['neo4j_node_id']}",
                    t_id=term["neo4j_node_id"],
                )
                counts["weeks"] += 1

                for day in days_by_week[week["id"]]:
                    label = DAY_TYPE_LABELS.get(day["day_type"], "AcademicDay")
                    s.run(
                        f"""
                        MERGE (d:{label} {{uuid_string: $id}})
                        SET d.date = date($date), d.day_of_week = $dow,
                            d.day_type = $dtype,
                            d.academic_day = $adn,
                            d.node_storage_path = $path
                        WITH d MATCH (w:AcademicWeek {{uuid_string: $w_id}})
                        MERGE (w)-[:ACADEMIC_WEEK_HAS_ACADEMIC_DAY]->(d)
                        WITH d MATCH (t:AcademicTerm {{uuid_string: $t_id}})
                        MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_DAY]->(d)
                        """,
                        id=day["neo4j_node_id"],
                        date=str(day["date"]),
                        dow=day["day_of_week"],
                        dtype=day["day_type"],
                        adn=str(day.get("academic_day_number") or ""),
                        path=f"timetable/{day['neo4j_node_id']}",
                        w_id=week["neo4j_node_id"],
                        t_id=term["neo4j_node_id"],
                    )
                    counts["days"] += 1

                    # Period nodes for Academic days only
                    if day["day_type"] == "Academic":
                        excluded = set(day.get("excluded_period_codes") or [])
                        for p in periods_template:
                            if p.get("code") in excluded:
                                continue
                            p_label = PERIOD_TYPE_LABELS.get(
                                (p.get("period_type") or "lesson").lower(), "AcademicPeriod"
                            )
                            rel = PERIOD_DAY_RELS.get(p_label, "ACADEMIC_DAY_HAS_ACADEMIC_PERIOD")
                            p_id = f"{day['neo4j_node_id']}_{p['code']}"
                            s.run(
                                f"""
                                MERGE (p:{p_label} {{uuid_string: $id}})
                                SET p.period_code = $code, p.name = $name,
                                    p.start_time = $start, p.end_time = $end,
                                    p.node_storage_path = $path
                                WITH p MATCH (d:{label} {{uuid_string: $d_id}})
                                MERGE (d)-[:{rel}]->(p)
                                """,
                                id=p_id,
                                code=p["code"],
                                name=p["name"],
                                start=p["start_time"],
                                end=p["end_time"],
                                path=f"timetable/{p_id}",
                                d_id=day["neo4j_node_id"],
                            )
                            counts["periods"] += 1

    # ── Teacher timetables + taught lessons (best-effort — may be empty on first run)
    try:
        tt_counts = _sync_teacher_timetables_to_neo4j(institute_id, institute_db, sb)
        counts.update(tt_counts)
    except Exception as e:
        logger.warning(f"TeacherTimetable sync skipped: {e}")
    try:
        counts["taught_lessons"] = _sync_taught_lessons_to_neo4j(institute_id, institute_db, sb)
    except Exception as e:
        logger.warning(f"TaughtLesson sync skipped: {e}")

    return counts


def _sync_teacher_timetables_to_neo4j(
    institute_id: str,
    institute_db: str,
    sb: SupabaseServiceRoleClient,
) -> Dict[str, int]:
    """
    Rebuild TeacherTimetable and TimetableSlot Neo4j nodes from Supabase
    for the given institute. Safe to re-run (MERGE).
    """
    counts: Dict[str, int] = {"teacher_timetables": 0, "slots": 0}

    tt_rows = (
        sb.supabase.table("teacher_timetables")
        .select("id,profile_id,neo4j_node_id,start_date,end_date")
        .eq("institute_id", institute_id)
        .execute()
        .data or []
    )
    if not tt_rows:
        return counts

    profile_ids = list({t["profile_id"] for t in tt_rows if t.get("profile_id")})
    email_map: Dict[str, str] = {}
    if profile_ids:
        prows = (
            sb.supabase.table("profiles")
            .select("id,email")
            .in_("id", profile_ids)
            .execute()
            .data or []
        )
        email_map = {p["id"]: p["email"] for p in prows}

    tt_ids = [t["id"] for t in tt_rows]
    all_slots: Dict[str, List[Dict]] = defaultdict(list)
    for i in range(0, len(tt_ids), 100):
        chunk = tt_ids[i : i + 100]
        slot_rows = (
            sb.supabase.table("teacher_timetable_slots")
            .select("*")
            .in_("teacher_timetable_id", chunk)
            .execute()
            .data or []
        )
        for slot in slot_rows:
            all_slots[slot["teacher_timetable_id"]].append(slot)

    with driver_tools.get_session(database=institute_db) as s:
        for tt in tt_rows:
            tt_neo4j_id = tt.get("neo4j_node_id")
            if not tt_neo4j_id:
                continue

            teacher_email = email_map.get(tt["profile_id"], "")
            teacher_uuid = _query_teacher_uuid(institute_db, teacher_email) if teacher_email else None

            s.run("""
                MERGE (tt:TeacherTimetable {uuid_string: $id})
                SET tt.teacher_timetable_id = $id,
                    tt.start_date = date($start),
                    tt.end_date = date($end),
                    tt.node_storage_path = $path
            """,
                id=tt_neo4j_id,
                start=str(tt["start_date"]),
                end=str(tt["end_date"]),
                path=f"timetable/{tt_neo4j_id}",
            )
            counts["teacher_timetables"] += 1

            if teacher_uuid:
                s.run("""
                    MATCH (t:Teacher {uuid_string: $tu})
                    MATCH (tt:TeacherTimetable {uuid_string: $id})
                    MERGE (t)-[:HAS_TIMETABLE]->(tt)
                """, tu=teacher_uuid, id=tt_neo4j_id)

            for slot in all_slots.get(tt["id"], []):
                slot_neo4j_id = (
                    slot.get("neo4j_node_id")
                    or f"slot_{tt_neo4j_id}_{slot['day_of_week']}_{slot['period_code']}_{slot.get('week_cycle', '')}"
                )
                s.run("""
                    MERGE (sl:TimetableSlot {uuid_string: $id})
                    SET sl.day_of_week = $day, sl.period_code = $code,
                        sl.subject_class = $cls, sl.start_time = $start,
                        sl.end_time = $end, sl.week_cycle = $wc,
                        sl.node_storage_path = $path
                    WITH sl MATCH (tt:TeacherTimetable {uuid_string: $tt_id})
                    MERGE (tt)-[:HAS_TIMETABLE_SLOT]->(sl)
                """,
                    id=slot_neo4j_id,
                    day=slot["day_of_week"],
                    code=slot["period_code"],
                    cls=slot.get("subject_class", ""),
                    start=slot.get("start_time", ""),
                    end=slot.get("end_time", ""),
                    wc=slot.get("week_cycle", ""),
                    path=f"timetable/{slot_neo4j_id}",
                    tt_id=tt_neo4j_id,
                )
                counts["slots"] += 1

    return counts


def _sync_taught_lessons_to_neo4j(
    institute_id: str,
    institute_db: str,
    sb: SupabaseServiceRoleClient,
) -> int:
    """
    Create/update TaughtLesson Neo4j nodes from Supabase taught_lessons.
    Links each node to its AcademicPeriod and TeacherTimetable. Safe to re-run.
    Returns count of lessons merged.
    """
    lessons = (
        sb.supabase.table("taught_lessons")
        .select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status")
        .eq("institute_id", institute_id)
        .execute()
        .data or []
    )
    if not lessons:
        return 0

    # academic_period UUID → neo4j_node_id
    period_ids = list({l["academic_period_id"] for l in lessons if l.get("academic_period_id")})
    period_neo4j_map: Dict[str, str] = {}
    for i in range(0, len(period_ids), 100):
        chunk = period_ids[i : i + 100]
        prows = (
            sb.supabase.table("academic_periods")
            .select("id,neo4j_node_id")
            .in_("id", chunk)
            .execute()
            .data or []
        )
        for p in prows:
            if p.get("neo4j_node_id"):
                period_neo4j_map[p["id"]] = p["neo4j_node_id"]

    # teacher profile_id → teacher_timetable neo4j_node_id
    teacher_ids = list({l["teacher_id"] for l in lessons if l.get("teacher_id")})
    teacher_tt_map: Dict[str, str] = {}
    for i in range(0, len(teacher_ids), 100):
        chunk = teacher_ids[i : i + 100]
        ttrows = (
            sb.supabase.table("teacher_timetables")
            .select("profile_id,neo4j_node_id")
            .eq("institute_id", institute_id)
            .in_("profile_id", chunk)
            .execute()
            .data or []
        )
        for tt in ttrows:
            if tt.get("neo4j_node_id"):
                teacher_tt_map[tt["profile_id"]] = tt["neo4j_node_id"]

    count = 0
    with driver_tools.get_session(database=institute_db) as s:
        for lesson in lessons:
            tl_id = lesson.get("neo4j_node_id") or f"tl_{lesson['id']}"
            ap_id = period_neo4j_map.get(lesson.get("academic_period_id", ""))
            tt_id = teacher_tt_map.get(lesson.get("teacher_id", ""))

            s.run("""
                MERGE (tl:TaughtLesson {uuid_string: $id})
                SET tl.date = date($date), tl.period_code = $pcode,
                    tl.week_cycle = $wc, tl.day_of_week = $dow,
                    tl.status = $status,
                    tl.node_storage_path = $path
            """,
                id=tl_id,
                date=str(lesson["date"]),
                pcode=lesson["period_code"],
                wc=lesson.get("week_cycle", ""),
                dow=lesson.get("day_of_week", ""),
                status=lesson.get("status", "planned"),
                path=f"taught_lessons/{tl_id}",
            )
            count += 1

            if ap_id:
                s.run("""
                    MATCH (ap:AcademicPeriod {uuid_string: $ap_id})
                    MATCH (tl:TaughtLesson {uuid_string: $tl_id})
                    MERGE (ap)-[:ACADEMIC_PERIOD_HAS_TAUGHT_LESSON]->(tl)
                """, ap_id=ap_id, tl_id=tl_id)

            if tt_id:
                s.run("""
                    MATCH (tt:TeacherTimetable {uuid_string: $tt_id})
                    MATCH (tl:TaughtLesson {uuid_string: $tl_id})
                    MERGE (tt)-[:TEACHER_TIMETABLE_HAS_TAUGHT_LESSON]->(tl)
                """, tt_id=tt_id, tl_id=tl_id)

    return count


def _write_neo4j_direct(
    institute_db: str,
    school_tt_id: str,
    ay_id: str,
    body: TimetableSetupRequest,
    year_start: date,
    year_label: str,
    teacher_uuid: Optional[str],
    teacher_tt_id: Optional[str],
) -> None:
    """Fallback: write Neo4j directly when no Supabase institute_id is available."""
    with driver_tools.get_session(database=institute_db) as s:
        s.run("""
            MERGE (tt:SchoolTimetable {uuid_string: $id})
            SET tt.school_timetable_id = $id,
                tt.start_date = date($start), tt.end_date = date($end),
                tt.node_storage_path = $path, tt.periods_template = $periods
            WITH tt MATCH (sch:School) MERGE (sch)-[:HAS_TIMETABLE]->(tt)
        """,
            id=school_tt_id,
            start=body.year_start,
            end=body.year_end,
            path=f"timetable/{school_tt_id}",
            periods=json.dumps([p.dict() for p in body.periods]),
        )
        s.run("""
            MERGE (ay:AcademicYear {uuid_string: $id})
            SET ay.year = $year, ay.node_storage_path = $path
            WITH ay MATCH (tt:SchoolTimetable {uuid_string: $tt_id})
            MERGE (tt)-[:ACADEMIC_TIMETABLE_HAS_ACADEMIC_YEAR]->(ay)
        """,
            id=ay_id, year=year_label, path=f"timetable/{ay_id}", tt_id=school_tt_id,
        )
        for term in body.terms:
            t_id = f"term_{year_start.year}_{term.term_number}"
            t_start = _iso_date(term.start_date)
            t_end = _iso_date(term.end_date)
            s.run("""
                MERGE (t:AcademicTerm {uuid_string: $id})
                SET t.term_name = $name, t.term_number = $num,
                    t.start_date = date($start), t.end_date = date($end),
                    t.node_storage_path = $path
                WITH t MATCH (ay:AcademicYear {uuid_string: $ay_id})
                MERGE (ay)-[:ACADEMIC_YEAR_HAS_ACADEMIC_TERM]->(t)
            """,
                id=t_id, name=term.name, num=str(term.term_number),
                start=term.start_date, end=term.end_date,
                path=f"timetable/{t_id}", ay_id=ay_id,
            )
            for wn, wstart in _academic_weeks(t_start, t_end):
                w_id = f"week_{t_id}_{wn}"
                s.run("""
                    MERGE (w:AcademicWeek {uuid_string: $id})
                    SET w.academic_week_number = $num, w.start_date = date($start),
                        w.week_type = 'academic', w.week_cycle = $cycle,
                        w.node_storage_path = $path
                    WITH w MATCH (t:AcademicTerm {uuid_string: $t_id})
                    MERGE (t)-[:ACADEMIC_TERM_HAS_ACADEMIC_WEEK]->(w)
                """,
                    id=w_id, num=str(wn), start=wstart.isoformat(),
                    cycle="A" if wn % 2 == 1 else "B",
                    path=f"timetable/{w_id}", t_id=t_id,
                )
        if teacher_uuid and teacher_tt_id:
            s.run("""
                MERGE (tt:TeacherTimetable {uuid_string: $id})
                SET tt.teacher_timetable_id = $id, tt.start_date = date($start),
                    tt.end_date = date($end), tt.node_storage_path = $path
                WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid})
                MERGE (teacher)-[:HAS_TIMETABLE]->(tt)
                WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id})
                MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st)
            """,
                id=teacher_tt_id, start=body.year_start, end=body.year_end,
                path=f"timetable/{teacher_tt_id}",
                t_uuid=teacher_uuid, st_id=school_tt_id,
            )


# ─── Endpoints ────────────────────────────────────────────────────────────────

@router.get("/status")
async def get_timetable_status(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    """Check whether the teacher has a timetable set up."""
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    _, institute_db, teacher_uuid = _resolve_institute(user_id, user_email)
    if not institute_db:
        return {"status": "no_school"}
    try:
        with driver_tools.get_session(database=institute_db) as s:
            has_tt = (
                s.run(
                    "MATCH (t:Teacher {uuid_string: $u})-[:HAS_TIMETABLE]->(tt:TeacherTimetable) "
                    "RETURN tt.uuid_string AS id LIMIT 1",
                    u=teacher_uuid,
                ).single()
                if teacher_uuid
                else None
            )
            has_school_tt = s.run(
                "MATCH (st:SchoolTimetable) RETURN st.uuid_string AS id LIMIT 1"
            ).single()
        return {
            "status": "ok",
            "has_teacher_timetable": has_tt is not None,
            "timetable_id": has_tt["id"] if has_tt else None,
            "has_school_timetable": has_school_tt is not None,
            "institute_db": institute_db,
        }
    except Exception as e:
        return {"status": "error", "message": str(e)}


@router.post("/setup")
async def setup_timetable(
    body: TimetableSetupRequest,
    credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
    """
    Create school timetable structure.
    Writes to Supabase first (source of truth), then builds Neo4j from Supabase.
    Accepts optional days[] for AcademicDay nodes; auto-generates Mon–Fri if omitted.
    Accepts optional term_breaks[] and week_cycles[] overrides.
    """
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, teacher_uuid = _resolve_institute(user_id, user_email)

    if not institute_db:
        return {"status": "error", "message": "Institute database not found"}

    year_start = _iso_date(body.year_start)
    year_end = _iso_date(body.year_end)
    year_label = f"{year_start.year}-{year_end.year}"
    school_tt_id = f"school_tt_{year_start.year}_{year_end.year}"
    ay_id = f"academic_year_{year_start.year}_{year_end.year}"
    teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_start.year}" if teacher_uuid else None

    days_by_date: Dict[str, DayInput] = {}
    if body.days:
        for d in body.days:
            days_by_date[d.date] = d

    week_cycles_map: Optional[Dict[Tuple[int, int], str]] = None
    if body.week_cycles:
        week_cycles_map = {(wc.term_number, wc.week_number): wc.cycle for wc in body.week_cycles}

    # ── 1. Supabase writes ────────────────────────────────────────────────────
    stt_sb_id: Optional[str] = None
    if not institute_id:
        logger.warning("No Supabase institute_id — skipping Supabase writes, falling back to direct Neo4j")
    else:
        try:
            sb = _sb()
            stt_sb_id = _sb_upsert_timetable(
                sb, institute_id, year_start, year_end, year_label, school_tt_id, body.periods
            )
            ay_sb_id = _sb_upsert_academic_year(sb, stt_sb_id, institute_id, year_label, ay_id)
            _sb_upsert_terms_weeks_days(
                sb, ay_sb_id, institute_id, body.terms, year_start, days_by_date, week_cycles_map
            )
            if body.term_breaks:
                _sb_upsert_term_breaks(sb, institute_id, stt_sb_id, body.term_breaks)
            if teacher_uuid and teacher_tt_id:
                _sb_upsert_teacher_timetable(
                    sb, user_id, institute_id, stt_sb_id, year_start, year_end, teacher_tt_id
                )
            logger.info(f"Supabase writes complete for institute {institute_id}")
        except Exception as e:
            logger.error(f"Supabase write failed: {e}")
            return {"status": "error", "message": f"Supabase write failed: {e}"}

    # ── 2. Build Neo4j ────────────────────────────────────────────────────────
    if institute_id and stt_sb_id:
        try:
            counts = _build_neo4j_from_supabase(institute_id, institute_db)
            logger.info(f"Neo4j build complete from Supabase: {counts}")
        except Exception as e:
            logger.error(f"Neo4j build from Supabase failed: {e}")
            return {"status": "error", "message": f"Neo4j build failed: {e}"}
    else:
        try:
            _write_neo4j_direct(
                institute_db, school_tt_id, ay_id, body,
                year_start, year_label, teacher_uuid, teacher_tt_id,
            )
        except Exception as e:
            logger.error(f"Neo4j direct write failed: {e}")
            return {"status": "error", "message": str(e)}

    return {
        "status": "ok",
        "timetable_id": teacher_tt_id,
        "school_timetable_id": school_tt_id,
        "institute_db": institute_db,
    }


@router.post("/rebuild-neo4j")
async def rebuild_neo4j(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    """Rebuild all Neo4j timetable nodes from Supabase source of truth."""
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, _ = _resolve_institute(user_id, user_email)
    if not institute_id or not institute_db:
        return {"status": "error", "message": "Could not resolve institute"}
    try:
        counts = _build_neo4j_from_supabase(institute_id, institute_db)
        return {"status": "ok", "rebuilt": counts, "institute_db": institute_db}
    except Exception as e:
        logger.error(f"Neo4j rebuild failed: {e}")
        return {"status": "error", "message": str(e)}


@router.post("/materialize-periods")
async def materialize_periods(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    """
    Materialize academic_periods rows from academic_days × periods_template.
    Creates one row per period per academic day (day_type='Academic'),
    respecting excluded_period_codes per day. Safe to re-run (upserts).
    """
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, _, _ = _resolve_institute(user_id, user_email)
    if not institute_id:
        return {"status": "error", "message": "Could not resolve institute"}

    sb = _sb()

    stt_rows = (
        sb.supabase.table("school_timetables")
        .select("id,periods_template")
        .eq("institute_id", institute_id)
        .limit(1)
        .execute()
        .data or []
    )
    if not stt_rows:
        return {"status": "error", "message": "No school timetable found for this institute"}
    stt = stt_rows[0]
    periods_template: List[Dict] = stt.get("periods_template") or []
    if not periods_template:
        return {"status": "error", "message": "No periods_template defined on school timetable"}

    days = (
        sb.supabase.table("academic_days")
        .select("id,neo4j_node_id,day_type,excluded_period_codes")
        .eq("institute_id", institute_id)
        .eq("day_type", "Academic")
        .execute()
        .data or []
    )

    created = 0
    skipped = 0
    errors = 0
    for day in days:
        excluded = set(day.get("excluded_period_codes") or [])
        for p in periods_template:
            if p.get("code") in excluded:
                skipped += 1
                continue
            p_neo4j_id = f"{day['neo4j_node_id']}_{p['code']}"
            try:
                sb.supabase.table("academic_periods").upsert(
                    {
                        "academic_day_id": day["id"],
                        "institute_id": institute_id,
                        "period_code": p["code"],
                        "period_name": p["name"],
                        "period_type": p.get("period_type", "lesson"),
                        "start_time": p["start_time"],
                        "end_time": p["end_time"],
                        "neo4j_node_id": p_neo4j_id,
                    },
                    on_conflict="academic_day_id,period_code",
                ).execute()
                created += 1
            except Exception as e:
                logger.error(f"Period upsert failed for day {day['id']} period {p['code']}: {e}")
                errors += 1

    logger.info(f"Materialized {created} periods, skipped {skipped}, errors {errors} for institute {institute_id}")
    return {
        "status": "ok",
        "created": created,
        "skipped": skipped,
        "errors": errors,
        "academic_days": len(days),
    }


@router.get("/slots")
async def get_timetable_slots(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, _ = _resolve_institute(user_id, user_email)
    if not institute_db:
        return {"status": "no_school", "slots": [], "periods": []}
    try:
        sb = _sb()
        tt_rows = (
            sb.supabase.table("teacher_timetables")
            .select("id,neo4j_node_id")
            .eq("profile_id", user_id)
            .limit(1)
            .execute()
            .data or []
        )
        if not tt_rows:
            return {"status": "empty", "slots": [], "periods": []}
        tt_id = tt_rows[0]["id"]
        tt_neo4j_id = tt_rows[0].get("neo4j_node_id", "")

        slot_rows = (
            sb.supabase.table("teacher_timetable_slots")
            .select("day_of_week,period_code,subject_class,start_time,end_time,week_cycle")
            .eq("teacher_timetable_id", tt_id)
            .execute()
            .data or []
        )

        st_rows = (
            sb.supabase.table("school_timetables")
            .select("periods_template")
            .eq("institute_id", institute_id)
            .limit(1)
            .execute()
            .data or []
        )
        periods = (st_rows[0].get("periods_template") or []) if st_rows else []

        return {
            "status": "ok",
            "timetable_id": tt_neo4j_id or str(tt_id),
            "slots": slot_rows,
            "periods": periods,
            "institute_db": institute_db,
        }
    except Exception as e:
        logger.error(f"Get timetable slots failed: {e}")
        return {"status": "error", "slots": [], "periods": []}


@router.post("/slots")
async def save_timetable_slots(
    body: SlotsRequest,
    credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, _ = _resolve_institute(user_id, user_email)
    if not institute_db:
        return {"status": "error", "message": "Teacher not linked to school"}

    # ── 1. Supabase write (primary — fail hard if this fails) ────────────────
    sb = _sb()
    tt_sb = (
        sb.supabase.table("teacher_timetables")
        .select("id,neo4j_node_id")
        .eq("profile_id", user_id)
        .limit(1)
        .execute()
    )
    tt_sb_row = (tt_sb.data or [{}])[0]
    tt_sb_id = tt_sb_row.get("id")
    if not tt_sb_id:
        return {"status": "error", "message": "No teacher timetable found — run /timetable/init first"}

    # Resolve class names → class UUIDs (best-effort; None if not found)
    subject_names = list({s.subject_class.strip() for s in body.slots if s.subject_class.strip()})
    class_name_map: Dict[str, str] = {}
    if subject_names and institute_id:
        try:
            classes = (
                sb.supabase.table("classes")
                .select("id,name,class_code")
                .eq("institute_id", institute_id)
                .eq("is_active", True)
                .execute()
                .data or []
            )
            for c in classes:
                if c.get("name"):
                    class_name_map[c["name"]] = c["id"]
                if c.get("class_code"):
                    class_name_map[c["class_code"]] = c["id"]
        except Exception as e:
            logger.warning(f"Class name resolution failed (non-fatal): {e}")

    # Full replace: delete existing then insert
    sb.supabase.table("teacher_timetable_slots").delete().eq(
        "teacher_timetable_id", tt_sb_id
    ).execute()
    slot_rows = [
        {
            "teacher_timetable_id": tt_sb_id,
            "profile_id": user_id,
            "institute_id": institute_id,
            "day_of_week": slot.day_of_week,
            "period_code": slot.period_code,
            "subject_class": slot.subject_class.strip(),
            "start_time": slot.start_time,
            "end_time": slot.end_time,
            "week_cycle": slot.week_cycle,
            "class_id": class_name_map.get(slot.subject_class.strip()),
            "neo4j_node_id": f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}_{slot.week_cycle}",
        }
        for slot in body.slots
        if slot.subject_class.strip()
    ]
    if slot_rows:
        sb.supabase.table("teacher_timetable_slots").insert(slot_rows).execute()
    logger.info(f"Saved {len(slot_rows)} slots to Supabase for timetable {tt_sb_id}")

    # ── 2. Neo4j write ────────────────────────────────────────────────────────
    try:
        with driver_tools.get_session(database=institute_db) as s:
            s.run(
                "MATCH (:TeacherTimetable {uuid_string: $id})-[:HAS_TIMETABLE_SLOT]->(sl) DETACH DELETE sl",
                id=body.timetable_id,
            )
            created = 0
            for slot in body.slots:
                if not slot.subject_class.strip():
                    continue
                slot_id = f"slot_{body.timetable_id}_{slot.day_of_week}_{slot.period_code}_{slot.week_cycle}"
                s.run("""
                    MERGE (sl:TimetableSlot {uuid_string: $id})
                    SET sl.day_of_week = $day, sl.period_code = $code,
                        sl.subject_class = $cls, sl.start_time = $start,
                        sl.end_time = $end, sl.week_cycle = $wc,
                        sl.node_storage_path = $path
                    WITH sl MATCH (tt:TeacherTimetable {uuid_string: $tt_id})
                    MERGE (tt)-[:HAS_TIMETABLE_SLOT]->(sl)
                """,
                    id=slot_id, day=slot.day_of_week, code=slot.period_code,
                    cls=slot.subject_class, start=slot.start_time, end=slot.end_time,
                    wc=slot.week_cycle, path=f"timetable/slots/{slot_id}",
                    tt_id=body.timetable_id,
                )
                created += 1
        return {"status": "ok", "created": created}
    except Exception as e:
        logger.error(f"Save timetable slots failed: {e}")
        return {"status": "error", "message": str(e)}


@router.post("/init")
async def init_teacher_timetable(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    """Create a TeacherTimetable for the current teacher from the existing school calendar."""
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, teacher_uuid = _resolve_institute(user_id, user_email)
    if not institute_db:
        return {"status": "error", "message": "Teacher not linked to a school"}

    try:
        with driver_tools.get_session(database=institute_db) as s:
            st_rec = s.run(
                "MATCH (st:SchoolTimetable) "
                "RETURN st.uuid_string AS id, "
                "       toString(st.start_date) AS start, toString(st.end_date) AS end "
                "LIMIT 1"
            ).single()
            if not st_rec:
                return {"status": "error", "message": "No school calendar set up yet — contact your school admin"}

            school_tt_id = st_rec["id"]
            start_str = str(st_rec["start"])
            end_str = str(st_rec["end"])
            year_str = start_str[:4]
            teacher_tt_id = f"teacher_tt_{teacher_uuid}_{year_str}"

            s.run("""
                MERGE (tt:TeacherTimetable {uuid_string: $id})
                SET tt.teacher_timetable_id = $id, tt.start_date = date($start),
                    tt.end_date = date($end), tt.node_storage_path = $path
                WITH tt MATCH (teacher:Teacher {uuid_string: $t_uuid})
                MERGE (teacher)-[:HAS_TIMETABLE]->(tt)
                WITH tt MATCH (st:SchoolTimetable {uuid_string: $st_id})
                MERGE (tt)-[:TEACHER_TIMETABLE_FOR]->(st)
            """,
                id=teacher_tt_id, start=start_str, end=end_str,
                path=f"timetable/{teacher_tt_id}",
                t_uuid=teacher_uuid, st_id=school_tt_id,
            )

        # Supabase teacher_timetable record (best-effort)
        if institute_id:
            try:
                sb = _sb()
                stt_sb = (
                    sb.supabase.table("school_timetables")
                    .select("id")
                    .eq("institute_id", institute_id)
                    .limit(1)
                    .execute()
                )
                stt_sb_id = (stt_sb.data or [{}])[0].get("id")
                if stt_sb_id:
                    sb.supabase.table("teacher_timetables").upsert(
                        {
                            "profile_id": user_id,
                            "institute_id": institute_id,
                            "school_timetable_id": stt_sb_id,
                            "start_date": start_str,
                            "end_date": end_str,
                            "neo4j_node_id": teacher_tt_id,
                        },
                        on_conflict="profile_id,school_timetable_id",
                    ).execute()
            except Exception as e:
                logger.warning(f"Supabase teacher_timetable upsert (non-fatal): {e}")

        return {"status": "ok", "timetable_id": teacher_tt_id}
    except Exception as e:
        logger.error(f"Teacher timetable init failed: {e}")
        return {"status": "error", "message": str(e)}


@router.post("/sync-lessons")
async def sync_taught_lessons(credentials: dict = Depends(SupabaseBearer())) -> Dict[str, Any]:
    """
    Sync TaughtLesson Neo4j nodes from Supabase for the caller's school.
    Also rebuilds TeacherTimetable and TimetableSlot nodes.
    Safe to re-run (all MERGEs).
    """
    user_id = credentials.get("sub", "")
    user_email = credentials.get("email", "")
    institute_id, institute_db, _ = _resolve_institute(user_id, user_email)
    if not institute_db:
        return {"status": "error", "message": "No school found"}
    try:
        sb = _sb()
        tt_counts = _sync_teacher_timetables_to_neo4j(institute_id, institute_db, sb)
        lesson_count = _sync_taught_lessons_to_neo4j(institute_id, institute_db, sb)
        return {
            "status": "ok",
            "teacher_timetables": tt_counts["teacher_timetables"],
            "slots": tt_counts["slots"],
            "taught_lessons": lesson_count,
        }
    except Exception as e:
        logger.error(f"sync_taught_lessons failed: {e}")
        return {"status": "error", "message": str(e)}
