// data.jsx — the bridge between Informatique.SIS.MobileService and the prototype screens.
//
// The screens were written against hand-authored sample arrays. Rather than rewrite
// them, this file reshapes the live service payloads into exactly those shapes, so a
// screen only has to swap `SAMPLE` for `useLive('courses', SAMPLE)`.
//
// Rules:
//   • every mapper is total — a missing/short payload yields [] or null, never a throw
//   • when the service has nothing to say, screens keep their sample data so the
//     prototype still demonstrates the flow (`live` tells you which you got)
//   • nothing here holds UI state; it is a cache + a set of pure mappers

const SISCtx = React.createContext(null);

// ────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────
const num = v => (v === null || v === undefined || v === '' ? null : Number(v));
const str = v => (v === null || v === undefined ? '' : String(v));
const clean = v => str(v).trim();

// "09:00:00" → "09:00" ; also tolerates "9:00" and full ISO timestamps
function hhmm(v) {
  const s = str(v);
  const m = s.match(/(\d{1,2}):(\d{2})/);
  if (!m) return '';
  return String(m[1]).padStart(2, '0') + ':' + m[2];
}
const minutesOf = hm => {
  const m = str(hm).match(/(\d{1,2}):(\d{2})/);
  return m ? Number(m[1]) * 60 + Number(m[2]) : 0;
};

const AR_MONTHS = ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر'];
function arDate(v) {
  if (!v) return '';
  const d = new Date(v);
  if (isNaN(d)) return '';
  return `${d.getDate()} ${AR_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}

// GS_CODE_WEEK_DAY_ID as used by the SIS schedule tables.
const WEEK_DAYS = [
  { id: 1, ar: 'الجمعة',   short: 'ج',  js: 5 },
  { id: 2, ar: 'السبت',    short: 'س',  js: 6 },
  { id: 3, ar: 'الأحد',    short: 'ح',  js: 0 },
  { id: 4, ar: 'الإثنين',  short: 'ن',  js: 1 },
  { id: 5, ar: 'الثلاثاء', short: 'ث',  js: 2 },
  { id: 6, ar: 'الأربعاء', short: 'ر',  js: 3 },
  { id: 7, ar: 'الخميس',   short: 'خ',  js: 4 },
];
const weekDayIdForToday = () => (WEEK_DAYS.find(d => d.js === new Date().getDay()) || WEEK_DAYS[1]).id;

// Stable per-course accent so the same course keeps its colour across screens.
const COURSE_COLORS = ['#2C5BFF', '#14A06A', '#7E5BEF', '#F5A623', '#E84F5E', '#0E9F8E', '#DB2777', '#4F46E5'];
function courseColor(key) {
  const s = str(key);
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return COURSE_COLORS[h % COURSE_COLORS.length];
}

// ────────────────────────────────────────────────────────────
// mappers: service payload → screen shape
// ────────────────────────────────────────────────────────────

// students/get → the profile block every screen reads names/level/GPA from.
function mapProfile(card, session) {
  if (!card) return null;
  return {
    nameAr: clean(card.FULL_NAME_AR), nameEn: clean(card.FULL_NAME_EN),
    code: clean(card.STUD_FACULTY_CODE),
    facultyAr: clean(card.FACULTY_DESCR_AR), facultyEn: clean(card.FACULTY_DESCR_EN),
    majorAr: clean(card.MAJOR_AR), majorEn: clean(card.MAJOR_EN),
    levelAr: clean(card.LEVEL_AR), levelEn: clean(card.LEVEL_EN),
    degreeAr: clean(card.DEGREE_AR),
    enrollAr: clean(card.ENROLL_AR),
    genderAr: clean(card.GENDER_DESCR_AR),
    nationAr: clean(card.NATION_DESCR_AR),
    nationalId: clean(card.NATIONAL_NUMBER),
    birthDate: arDate(card.BIRTH_DATE),
    mobile: clean(card.STUD_MOBNO),
    email: clean(card.STUD_EMAIL),
    advisorAr: clean(card.ACAD_ADV_AR), advisorEn: clean(card.ACAD_ADV_EN),
    gpa: num(card.ACCUM_GPA),
    semGpa: num(card.SEM_GPA),
    accumCh: num(card.ACCUM_CH),
    requiredCh: num(card.FULLFILLED_CH),
    graduated: card.GRADUATES_FLAG === 1,
    yearAr: session ? session.yearAr : '',
    semAr: session ? session.semAr : '',
  };
}

// students/scheduledays → one entry per timetabled slot, grouped by weekday.
function mapSlots(days) {
  return (days || []).map((d, i) => {
    const start = hhmm(d.FROM_TIME), end = hhmm(d.TO_TIME);
    const kind = clean(d.TEACHING_DESCR_AR) || clean(d.DESCR_AR);
    const room = clean(d.HALL_DESCR_AR) || clean(d.BUILDING_DESCR_AR) || clean(d.CAMPUS_DESCR_AR);
    const instructor = clean(d.STF_FULL_NAME_AR) || clean(d.MASTER_ASSISTANTS) || clean(d.STF_ASS_FULL_NAME_AR);
    const code = clean(d.COURSE_CODE);
    return {
      id: 'slot-' + (d.SC_SCHEDULE_DTL_ID || i) + '-' + i,
      dayId: num(d.GS_CODE_WEEK_DAY_ID),
      dayAr: clean(d.DAY_DESCR_AR),
      start, end,
      startMin: minutesOf(start), endMin: minutesOf(end),
      title: clean(d.COURSE_DESCR_AR) + (kind && kind !== 'محاضرة' ? ' — ' + kind : ''),
      courseTitle: clean(d.COURSE_DESCR_AR),
      code: code + (clean(d.GROUP_DESCR_AR) ? ' · ' + clean(d.GROUP_DESCR_AR) : ''),
      courseCode: code,
      courseId: num(d.ED_COURSE_ID),
      kind, room, instructor,
      capacity: num(d.CAPACITY), enrolled: num(d.NO_STUDENTS),
      color: courseColor(code),
    };
  }).sort((a, b) => (a.dayId - b.dayId) || (a.startMin - b.startMin));
}

// Slots for one weekday, tagged past/current/upcoming against `nowMin`.
function daySessions(slots, dayId, nowMin) {
  return slots
    .filter(s => s.dayId === dayId)
    .map(s => ({
      ...s,
      status: nowMin >= s.endMin ? 'past' : (nowMin >= s.startMin ? 'current' : 'upcoming'),
    }));
}

// students/schedule (+ absence, + result) → the course cards.
// `lms` carries a real per-course completion percentage from Moodle. Before this the progress ring
// on every card showed the SAME number — the term's elapsed percentage — which looked like data and
// was not. Moodle's figure is used when the course can be matched, and the term figure stays as the
// fallback for courses Moodle does not know about.
function mapCourses(schedule, absence, result, termProgress, lms) {
  const byCourse = new Map();
  (schedule || []).forEach(r => {
    const id = num(r.ED_COURSE_ID);
    if (id === null || byCourse.has(id)) return;
    byCourse.set(id, {
      id: 'c' + id, courseId: id,
      code: clean(r.COURSE_CODE),
      title: clean(r.COURSE_DESCR_AR),
      titleEn: clean(r.COURSE_DESCR_EN),
      credits: num(r.CREDIT_HOURS) || 0,
      instructor: '',
      attendance: 100,
      grade: null,
      progress: termProgress,
      progressSource: 'term',
      color: courseColor(clean(r.COURSE_CODE)),
    });
  });

  (absence || []).forEach(a => {
    const c = byCourse.get(num(a.ED_COURSE_ID));
    if (!c) return;
    const pct = num(a.StudAbsPrcnt);
    c.absencePct = pct === null ? 0 : Math.round(pct * 100) / 100;
    c.attendance = Math.max(0, 100 - Math.round(c.absencePct));
    c.absences = num(a.StudAbs) || 0;
    c.warnAt = num(a.ABS_RATE_WARNINIG_1);
    c.failAt = num(a.ABS_RATE_COURSE_FAIL);
  });

  (result || []).forEach(r => {
    const c = byCourse.get(num(r.ED_COURSE_ID));
    if (!c) return;
    c.grade = clean(r.GRADING_AR) || null;
    c.points = num(r.COURSE_POINT);
    c.statusAr = clean(r.STATUS_DESCR_AR);
  });

  // Overlay Moodle's real completion where the course matches by code.
  const out = [...byCourse.values()];
  const byCode = new Map((lms || []).map(m => [String(m.CourseCode || m.courseCode || '').toUpperCase(), m]));
  out.forEach(c => {
    const m = byCode.get(String(c.code || '').toUpperCase());
    const pct = m && (m.CompletionPercent ?? m.completionPercent ?? m.Progress ?? m.progress);
    if (pct != null && !Number.isNaN(Number(pct))) {
      c.progress = Math.max(0, Math.min(1, Number(pct) / 100)); // stored as a fraction, like termProgress
      c.progressSource = 'moodle';
    }
  });
  return out;
}

// students/getabs → the attendance screen rows.
function mapAttendance(absence) {
  return (absence || []).map(a => {
    const pct = Math.round((num(a.StudAbsPrcnt) || 0) * 100) / 100;
    // The service reports a percentage, not a slot count; derive a plausible
    // total so the existing bar rendering keeps working.
    const missed = num(a.StudAbs) || 0;
    const total = pct > 0 ? Math.max(missed, Math.round((missed * 100) / pct)) : Math.max(missed, 12);
    return {
      code: clean(a.COURSE_CODE),
      title: clean(a.COURSE_DESCR_AR),
      missed, total, attended: Math.max(0, total - missed),
      absencePct: pct,
      warnAt: num(a.ABS_RATE_WARNINIG_1) || 15,
      failAt: num(a.ABS_RATE_COURSE_FAIL) || 25,
      dates: (a.absences || []).map(x => ({
        date: arDate(x.ABS_DATE),
        excused: x.EXECUSE_FLG === 1,
        kind: clean(x.Article_ar),
      })),
    };
  });
}

// students/transcript → the semester-result screen (newest first).
function mapSemesters(transcript) {
  const out = [];
  (transcript || []).forEach(y => {
    (y.Semesters || []).forEach(s => {
      const rows = s.Transcripts || [];
      if (!rows.length) return;
      const head = rows[0];
      const term = clean(s.SEMESTER_DESCR_EN).toLowerCase();
      out.push({
        id: `${y.ED_ACAD_YEAR_ID}-${s.ED_STUD_SEMESTER_ID}`,
        year: clean(y.ACAD_YEAR_DESCR_AR) || clean(y.ACAD_YEAR_DESCR_EN),
        term: term || 'term',
        termAr: clean(s.SEMESTER_DESCR_AR),
        termEn: clean(s.SEMESTER_DESCR_EN),
        gpa: num(head.SEM_GPA) || 0,
        cgpa: num(head.ACCUM_GPA) || 0,
        delta: null,
        credits: num(head.SEM_CH) || 0,
        cumulative: num(head.ACCUM_CH) || 0,
        yearOrder: num(head.YEAR_ORDER) || 0,
        semOrder: num(head.SEMESTER_ORDER) || 0,
        approved: head.IS_RESULT_APPROVED === 1,
        // The academic-warning level is carried on the transcript header and had never been shown.
        // The portal shows the result whether or not it is approved (its approval filter is
        // commented out); only the OFFICIAL transcript is gated. So the flag is a label here,
        // not a reason to hide the semester.
        warning: clean(head.ACAD_WARN_TYPE_DESCR_AR) || clean(head.ACAD_WARN_TYPE_DESCR_EN) || null,
        rows: rows.map(r => ({
          code: clean(r.COURSE_CODE),
          title: clean(r.COURSE_DESCR_AR),
          grade: clean(r.GRADING_AR) || clean(r.Symbol_AR) || '—',
          pts: num(r.COURSE_POINT) || 0,
          ch: num(r.CREDIT_HOURS) || 0,
          degree: num(r.COURSE_DEGREE),
          passed: r.GS_CODE_PASS_FAIL_ID === 1,
        })),
      });
    });
  });

  out.sort((a, b) => (b.yearOrder - a.yearOrder) || (b.semOrder - a.semOrder));
  // GPA delta against the chronologically previous term.
  for (let i = 0; i < out.length - 1; i++) {
    const d = out[i].gpa - out[i + 1].gpa;
    if (d) out[i].delta = (d > 0 ? '+' : '') + d.toFixed(2);
  }
  if (out.length) out[0].current = true;
  return out;
}

// students/AcadPlan → study-plan packages.
function mapStudyPlan(plan) {
  return (plan || []).map((p, i) => {
    const courses = (p.Courses || []).map(c => ({
      code: clean(c.COURSE_CODE),
      title: clean(c.COURSE_DESCR_AR),
      credits: num(c.CREDIT_HOURS) || 0,
      grade: clean(c.Course_Grading_AR) || null,
      done: !!clean(c.Course_Grading_AR),
    }));
    const required = num(p.MIN_TOT_CH) || courses.reduce((s, c) => s + c.credits, 0);
    const passed = num(p.PASSED_HOURS) || 0;
    return {
      id: 'pkg' + i,
      title: clean(p.PKG_HDR_TITLE_AR),
      titleEn: clean(p.PKG_HDR_TITLE_EN),
      kind: clean(p.PKG_TYPE_AR),
      required, passed,
      remaining: Math.max(0, required - passed),
      progress: required ? Math.min(1, passed / required) : 0,
      courses,
      color: COURSE_COLORS[i % COURSE_COLORS.length],
    };
  });
}

// students/GetTotalFeesInfo (+ students/fees) → the payments screen.
function mapFees(info, byCurrency) {
  if (!info) return null;
  const s = info.FeesSummary || {};
  const bucket = (b, labelAr, kind) => ({
    kind,
    labelAr,
    total: num((b || {}).Total) || 0,
    paid: num((b || {}).Paid) || 0,
    remain: num((b || {}).Remain) || 0,
    discount: num((b || {}).Discount) || 0,
  });
  const buckets = [
    bucket(s.SummaryStudyFees, 'المصروفات الدراسية', 'study'),
    bucket(s.SummaryOtherFees, 'رسوم أخرى', 'other'),
    bucket(s.SummaryFineFees, 'غرامات', 'fine'),
  ];

  const charges = [];
  const push = (arr, kind, labelAr) => (arr || []).forEach((f, i) => {
    const amount = num(f.Remain) !== null ? num(f.Remain) : num(f.Total);
    charges.push({
      id: `${kind}-${i}`,
      code: clean(f.ITEM_CODE) || clean(f.CODE) || kind.toUpperCase(),
      title: clean(f.ITEM_DESCR_AR) || clean(f.DESCR_AR) || labelAr,
      amount: amount || 0,
      due: arDate(f.DUE_DATE || f.TO_DATE),
      kind,
    });
  });
  push(info.StudyFees, 'study', 'مصروفات دراسية');
  push(info.OtherFees, 'other', 'رسوم أخرى');
  push(info.FineFees, 'fine', 'غرامة');

  return {
    balance: num(info.AvailableBalance) || 0,
    totalRemains: num(info.TotalRemains) || 0,
    buckets,
    charges,
    currency: (byCurrency && byCurrency[0] && clean(byCurrency[0].CURRENCY_CODE)) || 'EGP',
    byCurrency: (byCurrency || []).map(c => ({
      currency: clean(c.CURRENCY_CODE),
      total: num(c.Total) || 0, paid: num(c.Paid) || 0,
      remain: num(c.Remain) || 0, discount: num(c.Disc_Amount) || 0,
    })),
    totals: buckets.reduce((acc, b) => ({
      total: acc.total + b.total, paid: acc.paid + b.paid,
      remain: acc.remain + b.remain, discount: acc.discount + b.discount,
    }), { total: 0, paid: 0, remain: 0, discount: 0 }),
  };
}

// students/GetMobileNotifications → the notification list.
function mapNotifications(rows) {
  return (rows || []).map((n, i) => ({
    id: n.SV_USER_MESSAGE_ID || i,
    title: clean(n.MSG_SUBJECT) || 'إشعار',
    body: clean(n.MSG_CONTENT),
    date: arDate(n.MSG_DATE),
    ts: n.MSG_DATE,
    read: n.OPENED_FLG === 1 || n.OPENED_FLG === true,
  }));
}

// getInstructorsAndAdvisorForAsk (+ inbox) → chat threads.
function mapChats(contacts, inbox) {
  if (!contacts) return [];
  const threads = [];
  const adv = contacts.AcademicAdvisior;
  if (adv && num(adv.SA_STF_MEMBER_ID) !== null) {
    threads.push({
      id: 'adv-' + adv.SA_STF_MEMBER_ID,
      staffId: num(adv.SA_STF_MEMBER_ID),
      advisor: true,
      name: clean(adv.STF_FULL_NAME_AR),
      role: 'المرشد الأكاديمي',
      msg: 'ابدأ محادثة مع مرشدك الأكاديمي',
      time: '', unread: 0, pinned: true, messages: [],
    });
  }
  const seen = new Set();
  (contacts.Instructors || []).forEach(ins => {
    const id = num(ins.SA_STF_MEMBER_ID);
    if (id === null || seen.has(id)) return;
    seen.add(id);
    const courses = (contacts.Instructors || [])
      .filter(x => num(x.SA_STF_MEMBER_ID) === id)
      .map(x => clean(x.CourseCode))
      .filter(Boolean);
    threads.push({
      id: 'ins-' + id,
      staffId: id,
      advisor: false,
      name: clean(ins.STF_FULL_NAME_AR),
      role: courses.length ? courses.join(' · ') : 'عضو هيئة تدريس',
      msg: clean(ins.CourseAr),
      time: '', unread: 0, messages: [],
    });
  });

  // Fold in unread counts / last message from the mail inbox when present.
  (inbox || []).forEach(m => {
    const id = num(m.SA_STF_MEMBER_ID);
    const th = threads.find(x => x.staffId === id);
    if (!th) return;
    if (clean(m.RE_DTL)) th.msg = clean(m.RE_DTL);
    if (m.RE_DATE) th.time = arDate(m.RE_DATE);
    if (num(m.UnReadCount)) th.unread = num(m.UnReadCount);
  });

  return threads;
}

// GetaskById → messages inside one thread.
function mapThread(rows, myStudId) {
  return (rows || []).map((r, i) => ({
    id: i,
    from: (r.FROM_STUD === true || r.FROM_STUD === 1 || num(r.ED_STUD_ID) === myStudId) ? 'me' : 'them',
    text: clean(r.RE_DTL),
    t: arDate(r.RE_DATE),
  }));
}

// Faculties/CoursesCatalogNew → the catalog / add-drop pool.
function mapCatalog(rows) {
  return (rows || []).map((c, i) => ({
    id: 'cat' + (c.ED_COURSE_ID || i),
    courseId: num(c.ED_COURSE_ID),
    code: clean(c.COURSE_CODE),
    title: clean(c.COURSE_DESCR_AR),
    titleEn: clean(c.COURSE_DESCR_EN),
    credits: num(c.CREDIT_HOURS) || 0,
    contents: clean(c.COURSE_CONTENTS_AR),
    fees: num(c.Fees) || 0,
    color: courseColor(clean(c.COURSE_CODE)),
  }));
}

// students/GetExamSchdule → the exam list.
function mapExams(rows) {
  return (rows || []).map((e, i) => ({
    id: 'ex' + i,
    code: clean(e.COURSE_CODE),
    title: clean(e.COURSE_DESCR_AR),
    date: arDate(e.EXAM_DATE || e.EXAM_DAY),
    rawDate: e.EXAM_DATE || e.EXAM_DAY,
    from: hhmm(e.FROM_TIME), to: hhmm(e.TO_TIME),
    hall: clean(e.HALL_DESCR_AR) || clean(e.BUILDING_DESCR_AR),
    seat: clean(e.SEAT_NO),
  })).sort((a, b) => new Date(a.rawDate || 0) - new Date(b.rawDate || 0));
}

// EdSemesterOpen/GetAll → how far through the term we are (drives progress bars).
function termProgressFrom(openSemesters, session) {
  const rows = openSemesters || [];
  if (!rows.length || !session) return 0.5;
  const now = Date.now();
  let best = null;
  rows.forEach(r => {
    const from = r.FROM_DATE && new Date(r.FROM_DATE).getTime();
    const to = r.TO_DATE && new Date(r.TO_DATE).getTime();
    if (!from || !to || to <= from) return;
    if (now >= from && now <= to) best = { from, to };
    else if (!best) best = best || null;
  });
  if (!best) {
    // Outside every published window: fall back to the most recent one that ended.
    const past = rows
      .map(r => ({ from: r.FROM_DATE && new Date(r.FROM_DATE).getTime(), to: r.TO_DATE && new Date(r.TO_DATE).getTime() }))
      .filter(x => x.from && x.to && x.to <= now)
      .sort((a, b) => b.to - a.to)[0];
    if (past) return 1;
    return 0.5;
  }
  return Math.max(0, Math.min(1, (now - best.from) / (best.to - best.from)));
}

// Info/Capabilities → a plain { featureKey: bool } lookup plus the provider names.
function mapCapabilities(caps) {
  if (!caps) return null;
  const features = {};
  (caps.features || []).forEach(f => { features[f.key] = !!f.available; });
  return {
    client: caps.client,
    payments: caps.payments || {},
    meetings: caps.meetings,
    lms: caps.lms,
    // Which integrations the service is faking. Empty on a real installation — the app must be able
    // to tell the two apart, because otherwise a demo build looks exactly like a live one.
    sandbox: caps.sandbox || [],
    features,
    // Blocked features are reported so the Tweaks panel can explain a hidden button.
    blocked: (caps.features || []).filter(f => f.status === 'Blocked').map(f => f.key),
  };
}

// ────────────────────────────────────────────────────────────
// provider
// ────────────────────────────────────────────────────────────
function SISProvider({ children }) {
  const [state, setState] = React.useState({
    status: 'idle',      // idle | loading | ready | error
    error: null,
    session: api.getSession(),
    data: {},
  });

  const load = React.useCallback(async (session) => {
    setState(s => ({ ...s, status: 'loading', error: null, session }));

    const S = api.soft;
    // A staff session has no ED_STUD_ID, so every student read would 400. Decide the role once,
    // up front, and only ask for what this role actually has — the alternative is a burst of
    // failed requests on every staff login that looks like a broken service in the console.
    const isStaff = session && session.role === 'staff';
    // Takes a THUNK, not a promise: SS(() => api.x(), fb) would already have fired the request before
    // SS could decide not to. That is exactly the bug this replaced — a staff login produced a
    // burst of 400s from student endpoints whose results were then discarded.
    const SS = (fn, fb) => (isStaff ? Promise.resolve(fb) : S(fn(), fb));
    // One round of parallel reads — the screens can render as soon as this lands.
    const [
      card, schedule, scheduleDays, semResult, transcript, plan, absence,
      feesInfo, feesByCurrency, notifications, contacts, inbox, catalog,
      exams, openSemesters, surveys, capabilities, payMethods, lmsCourses,
      violations, clearance, enrollChanges, officeHours, complaints, appointments,
      myRecords, campusReqs, housing, courseWithdrawals, contactMethods,
    ] = await Promise.all([
      SS(() => api.studentCard()), SS(() => api.schedule(), []), SS(() => api.scheduleDays(), []),
      SS(() => api.semesterResult(), []), SS(() => api.transcript(), []), SS(() => api.academicPlan(), []),
      SS(() => api.absence(), []), SS(() => api.feesSummary()), SS(() => api.fees(), []),
      SS(() => api.notifications(), []), SS(() => api.contacts()), SS(() => api.mailInbox(), []),
      SS(() => api.courseCatalog(), []), SS(() => api.examSchedule(), []), S(api.openSemesters(), []),
      SS(() => api.surveyCourses(), []), S(api.capabilities()), SS(() => api.paymentMethods(), []),
      SS(() => api.lmsCourses()),
      // Student-affairs reads. Every one is behind a FeatureGate, so a client with the
      // feature off answers "not available" — api.soft turns that into the fallback and
      // the screen quietly shows nothing rather than an error.
      SS(() => api.violations()), SS(() => api.clearanceRequests()), SS(() => api.enrollmentChanges()),
      SS(() => api.officeHours()), SS(() => api.complaints()), SS(() => api.appointmentSlots()),
      SS(() => api.myRecords()), SS(() => api.campusRequests()),
      SS(() => api.housingRequests()), SS(() => api.courseWithdrawals()), SS(() => api.contactMethods()),
    ]);

    // Staff-only reads, skipped for a student session rather than fetched and thrown away.
    const [staffProfile, advisees, staffCourses, gradeSheet] = isStaff
      ? await Promise.all([S(api.staffProfile()), S(api.supervisedStudents(), []),
                           S(api.staffCourses(), []), S(api.gradeEntrySheet())])
      : [null, [], [], null];

    const termProgress = termProgressFrom(openSemesters, session);
    const slots = mapSlots(scheduleDays);

    setState({
      status: 'ready',
      error: null,
      session,
      data: {
        profile: mapProfile(card, session),
        slots,
        courses: mapCourses(schedule, absence, semResult, termProgress,
                            lmsCourses && lmsCourses.matched ? (lmsCourses.data || []) : []),
        attendance: mapAttendance(absence),
        semesters: mapSemesters(transcript),
        studyPlan: mapStudyPlan(plan),
        fees: mapFees(feesInfo, feesByCurrency),
        notifications: mapNotifications(notifications),
        chats: mapChats(contacts, inbox),
        catalog: mapCatalog(catalog),
        exams: mapExams(exams),
        surveys: (surveys || []).map(s => ({
          courseId: num(s.ED_COURSE_ID),
          code: clean(s.COURSE_CODE),
          title: clean(s.COURSE_DESCR_AR),
          mandatory: s.REG_FLG === 1,
        })),
        violations: mapViolations(violations),
        clearance: (clearance && clearance.requests) || [],
        enrollmentChanges: (enrollChanges && enrollChanges.requests) || [],
        enrollOptions: enrollChanges
          ? { types: enrollChanges.enrollTypes || [], reasons: enrollChanges.reasons || [],
              current: enrollChanges.current || null, hasPending: !!enrollChanges.hasPendingRequest,
              maxPostpone: enrollChanges.maxPostponeSemesters || null }
          : { types: [], reasons: [], current: null, hasPending: false, maxPostpone: null },
        officeHours: (officeHours && officeHours.staff) || [],
        complaints: mapComplaints(complaints),
        appointments: appointments || { activities: [], days: [], myBooking: null },
        myRecords: myRecords || { activities: [], training: [], lectureNotes: [] },
        campusRequests: campusReqs || { lockers: [], carStickers: [], feeDiscounts: [], buildings: [], carColours: [] },
        housing: housing || { requests: [], roomTypes: [], windowOpen: false, windowMessage: null },
        courseWithdrawals: courseWithdrawals || { requests: [], eligibleCourses: [], reasons: [] },
        contactMethods: (contactMethods && contactMethods.methods) || [],
        contactTypes: (contactMethods && contactMethods.types) || [],
        // call() already unwraps the envelope's single payload key, so this is the row array
        // itself — not { data: [...] }. Reading .data here silently produced null.
        staffProfile: Array.isArray(staffProfile) ? (staffProfile[0] || null) : (staffProfile || null),
        advisees, staffCourses, gradeSheet,
        sisRequests: mapSisRequests(clearance, enrollChanges, campusReqs, housing, courseWithdrawals),
        termProgress,
        capabilities: mapCapabilities(capabilities),
        // Lifted to the top level so screens can read it with the usual useLive('sandbox') idiom
        // — the badge that tells a tester this is a fake gateway must not be easy to miss.
        sandbox: (capabilities && capabilities.sandbox) || [],
        paymentMethods: (payMethods || []).map(m => ({
          key: m.Key, channel: m.Channel,
          label: m.DescrAr, sub: m.DescrEn,
          needsMobile: !!m.RequiresMobileNumber,
          redirects: !!m.Redirects,
        })),
        lms: lmsCourses && lmsCourses.matched ? (lmsCourses.data || []) : [],
        raw: { card, schedule, scheduleDays, semResult, transcript, plan, absence, feesInfo, contacts },
      },
    });
  }, []);

  const signIn = React.useCallback(async (userName, password) => {
    // No role argument: auth/login answers with IsStudent and that is what decides the flow.
    const session = await api.login(userName, password);
    // …then auth/me says which screens this person may actually open. Storing it on the session
    // keeps the permission answer in one place instead of scattered across the screens.
    try {
      const me = await api.me();
      if (me) {
        session.allowedScreens = me.allowedScreens || null;
        session.screens = me.screens || null;
        session.home = me.home || null;
        api.setSession(session);
      }
    } catch (e) { /* older service: fall back to the built-in map */ }
    await load(session);
    return session;
  }, [load]);

  const signOut = React.useCallback(() => {
    api.logout();
    setState({ status: 'idle', error: null, session: null, data: {} });
  }, []);

  const value = React.useMemo(() => ({
    ...state,
    live: state.status === 'ready',
    signIn, signOut,
    reload: () => state.session && load(state.session),
  }), [state, signIn, signOut, load]);

  return <SISCtx.Provider value={value}>{children}</SISCtx.Provider>;
}

function useSIS() {
  return React.useContext(SISCtx) || { status: 'idle', live: false, data: {}, session: null };
}

// The one call screens make: live value when the service supplied one, otherwise
// the screen's own sample data so the prototype never renders an empty shell.
// ── Student affairs ──────────────────────────────────────────────────────────
// Appeals are shown, never offered: in the SIS menu a student holds only the
// violations view and its report, while lodging an appeal is a Dean of Student
// Affairs / Registration Admin form. See the tutorial chapter "الـ SIS مصدر الحقيقة".
function mapViolations(res) {
  return ((res && res.violations) || []).map(v => ({
    id: num(v.ED_STUD_VIOL_ID),
    date: clean(v.VIOL_DATE),
    title: clean(v.ViolDescAr) || clean(v.ViolDescrEn),
    category: clean(v.CDESCRAR) || clean(v.CDESCREN),
    penalty: clean(v.PenaltyDescAr) || clean(v.PenaltyDescEn),
    executed: !!v.executed,
    dropped: !!v.dropped,
    hasAppeal: !!v.hasAppeal,
    appeals: v.appeals || [],
  }));
}

function mapComplaints(res) {
  return {
    mine: ((res && res.complaints) || []).map(c => ({
      id: num(c.SV_STUD_COMP_ID),
      date: clean(c.COMP_DATE),
      subject: clean(c.SUBJECT_AR),
      type: clean(c.TYPE_AR),
      status: clean(c.STATUS_AR),
      detail: clean(c.COMP_DTL),
      answered: !!c.answered,
      closed: !!c.closed,
      reply: clean(c.REP_TMPLT_AR) || clean(c.COMP_COMMENTS),
    })),
    subjects: (res && res.subjects) || [],
    types: (res && res.types) || [],
  };
}

// The prototype's Requests screen was designed around a document catalog (transcript,
// enrolment certificate, ...). The SIS "My Requests" module is a different set — clearance,
// locker, car sticker, fee discount, enrolment change — and the portal has no handler for the
// prototype's catalog. So "my requests" is fed from the real forms; the catalog stays as a
// front-end demo of a flow this SIS does not implement.
function mapSisRequests(clearance, enrollChanges, campus, housing, withdrawals) {
  const out = [];
  const line = (done, active) => ({ done, active });
  const push = (id, typeId, titleAr, status, date, steps) => out.push({
    id, typeId, sis: true, titleAr, status, date: clean(date).slice(0, 10),
    copies: 1, delivery: 'pickup', step: steps.filter(s => s.done).length - 1,
    timeline: steps,
  });

  ((clearance && clearance.requests) || []).forEach(r => {
    const total = r.itemsTotal || 0, ok = r.itemsConfirmed || 0;
    push('CLR-' + num(r.ED_STUD_CLRNC_REQ_ID), 'clearance', 'إخلاء طرف',
      total && ok >= total ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(ok > 0, ok > 0 && ok < total), line(total > 0 && ok >= total)]);
  });

  ((enrollChanges && enrollChanges.requests) || []).forEach(r => {
    push('ENR-' + num(r.ED_STUD_ENROLL_CHANGE_ID), 'enroll',
      clean(r.NEW_STATUS_AR) || 'تغيير قيد',
      r.status === 'Approved' ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(true, r.status !== 'Approved'), line(r.status === 'Approved')]);
  });

  ((campus && campus.lockers) || []).forEach(r => {
    const issued = !!clean(r.KEY_NO);
    push('LCK-' + num(r.SV_STUD_LOCKER_REQ_ID), 'locker', 'طلب خزانة',
      issued ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(true, !issued), line(issued)]);
  });

  ((campus && campus.carStickers) || []).forEach(r => {
    const printed = num(r.PRNT_FLG) === 1;
    push('CAR-' + num(r.TRNS_STUD_CAR_STKR_ID), 'carSticker',
      'ملصق سيارة — ' + clean(r.CAR_NO),
      printed ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(true, !printed), line(printed)]);
  });

  ((housing && housing.requests) || []).forEach(r => {
    const approved = !!clean(r.APPROVE_DATE);
    push('HSG-' + num(r.ACCOM_STUD_REQ_ID), 'housing',
      'طلب سكن — ' + (clean(r.ROOM_TYPE_AR) || 'غرفة'),
      approved ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(true, !approved), line(approved)]);
  });

  // The registration status only moves on the dean's approval, so a request that is still
  // unapproved is genuinely mid-flight rather than refused.
  ((withdrawals && withdrawals.requests) || []).forEach(r => {
    push('WDR-' + num(r.ED_STUD_COURSE_CHNG_ID), 'courseWithdraw',
      clean(r.CHANGE_AR) + ' — ' + clean(r.COURSE_CODE),
      r.approved ? 'done' : 'processing', r.REQ_DATE,
      [line(true), line(true, !r.approved), line(!!r.approved)]);
  });

  ((campus && campus.feeDiscounts) || []).forEach(r => {
    push('DSC-' + num(r.FEE_STUD_DISC_ID), 'discount',
      'طلب خصم — ' + clean(r.TYPE_AR),
      clean(r.APPROVE_DATE) ? 'done' : 'processing', r.DISC_REQ_DATE,
      [line(true), line(true, !clean(r.APPROVE_DATE)), line(!!clean(r.APPROVE_DATE))]);
  });

  return out;
}

function useLive(key, sample) {
  const { data } = useSIS();
  const v = data ? data[key] : undefined;
  if (v === undefined || v === null) return sample;
  if (Array.isArray(v) && v.length === 0) return sample;
  return v;
}

// True when `key` came from the service (screens use it to hide "sample" affordances).
function useIsLive(key) {
  const { data, live } = useSIS();
  if (!live || !data) return false;
  const v = data[key];
  return !(v === undefined || v === null || (Array.isArray(v) && v.length === 0));
}

// Current-day view model shared by Home and Schedule.
function useToday() {
  const { data } = useSIS();
  const slots = (data && data.slots) || [];
  const now = new Date();
  const realNowMin = now.getHours() * 60 + now.getMinutes();
  const todayId = weekDayIdForToday();

  const daysWithClasses = [...new Set(slots.map(s => s.dayId))].sort((a, b) => a - b);
  const hasToday = daysWithClasses.includes(todayId);

  // No classes today (weekend / holiday): preview the next teaching day and put the
  // clock inside it so the timeline still demonstrates the live-session state.
  const dayId = hasToday ? todayId : (daysWithClasses.find(d => d > todayId) ?? daysWithClasses[0] ?? todayId);
  const daySlots = slots.filter(s => s.dayId === dayId);
  const nowMin = hasToday
    ? realNowMin
    : (daySlots.length ? Math.round((daySlots[0].startMin + daySlots[0].endMin) / 2) : realNowMin);

  return {
    dayId, isToday: hasToday, nowMin,
    dayAr: (WEEK_DAYS.find(d => d.id === dayId) || {}).ar || '',
    sessions: daySessions(slots, dayId, nowMin),
    daysWithClasses,
  };
}

// Is this feature usable on the installation we are talking to?
// Unknown (no capabilities yet) counts as available so the prototype still demonstrates the flow.
function useCan(featureKey) {
  const { data } = useSIS();
  const caps = data && data.capabilities;
  if (!caps) return true;
  return caps.features[featureKey] !== false;
}

Object.assign(window, {
  SISProvider, useSIS, useLive, useIsLive, useToday, useCan, mapCapabilities,
  WEEK_DAYS, weekDayIdForToday, daySessions, courseColor, arDate, hhmm, minutesOf,
});
