/* ============================================================
   Fuse — core: API client, helpers, theme, icons, shared UI
   Loaded first; every later file builds on what is declared here.
   ============================================================ */
const {useState, useMemo, useEffect, useRef, useCallback} = React;

/* ---------------------------------------------------------------- API */

const API = {
  async call(method, path, body, opts = {}) {
    const headers = {'X-Requested-With': 'fuse'};
    let payload;
    if (opts.raw) {
      payload = body;
      headers['Content-Type'] = 'application/octet-stream';
    } else if (body !== undefined) {
      payload = JSON.stringify(body);
      headers['Content-Type'] = 'application/json';
    }
    const res = await fetch(path, {method, headers, body: payload, credentials: 'same-origin'});
    let data = null;
    try { data = await res.json(); } catch { /* empty body */ }
    if (!res.ok) {
      const err = new Error((data && data.error) || `Request failed (${res.status})`);
      err.status = res.status;
      throw err;
    }
    return data;
  },
  get:  (p) => API.call('GET', p),
  post: (p, b) => API.call('POST', p, b),
  put:  (p, b) => API.call('PUT', p, b),
  del:  (p) => API.call('DELETE', p)
};

/** Upload raw bytes with progress. Used for attachments and profile images. */
function uploadFile(url, file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    xhr.setRequestHeader('X-Requested-With', 'fuse');
    xhr.setRequestHeader('Content-Type', 'application/octet-stream');
    if (onProgress) {
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
      };
    }
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        let body = null;
        try { body = JSON.parse(xhr.responseText); } catch { /* no body */ }
        resolve(body);
        return;
      }
      let msg = `Upload failed (${xhr.status})`;
      try { msg = JSON.parse(xhr.responseText).error || msg; } catch { /* keep default */ }
      reject(new Error(msg));
    };
    xhr.onerror = () => reject(new Error('Upload failed'));
    xhr.send(file);
  });
}

/* ------------------------------------------------------------ constants */

const OWNER_ROLES = [
  {key:'executive', label:'Executive Owner'},
  {key:'business',  label:'Business Owner'},
  {key:'technical', label:'Technical Owner'},
  {key:'sysadmin',  label:'System Administrator'}
];
const STATUSES = ['Active','Inactive','Archived'];
const TIERS = [1,2,3,4];
const TIER_LABEL = {1:'Business critical',2:'Business important',3:'Business supporting',4:'Low impact'};
const CRITICALITIES = ['Hard','Soft'];
const RACI_LETTERS = [
  {k:'R', name:'Responsible', hint:'Does the work'},
  {k:'A', name:'Accountable', hint:'Answerable for the outcome, one per activity'},
  {k:'C', name:'Consulted',   hint:'Asked for input before it happens'},
  {k:'I', name:'Informed',    hint:'Told after the fact'}
];
const ROLE_LABEL = {admin:'Administrator', editor:'Editor', viewer:'Viewer'};
const REQUEST_STATUS_TONE = {
  'New':'slate', 'Triaged':'blue', 'In progress':'accent', 'Blocked':'red',
  'In review':'purple', 'Done':'green', 'Rejected':'slate'
};
const DOC_STATUS_TONE = {'Draft':'slate','Current':'green','Under review':'amber','Retired':'slate'};
const CHANGE_STATUS_TONE = {
  'Draft':'slate', 'Assessment':'blue', 'Awaiting approval':'amber', 'Approved':'green',
  'Scheduled':'accent', 'In progress':'purple', 'Implemented':'blue', 'Under review':'amber',
  'Closed':'green', 'Rejected':'red', 'Cancelled':'slate', 'Failed':'red'
};
const TOOL_STATUS_TONE = {'Active':'green','Deprecated':'amber','Retired':'slate'};

/* -------------------------------------------------------------- helpers */

const clone = (o) => JSON.parse(JSON.stringify(o));
const byName = (a,b) => a.name.localeCompare(b.name);
const initials = (n) => (n||'?').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase();
const AV_COLORS = ['#4f46e5','#0f8a5f','#c0392b','#a86a00','#6d3fc4','#0e7490','#b83280','#3b6ea5','#7c6f2e','#4a5768'];
const avatarColor = (s) => {
  let h = 0; for (let i=0;i<(s||'').length;i++) h = (h*31 + s.charCodeAt(i)) >>> 0;
  return AV_COLORS[h % AV_COLORS.length];
};
const fmtDate = (d) => {
  if (!d) return '';
  const dt = new Date(String(d).length <= 10 ? d + 'T00:00:00' : d);
  if (isNaN(dt)) return d;
  return dt.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'});
};
const fmtDateTime = (d) => {
  if (!d) return '';
  const dt = new Date(d);
  if (isNaN(dt)) return d;
  return dt.toLocaleString('en-GB',{day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
};
const relative = (iso) => {
  if (!iso) return '';
  const diff = Date.now() - new Date(iso).getTime();
  if (isNaN(diff)) return '';
  const mins = Math.round(diff/60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins} min ago`;
  const hrs = Math.round(mins/60);
  if (hrs < 24) return `${hrs} hr ago`;
  const days = Math.round(hrs/24);
  if (days < 31) return `${days} day${days===1?'':'s'} ago`;
  return fmtDate(iso);
};
const daysUntil = (d) => {
  if (!d) return null;
  const dt = new Date(d + 'T00:00:00');
  if (isNaN(dt)) return null;
  return Math.round((dt.getTime() - new Date().setHours(0,0,0,0)) / 86400000);
};
const monthsSince = (d) => {
  if (!d) return null;
  const dt = new Date(d + 'T00:00:00'); if (isNaN(dt)) return null;
  return Math.floor((Date.now() - dt.getTime()) / (1000*60*60*24*30.44));
};
const isOverdue = (d) => { const n = daysUntil(d); return n != null && n < 0; };
const todayIso = () => new Date().toISOString().slice(0,10);
const statusTone = (s) => s === 'Active' ? 'green' : s === 'Inactive' ? 'amber' : 'slate';
const fmtBytes = (n) => {
  if (!n) return '0 B';
  const u = ['B','KB','MB','GB'];
  const i = Math.min(u.length-1, Math.floor(Math.log(n)/Math.log(1024)));
  return `${(n/Math.pow(1024,i)).toFixed(i ? 1 : 0)} ${u[i]}`;
};
const download = (name, text, mime) => {
  const blob = new Blob([text], {type: mime || 'application/json'});
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = name; document.body.appendChild(a); a.click();
  document.body.removeChild(a); setTimeout(()=>URL.revokeObjectURL(url), 1000);
};
const csvEscape = (v) => {
  const s = v == null ? '' : String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
const exportCsv = (filename, header, rows) =>
  download(filename, [header.join(','), ...rows.map(r => r.map(csvEscape).join(','))].join('\n'), 'text/csv');

/** RFC4180-ish parser: quoted fields, embedded commas/newlines, "" escaping. */
function parseCsv(text){
  const rows = [];
  let row = [], field = '', inQuotes = false;
  const s = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  const pushField = () => { row.push(field); field = ''; };
  const pushRow = () => { pushField(); rows.push(row); row = []; };
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (inQuotes) {
      if (c === '"') {
        if (s[i+1] === '"') { field += '"'; i++; } else { inQuotes = false; }
      } else field += c;
      continue;
    }
    if (c === '"') inQuotes = true;
    else if (c === ',') pushField();
    else if (c === '\n') pushRow();
    else field += c;
  }
  if (field.length || row.length) pushRow();
  const filtered = rows.filter(r => !(r.length === 1 && r[0] === ''));
  if (!filtered.length) return {headers: [], rows: []};
  return {headers: filtered[0].map(h => h.trim()), rows: filtered.slice(1)};
}

/* ---------------------------------------------------------------- theme */

const THEME_KEY = 'fuse.theme';

function applyTheme(theme) {
  const media = window.matchMedia('(prefers-color-scheme: dark)');
  const resolved = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme;
  document.documentElement.setAttribute('data-theme', resolved);
  try { window.localStorage.setItem(THEME_KEY, theme); } catch { /* private mode */ }
}

/** Read the last theme before the session loads, to avoid a flash of the wrong one. */
function bootTheme() {
  let stored = 'system';
  try { stored = window.localStorage.getItem(THEME_KEY) || 'system'; } catch { /* private mode */ }
  applyTheme(stored);
  return stored;
}

/* ------------------------------------------------------- derived lookups */

function makeIndex(state){
  const person = Object.fromEntries(state.people.map(p=>[p.id,p]));
  const role   = Object.fromEntries(state.roles.map(r=>[r.id,r]));
  const cap    = Object.fromEntries(state.capacities.map(c=>[c.id,c]));
  const type   = Object.fromEntries(state.componentTypes.map(t=>[t.id,t]));
  const contact= Object.fromEntries(state.contacts.map(c=>[c.id,c]));
  const comp   = Object.fromEntries(state.components.map(c=>[c.id,c]));
  const activity = Object.fromEntries(state.raciActivities.map(a=>[a.id,a]));
  const doc    = Object.fromEntries(state.documents.map(d=>[d.id,d]));
  const request= Object.fromEntries(state.requests.map(r=>[r.id,r]));
  const change = Object.fromEntries(state.changes.map(c=>[c.id,c]));
  const report = Object.fromEntries(state.reports.map(r=>[r.id,r]));
  const tool   = Object.fromEntries(state.tools.map(t=>[t.id,t]));
  return {person, role, cap, type, contact, comp, activity, doc, request, change, report, tool};
}
function resolveParty(ref, idx){
  if (!ref || !ref.id) return null;
  if (ref.kind === 'person'){
    const p = idx.person[ref.id];
    return p ? {kind:'person', id:p.id, name:p.name, sub:p.jobTitle, email:p.email,
                phone:p.phone, hasAvatar:p.hasAvatar} : null;
  }
  const r = idx.role[ref.id];
  if (!r) return null;
  const primary = r.primaryPersonId ? idx.person[r.primaryPersonId] : null;
  return {kind:'role', id:r.id, name:r.name,
          sub: primary ? 'Primary: ' + primary.name : 'No primary assignee',
          email: primary && primary.email, phone: primary && primary.phone,
          hasAvatar: false, primaryPersonId: primary && primary.id};
}
const partyKey = (ref) => ref && ref.id ? ref.kind + ':' + ref.id : '';
const parsePartyKey = (k) => k ? {kind:k.split(':')[0], id:k.split(':')[1]} : null;
const partyName = (ref, idx) => { const r = resolveParty(ref, idx); return r ? r.name : ''; };

/** Owners appear in the stakeholder register as derived rows. */
function allStakeholders(comp, idx){
  const rows = OWNER_ROLES.map(or => {
    const ref = comp.owners && comp.owners[or.key];
    return {id:'owner:'+or.key, derived:true, ownerKey:or.key, capacityName:or.label,
            party:ref||null, resolved:resolveParty(ref, idx), notes:''};
  });
  (comp.stakeholders||[]).forEach(s => rows.push({
    id:s.id, derived:false, capacityId:s.capacityId,
    capacityName:(idx.cap[s.capacityId] && idx.cap[s.capacityId].name) || 'Stakeholder',
    party:s.party, resolved:resolveParty(s.party, idx), notes:s.notes || ''
  }));
  return rows;
}

/** Every component a party (person or role) is involved with, and in what
 * capacity — owner (one of the four owner roles), stakeholder (with its
 * capacity name), and/or RACI (with activity + letter). A component can
 * appear more than once if the party wears several hats on it. */
function accountabilitiesForParty(kind, id, state, idx){
  return state.components.map(c => {
    const roles = [];
    OWNER_ROLES.forEach(o => {
      const ref = c.owners && c.owners[o.key];
      if (ref && ref.kind === kind && ref.id === id) roles.push({type:'owner', label:o.label});
    });
    (c.stakeholders||[]).forEach(s => {
      if (s.party && s.party.kind === kind && s.party.id === id) {
        roles.push({type:'stakeholder', label:(idx.cap[s.capacityId]||{}).name || 'Stakeholder'});
      }
    });
    (c.raci||[]).forEach(r => {
      if (r.party && r.party.kind === kind && r.party.id === id) {
        const activity = idx.activity[r.activityId];
        const letterName = (RACI_LETTERS.find(l=>l.k===r.letter)||{}).name || r.letter;
        roles.push({type:'raci', label:`${letterName} — ${activity ? activity.name : 'Unknown activity'}`});
      }
    });
    return {component:c, roles};
  }).filter(x => x.roles.length > 0);
}
function depGraph(state){
  const out = {}, inc = {};
  state.components.forEach(c => { out[c.id]=[]; inc[c.id]=[]; });
  state.dependencies.forEach(d => {
    if (out[d.fromId]) out[d.fromId].push(d);
    if (inc[d.toId]) inc[d.toId].push(d);
  });
  return {out, inc};
}
/** dir 'out' follows what a component depends on, 'in' follows its dependants. */
function traverse(startId, edges, dir, depth){
  const levels = new Map([[startId, 0]]);
  let frontier = [startId];
  for (let lvl=1; lvl<=depth; lvl++){
    const next = [];
    frontier.forEach(id => (edges[id]||[]).forEach(d => {
      const other = dir === 'out' ? d.toId : d.fromId;
      if (!levels.has(other)){ levels.set(other, lvl); next.push(other); }
    }));
    frontier = next;
    if (!frontier.length) break;
  }
  return levels;
}

/* ----------------------------------------------------------------- icons */

const Ic = ({d, size=16, sw=1.8, fill='none'}) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke="currentColor"
       strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={{flex:'0 0 auto'}}>{d}</svg>
);
const I = {
  home:   (p)=><Ic {...p} d={<><path d="M3 10.5 12 3l9 7.5"/><path d="M5.5 9.5V20a1 1 0 0 0 1 1H10v-6h4v6h3.5a1 1 0 0 0 1-1V9.5"/></>}/>,
  dash:   (p)=><Ic {...p} d={<><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></>}/>,
  grid:   (p)=><Ic {...p} d={<><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></>}/>,
  graph:  (p)=><Ic {...p} d={<><circle cx="12" cy="5" r="2.6"/><circle cx="5" cy="19" r="2.6"/><circle cx="19" cy="19" r="2.6"/><path d="M10.4 7.2 6.6 16.6M13.6 7.2l3.8 9.4M7.6 19h8.8"/></>}/>,
  users:  (p)=><Ic {...p} d={<><path d="M16 20v-1.6a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4V20"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-1.6a4 4 0 0 0-3-3.87M16.5 4.1a3.2 3.2 0 0 1 0 6.2"/></>}/>,
  phone:  (p)=><Ic {...p} d={<path d="M21.5 16.9v2.5a1.7 1.7 0 0 1-1.9 1.7 16.6 16.6 0 0 1-7.3-2.6 16.4 16.4 0 0 1-5-5A16.6 16.6 0 0 1 4.7 6.2 1.7 1.7 0 0 1 6.4 4.3h2.5a1.7 1.7 0 0 1 1.7 1.5c.1.9.3 1.7.6 2.5a1.7 1.7 0 0 1-.4 1.8l-1 1a13.3 13.3 0 0 0 5 5l1-1a1.7 1.7 0 0 1 1.8-.4c.8.3 1.6.5 2.5.6a1.7 1.7 0 0 1 1.4 1.6Z"/>}/>,
  mail:   (p)=><Ic {...p} d={<><rect x="2.5" y="4.5" width="19" height="15" rx="2"/><path d="m3 6.5 9 6.5 9-6.5"/></>}/>,
  link:   (p)=><Ic {...p} d={<><path d="M10 13a5 5 0 0 0 7.5.6l3-3A5 5 0 0 0 13.4 3.5l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.6l-3 3A5 5 0 0 0 10.6 20.5l1.7-1.7"/></>}/>,
  search: (p)=><Ic {...p} d={<><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></>}/>,
  plus:   (p)=><Ic {...p} d={<path d="M12 5v14M5 12h14"/>}/>,
  edit:   (p)=><Ic {...p} d={<><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></>}/>,
  trash:  (p)=><Ic {...p} d={<><path d="M3 6h18M8 6V4.5A1.5 1.5 0 0 1 9.5 3h5A1.5 1.5 0 0 1 16 4.5V6M19 6l-.8 13.1a2 2 0 0 1-2 1.9H7.8a2 2 0 0 1-2-1.9L5 6"/></>}/>,
  chev:   (p)=><Ic {...p} d={<path d="m9 18 6-6-6-6"/>}/>,
  book:   (p)=><Ic {...p} d={<><path d="M4 19.5V5a2 2 0 0 1 2-2h13v18H6.5A2.5 2.5 0 0 1 4 19.5Z"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H19"/></>}/>,
  shield: (p)=><Ic {...p} d={<path d="M12 22s8-3.5 8-10V5.5l-8-3-8 3V12c0 6.5 8 10 8 10Z"/>}/>,
  gear:   (p)=><Ic {...p} d={<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1v.3a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-2.8-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3.5 14H3a2 2 0 1 1 0-4h.2A1.6 1.6 0 0 0 4.3 7.2l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 2.7-1.1V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 2.8 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0 1.1 2.7h.3a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.2 1Z"/></>}/>,
  down:   (p)=><Ic {...p} d={<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5M12 15V3"/></>}/>,
  up:     (p)=><Ic {...p} d={<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5M12 3v12"/></>}/>,
  arrowL: (p)=><Ic {...p} d={<path d="M19 12H5m6-7-7 7 7 7"/>}/>,
  arrowR: (p)=><Ic {...p} d={<path d="M5 12h14m-7-7 7 7-7 7"/>}/>,
  arrowD: (p)=><Ic {...p} d={<path d="M12 5v14m-7-7 7 7 7-7"/>}/>,
  warn:   (p)=><Ic {...p} d={<><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></>}/>,
  check:  (p)=><Ic {...p} d={<path d="M20 6 9 17l-5-5"/>}/>,
  x:      (p)=><Ic {...p} d={<path d="M18 6 6 18M6 6l12 12"/>}/>,
  layers: (p)=><Ic {...p} d={<><path d="m12 2 9.5 5-9.5 5L2.5 7 12 2Z"/><path d="m2.5 12 9.5 5 9.5-5M2.5 17 12 22l9.5-5"/></>}/>,
  note:   (p)=><Ic {...p} d={<><path d="M14 2.5H7a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7.5Z"/><path d="M14 2.5v5h5M9 13h6M9 17h4"/></>}/>,
  paper:  (p)=><Ic {...p} d={<path d="M21.4 11.1 12.3 20.2a5.5 5.5 0 0 1-7.8-7.8l9.2-9.1a3.7 3.7 0 0 1 5.2 5.2l-9.2 9.1a1.8 1.8 0 1 1-2.6-2.6l8.5-8.4"/>}/>,
  raci:   (p)=><Ic {...p} d={<><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></>}/>,
  logout: (p)=><Ic {...p} d={<><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5M21 12H9"/></>}/>,
  key:    (p)=><Ic {...p} d={<><circle cx="7.5" cy="15.5" r="4"/><path d="m10.5 12.5 8-8 3 3-2 2-2-2-2 2 2 2-3 3-2-2"/></>}/>,
  grip:   (p)=><Ic {...p} fill="currentColor" d={<><circle cx="9" cy="5" r="1.3"/><circle cx="15" cy="5" r="1.3"/><circle cx="9" cy="12" r="1.3"/><circle cx="15" cy="12" r="1.3"/><circle cx="9" cy="19" r="1.3"/><circle cx="15" cy="19" r="1.3"/></>}/>,
  alignLeft:   (p)=><Ic {...p} d={<><path d="M3 6h18"/><path d="M3 12h12"/><path d="M3 18h16"/></>}/>,
  alignCenter: (p)=><Ic {...p} d={<><path d="M3 6h18"/><path d="M6 12h12"/><path d="M4 18h16"/></>}/>,
  alignRight:  (p)=><Ic {...p} d={<><path d="M3 6h18"/><path d="M9 12h12"/><path d="M5 18h16"/></>}/>,
  pin:    (p)=><Ic {...p} d={<path d="M12 17v5M9 3h6l-1 6 3 3v2H7v-2l3-3-1-6Z"/>}/>,
  history:(p)=><Ic {...p} d={<><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></>}/>,
  refresh:(p)=><Ic {...p} d={<><path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 12a9 9 0 0 1 15.3-6.4L21 8"/><path d="M21 4v4h-4M3 20v-4h4"/></>}/>,
  file:   (p)=><Ic {...p} d={<><path d="M14 2.5H7a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7.5Z"/><path d="M14 2.5v5h5"/></>}/>,
  inbox:  (p)=><Ic {...p} d={<><path d="M21 13.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5.5"/><path d="M3 13.5h5l1.5 2.5h5L16 13.5h5L18.4 4.6A2 2 0 0 0 16.5 3h-9a2 2 0 0 0-1.9 1.6Z"/></>}/>,
  chart:  (p)=><Ic {...p} d={<><path d="M3 3v17a1 1 0 0 0 1 1h17"/><path d="M7 15.5v-4M12 15.5v-8M17 15.5v-5"/></>}/>,
  board:  (p)=><Ic {...p} d={<><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18M15 3v18"/></>}/>,
  list:   (p)=><Ic {...p} d={<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/>}/>,
  table:  (p)=><Ic {...p} d={<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M3 15h18M9 10v10"/></>}/>,
  timeline:(p)=><Ic {...p} d={<><path d="M4 6h9M4 12h14M4 18h7"/><circle cx="16.5" cy="6" r="1.6"/><circle cx="20" cy="12" r="1.6"/><circle cx="13.5" cy="18" r="1.6"/></>}/>,
  calendar:(p)=><Ic {...p} d={<><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>}/>,
  sun:    (p)=><Ic {...p} d={<><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8 6 18M18 6l1.8-1.8"/></>}/>,
  moon:   (p)=><Ic {...p} d={<path d="M21 13.2A8.6 8.6 0 0 1 10.8 3 8.6 8.6 0 1 0 21 13.2Z"/>}/>,
  monitor:(p)=><Ic {...p} d={<><rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></>}/>,
  camera: (p)=><Ic {...p} d={<><path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2l1.2-2h8.2l1.2 2h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5Z"/><circle cx="12" cy="12.5" r="3.4"/></>}/>,
  clock:  (p)=><Ic {...p} d={<><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/></>}/>,
  tag:    (p)=><Ic {...p} d={<><path d="M20.6 13.4 12 22l-9-9V4a1 1 0 0 1 1-1h8Z"/><circle cx="7.5" cy="7.5" r="1.3"/></>}/>,
  comment:(p)=><Ic {...p} d={<path d="M21 11.5a8 8 0 0 1-8.5 8 9 9 0 0 1-3.9-.9L3 21l1.9-4.6A8 8 0 0 1 12.5 3.5a8 8 0 0 1 8.5 8Z"/>}/>,
  tool:   (p)=><Ic {...p} d={<><path d="M14.7 6.3a4.5 4.5 0 0 0 5.9 5.9l-7.6 7.6a2.6 2.6 0 0 1-3.7-3.7Z"/><path d="m5 5 3.5 3.5"/><circle cx="5" cy="5" r="2.2"/></>}/>,
  change: (p)=><Ic {...p} d={<><path d="M3 7h13l-3-3M21 17H8l3 3"/><path d="M16 7a5 5 0 0 1 5 5M8 17a5 5 0 0 1-5-5"/></>}/>,
  bell:   (p)=><Ic {...p} d={<><path d="M18 8.5a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 14.5 18 8.5"/><path d="M13.7 20a2 2 0 0 1-3.4 0"/></>}/>,
  stamp:  (p)=><Ic {...p} d={<><path d="M9 11V7a3 3 0 0 1 6 0v4"/><path d="M4.5 15h15l-1 5.5h-13Z"/><path d="M6.5 15c0-2 2-2.5 2.5-4M17.5 15c0-2-2-2.5-2.5-4"/></>}/>,
  external:(p)=><Ic {...p} d={<><path d="M14 4h6v6"/><path d="M20 4 10.5 13.5"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/></>}/>,
  pulse:  (p)=><Ic {...p} d={<path d="M2 12h4l2-7 4 14 3-9 2 5.5h5"/>}/>
};
const TYPE_ICON = {
  system:     (p)=><Ic {...p} d={<><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></>}/>,
  service:    (p)=><Ic {...p} d={<><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5v17M3.5 12h17"/></>}/>,
  app:        (p)=><Ic {...p} d={<><rect x="6" y="2.5" width="12" height="19" rx="2.5"/><path d="M11 18.5h2"/></>}/>,
  globe:      (p)=><Ic {...p} d={<><circle cx="12" cy="12" r="9"/><path d="M3.2 9h17.6M3.2 15h17.6"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/></>}/>,
  report:     (p)=><Ic {...p} d={<><path d="M14 2.5H7a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7.5Z"/><path d="M14 2.5v5h5M9 13h6M9 17h4"/></>}/>,
  server:     (p)=><Ic {...p} d={<><rect x="2.5" y="3.5" width="19" height="7" rx="1.8"/><rect x="2.5" y="13.5" width="19" height="7" rx="1.8"/><path d="M6.5 7h.01M6.5 17h.01"/></>}/>,
  database:   (p)=><Ic {...p} d={<><ellipse cx="12" cy="5.5" rx="8" ry="3"/><path d="M4 5.5v13c0 1.7 3.6 3 8 3s8-1.3 8-3v-13"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></>}/>,
  integration:(p)=><Ic {...p} d={<><rect x="2.5" y="8.5" width="7" height="7" rx="1.8"/><rect x="14.5" y="8.5" width="7" height="7" rx="1.8"/><path d="M9.5 12h5"/></>}/>,
  network:    (p)=><Ic {...p} d={<><rect x="2.5" y="14" width="19" height="6" rx="1.8"/><path d="M6.5 17h.01M12 14V9M8 9h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1Z"/></>}/>,
  cloud:      (p)=><Ic {...p} d={<path d="M17.5 19a4.5 4.5 0 0 0 .6-8.96A6.5 6.5 0 0 0 5.6 11 4 4 0 0 0 6 19Z"/>}/>
};
const typeIconFor = (type) => TYPE_ICON[(type && type.icon) || 'system'] || TYPE_ICON.system;
const moduleIconFor = (key) => I[key] || I.grid;

/* ------------------------------------------------------------- shared UI */

const Badge = ({tone='slate', children, dot, title}) =>
  <span className={'badge ' + tone} title={title}>{dot && <i className="dot"/>}{children}</span>;

const TierBadge = ({tier, showLabel}) =>
  <span className={'tier tier-' + (tier||4)} title={'Tier ' + tier + ' — ' + (TIER_LABEL[tier]||'')}>
    {showLabel ? 'Tier ' + tier : tier}</span>;

const Priority = ({value}) => <span className={'prio prio-'+value} title={'Priority ' + value}>{value}</span>;

const RaciLetter = ({letter}) => {
  const meta = RACI_LETTERS.find(l => l.k === letter);
  return <span className={'raci-letter raci-'+letter}
    title={meta ? meta.name + ' — ' + meta.hint : letter}>{letter}</span>;
};

/**
 * Avatar. Falls back to coloured initials when there is no uploaded image,
 * and again if the image fails to load.
 */
function Avatar({name, kind='person', size, src, personId, userId, hasAvatar}){
  const [failed, setFailed] = useState(false);
  useEffect(()=>{ setFailed(false); }, [src, personId, userId]);
  const url = src
    || (hasAvatar && personId ? `/api/people/${personId}/avatar` : null)
    || (hasAvatar && userId ? `/api/users/${userId}/avatar` : null);
  const cls = 'avatar' + (kind==='role'?' sq':'') + (size ? ' '+size : '');
  if (url && !failed) {
    return <span className={cls} style={{background: avatarColor(name||'')}}>
      <img src={url} alt="" onError={()=>setFailed(true)}/></span>;
  }
  return <span className={cls} style={{background: avatarColor(name||'')}}>{initials(name)}</span>;
}

/** Avatar for a party reference, wired up to the directory image if present. */
const PartyAvatar = ({resolved, size}) => resolved
  ? <Avatar name={resolved.name} kind={resolved.kind} size={size}
      hasAvatar={resolved.hasAvatar} personId={resolved.id}/>
  : <span className={'avatar empty' + (size ? ' '+size : '')}>—</span>;

function PartyChip({resolved, showSub}){
  if (!resolved) return <span className="dim small">Not assigned</span>;
  return (
    <span className="row" style={{gap:8}}>
      <PartyAvatar resolved={resolved}/>
      <span style={{minWidth:0}}>
        <span style={{display:'block',fontWeight:600,fontSize:13.5}}>
          {resolved.name}
          {resolved.kind==='role' && <span className="badge accent" style={{marginLeft:6,fontSize:10}}>Role</span>}
        </span>
        {showSub !== false && <span className="cell-sub">{resolved.sub}</span>}
      </span>
    </span>
  );
}

function Modal({title, onClose, children, footer, wide}){
  useEffect(()=>{
    const h = (e)=>{ if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', h);
    return ()=>window.removeEventListener('keydown', h);
  },[onClose]);
  return (
    <div className="overlay" onMouseDown={(e)=>{ if (e.target === e.currentTarget) onClose(); }}>
      <div className={'modal' + (wide?' wide':'')} onMouseDown={e=>e.stopPropagation()}>
        <div className="modal-head">
          <h2>{title}</h2><div className="spacer"/>
          <button className="icon-btn" onClick={onClose} title="Close"><I.x/></button>
        </div>
        <div className="modal-body">{children}</div>
        {footer && <div className="modal-foot">{footer}</div>}
      </div>
    </div>
  );
}

function ConfirmDialog({title, message, confirmLabel='Delete', onConfirm, onClose}){
  const [busy, setBusy] = useState(false);
  return (
    <Modal title={title} onClose={onClose} footer={<>
      <button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
      <button className="btn danger" disabled={busy}
        onClick={async ()=>{ setBusy(true); try { await onConfirm(); onClose(); } finally { setBusy(false); } }}>
        {busy ? 'Working…' : confirmLabel}</button>
    </>}>
      <p style={{margin:0}} className="muted">{message}</p>
    </Modal>
  );
}

/* ------------------------------------------------- right-click deep links */

/** Modules whose records have their own full page and a pretty deep link. */
const RECORD_LINK_PATH = {component:'components', request:'requests', change:'changes'};
const RECORD_VIEW = {component:'component', request:'request', change:'change'};

/** Prefers the shareable /components/SVC-003 form; falls back to the internal
 * hash link when a record has no public identifier yet (e.g. an unsaved
 * component draft never has this, requests/changes always do). */
function recordDeepLink({kind, id, identifier}){
  if (identifier) return `${window.location.origin}/${RECORD_LINK_PATH[kind]}/${encodeURIComponent(identifier)}`;
  return `${window.location.origin}/#/${RECORD_VIEW[kind]}/${id}`;
}

/** Builds the {kind, id, identifier, name} shape RecordContextMenu expects. */
function recordMenuItem(kind, obj){
  if (kind === 'component') {
    return {kind, id:obj.id, identifier:obj.identifier, name: obj.identifier ? `${obj.identifier} · ${obj.name}` : obj.name};
  }
  return {kind, id:obj.id, identifier:obj.reference, name:`${obj.reference} · ${obj.title}`};
}

/** Right-click state for RecordContextMenu, shared by a module's list/board/table views. */
function useRecordMenu(){
  const [menu, setMenu] = useState(null); // {x, y, kind, id, identifier, name}
  const openMenu = useCallback((e, record) => {
    e.preventDefault();
    setMenu({x: e.clientX, y: e.clientY, ...record});
  }, []);
  const closeMenu = useCallback(()=>setMenu(null), []);
  return {menu, openMenu, closeMenu};
}

/** Right-click menu offering a shareable deep link for records with their own page. */
function RecordContextMenu({menu, onClose, toast}){
  const ref = useRef(null);
  useEffect(()=>{
    if (!menu) return;
    const away = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    const esc = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('mousedown', away);
    document.addEventListener('keydown', esc);
    window.addEventListener('scroll', onClose, true);
    window.addEventListener('resize', onClose);
    return () => {
      document.removeEventListener('mousedown', away);
      document.removeEventListener('keydown', esc);
      window.removeEventListener('scroll', onClose, true);
      window.removeEventListener('resize', onClose);
    };
  }, [menu, onClose]);

  if (!menu) return null;
  const url = recordDeepLink(menu);
  const style = {left: Math.min(menu.x, window.innerWidth - 220), top: Math.min(menu.y, window.innerHeight - 108)};

  return (
    <div className="ctx-menu" style={style} ref={ref}>
      <div className="ctx-menu-label truncate">{menu.name}</div>
      <button className="ctx-menu-item" onClick={async ()=>{
        onClose();
        try { await navigator.clipboard.writeText(url); toast('Link copied'); }
        catch { toast('Could not copy the link', true); }
      }}><I.link size={14}/>Copy link</button>
      <button className="ctx-menu-item" onClick={()=>{
        onClose();
        window.open(url, '_blank', 'noopener,noreferrer');
      }}><I.external size={14}/>Open in new tab</button>
    </div>
  );
}

/* ---------------------------------------------------- list module helpers */

/** Search box shared by every list module: consistent placeholder/width, and
 * a "/" keyboard shortcut (skipped while any text field already has focus). */
function SearchBox({value, onChange, aria, title, placeholder='Search…'}){
  const ref = useRef(null);
  useEffect(()=>{
    const h = (e) => {
      if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
      const el = document.activeElement;
      const tag = el && el.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el && el.isContentEditable)) return;
      e.preventDefault();
      ref.current && ref.current.focus();
    };
    window.addEventListener('keydown', h);
    return ()=>window.removeEventListener('keydown', h);
  }, []);
  return (
    <div className="search-box">
      <I.search size={15}/>
      <input ref={ref} type="search" placeholder={placeholder} aria-label={aria} title={title}
             value={value} onChange={e=>onChange(e.target.value)}/>
      {!value && <kbd className="search-kbd">/</kbd>}
    </div>
  );
}

/** Reads one value from the current hash's query string, for seeding filter
 * state from a bookmarked or shared URL. */
function urlParam(key, fallback=''){
  const qs = window.location.hash.split('?')[1] || '';
  const v = new URLSearchParams(qs).get(key);
  return v == null ? fallback : v;
}

/** Mirrors a set of filter values onto the current hash's query string, so a
 * filtered module view is bookmarkable/shareable and restores after a reload.
 * `fields` is {key: [value, defaultValue]} — a key at its default is omitted
 * so the URL only ever shows what differs from a fresh visit. */
function useFilterUrlSync(fields){
  useEffect(()=>{
    const path = window.location.hash.split('?')[0];
    const params = new URLSearchParams();
    for (const [k, [v, def]] of Object.entries(fields)) {
      if (v !== undefined && v !== null && v !== '' && v !== false && v !== def) params.set(k, v);
    }
    const qs = params.toString();
    const want = path + (qs ? '?' + qs : '');
    if (window.location.hash !== want) window.history.replaceState(null, '', want);
  });
}

/** Click-to-sort table headers, shared by every list module with a table view.
 * `getters` maps a sort key to a value accessor for that key. */
function useSort(defaultKey, getters, defaultDir=1){
  const [sort, setSort] = useState({key:defaultKey, dir:defaultDir});
  const sorted = (list) => {
    const get = getters[sort.key] || getters[defaultKey];
    return [...list].sort((a,b) => {
      const A = get(a), B = get(b);
      if (typeof A === 'number' && typeof B === 'number') return (A - B) * sort.dir;
      return String(A).localeCompare(String(B)) * sort.dir;
    });
  };
  const Th = (props) => {
    const {k, label} = props;
    const rest = Object.assign({}, props);
    delete rest.k; delete rest.label;
    return (
    <th className="sortable" {...rest} onClick={()=>setSort(s => ({key:k, dir: s.key===k ? -s.dir : 1}))}>
      {label}{sort.key===k ? (sort.dir===1?' ↑':' ↓') : ''}
    </th>
    );
  };
  return {sort, sorted, Th};
}

/** Checkbox selection for a table's rows, plus the bulk-action bar it drives. */
function useBulkSelect(){
  const [selected, setSelected] = useState(()=>new Set());
  const toggle = (id) => setSelected(s => {
    const next = new Set(s);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });
  const toggleAll = (ids) => setSelected(s =>
    ids.length && ids.every(id => s.has(id)) ? new Set() : new Set(ids));
  const clear = () => setSelected(new Set());
  return {selected, toggle, toggleAll, clear};
}

/** A slim toolbar that replaces the results count once rows are selected. */
function BulkBar({count, onClear, children}){
  if (!count) return null;
  return (
    <div className="bulk-bar">
      <strong>{count} selected</strong>
      <div className="row wrap" style={{gap:8}}>{children}</div>
      <div className="spacer"/>
      <button className="btn ghost sm" onClick={onClear}>Clear</button>
    </div>
  );
}

/** Delays a delete so its toast can offer Undo before it actually happens —
 * use in place of `act(API.del(url), label)` for anything a user might
 * regret clicking. Nothing is sent to the server unless the toast expires
 * without Undo being clicked. `label` reads naturally before " deleted",
 * e.g. "Component", "SVC-003", "Jane Doe". */
function deleteWithUndo(act, toast, url, label){
  let undone = false;
  const timer = setTimeout(()=>{ if (!undone) act(API.del(url)); }, 5500);
  toast(`${label} deleted`, false, {
    label: 'Undo',
    onClick: () => { undone = true; clearTimeout(timer); toast(`${label} kept — nothing was deleted`); }
  });
}

const Field = ({label, hint, children, style}) => (
  <label className="field" style={style}>
    <span className="lbl">{label}</span>
    {children}
    {hint && <span className="hint">{hint}</span>}
  </label>
);

function PartySelect({value, onChange, state, allowEmpty=true, placeholder='Not assigned'}){
  return (
    <select value={partyKey(value)} onChange={e=>onChange(parsePartyKey(e.target.value))}>
      {allowEmpty && <option value="">{placeholder}</option>}
      <optgroup label="People">
        {state.people.map(p => <option key={p.id} value={'person:'+p.id}>{p.name} — {p.jobTitle}</option>)}
      </optgroup>
      <optgroup label="Roles">
        {state.roles.map(r => <option key={r.id} value={'role:'+r.id}>{r.name}</option>)}
      </optgroup>
    </select>
  );
}

/** Tick-list for choosing several components. */
function ComponentPicker({state, value, onChange, filter}){
  const [q, setQ] = useState('');
  const selected = new Set(value || []);
  const list = state.components
    .filter(c => !filter || filter(c))
    .filter(c => !q || (c.name + ' ' + c.identifier).toLowerCase().includes(q.toLowerCase()));
  const toggle = (id) => {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id); else next.add(id);
    onChange([...next]);
  };
  return (
    <div>
      <div className="search-box" style={{maxWidth:'none',marginBottom:8}}>
        <I.search size={15}/>
        <input type="search" placeholder="Filter components…" value={q} onChange={e=>setQ(e.target.value)}/>
      </div>
      <div className="multi-pick">
        {list.map(c =>
          <label key={c.id}>
            <input type="checkbox" checked={selected.has(c.id)} onChange={()=>toggle(c.id)}/>
            <TierBadge tier={c.tier}/>
            <span style={{flex:1,minWidth:0}} className="truncate">{c.name}</span>
            <span className="mono dim">{c.identifier}</span>
          </label>)}
        {!list.length && <div className="center-empty small">No components match</div>}
      </div>
      {selected.size > 0 && <div className="hint">{selected.size} selected</div>}
    </div>
  );
}

function EmptyState({icon, title, body, action}){
  return (
    <div className="center-empty">
      {icon}
      <div style={{fontWeight:600,color:'var(--text-2)',marginBottom:3}}>{title}</div>
      {body && <div className="small" style={{marginBottom:12}}>{body}</div>}
      {action}
    </div>
  );
}

function ContactLinks({contact}){
  const items = [];
  if (contact.phone) items.push({k:'tel', icon:<I.phone size={13}/>, label:contact.phone, href:'tel:'+contact.phone.replace(/\s/g,'')});
  if (contact.email) items.push({k:'mail', icon:<I.mail size={13}/>, label:contact.email, href:'mailto:'+contact.email});
  if (contact.portalUrl) items.push({k:'portal', icon:<I.link size={13}/>, label:'Support portal', href:contact.portalUrl});
  if (!items.length) return <div className="dim xsmall" style={{marginTop:8}}>No contact details recorded</div>;
  return (
    <div className="contact-links">
      {items.map(it =>
        <a key={it.k} className="clink" href={it.href}
           target={it.k==='portal'?'_blank':undefined} rel="noopener noreferrer"
           title={it.k==='portal' ? contact.portalUrl : it.label}>
          {it.icon}<span>{it.label}</span>
        </a>)}
    </div>
  );
}

const ReadOnlyBanner = ({message}) => (
  <div className="readonly-banner"><I.warn size={15}/><span>{message}</span></div>
);

/** Shared attachment list plus dropzone, used by three modules. */
function AttachmentPanel({attachments, base, editable, act, toast, title='Attachments'}){
  const [over, setOver] = useState(false);
  const [uploading, setUploading] = useState(null);
  const inputRef = useRef(null);
  const files = attachments || [];

  const handleFiles = async (list) => {
    for (const file of Array.from(list)) {
      if (file.size > 25 * 1024 * 1024) { toast(`${file.name} is larger than the 25MB limit`, true); continue; }
      setUploading({name: file.name, pct: 0});
      const params = new URLSearchParams({filename: file.name, type: file.type || 'application/octet-stream'});
      try {
        await uploadFile(`${base}/attachments?${params}`, file, (pct)=>setUploading({name:file.name, pct}));
      } catch (err) { toast(err.message, true); }
    }
    setUploading(null);
    await act(Promise.resolve(), 'Upload complete');
  };

  return (
    <div className="card">
      <div className="card-head">
        <h3>{title}</h3><Badge tone="slate">{files.length}</Badge>
        {files.length > 0 && <span className="xsmall dim">{fmtBytes(files.reduce((s,f)=>s+f.byteSize,0))} stored</span>}
        <div className="spacer"/>
        {editable && <button className="btn sm primary" onClick={()=>inputRef.current.click()}>
          <I.up size={13}/>Upload</button>}
      </div>
      {files.length === 0 && !editable
        ? <EmptyState icon={<I.paper size={26}/>} title="No attachments"/>
        : <div>
            {files.map(f => (
              <div className="att-row" key={f.id}>
                <span className="att-icon">{f.isImage ? <I.camera size={16}/> : <I.file size={16}/>}</span>
                <div style={{flex:1,minWidth:0}}>
                  <div style={{fontWeight:600}} className="truncate">{f.filename}</div>
                  <div className="cell-sub">
                    {fmtBytes(f.byteSize)} · {f.uploadedBy} · {relative(f.uploadedAt)}
                    {f.description ? ' · ' + f.description : ''}
                  </div>
                </div>
                <a className="btn sm" href={`/api/attachments/${f.id}/download`}><I.down size={13}/>Get</a>
                {editable &&
                  <button className="icon-btn" title="Delete"
                    onClick={()=>act(API.del(`${base}/attachments/${f.id}`), 'Attachment deleted')}>
                    <I.trash size={14}/></button>}
              </div>
            ))}
          </div>}
      {editable &&
        <div className="card-body">
          <input ref={inputRef} type="file" multiple style={{display:'none'}}
            onChange={e=>{ if (e.target.files.length) handleFiles(e.target.files); e.target.value=''; }}/>
          <div className={'dropzone' + (over ? ' over' : '')}
            onClick={()=>inputRef.current.click()}
            onDragOver={e=>{ e.preventDefault(); setOver(true); }}
            onDragLeave={()=>setOver(false)}
            onDrop={e=>{ e.preventDefault(); setOver(false);
              if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); }}>
            <I.up size={20}/>
            <div style={{fontWeight:600,marginTop:6}}>Drop files here, or click to choose</div>
            <div className="xsmall">Up to 25MB per file, stored on the server</div>
          </div>
          {uploading &&
            <div style={{marginTop:12}}>
              <div className="small">{uploading.name} — {uploading.pct}%</div>
              <div className="progress"><span style={{width: uploading.pct + '%'}}/></div>
            </div>}
        </div>}
    </div>
  );
}

/** Small toolbar for switching between the views a module offers. */
const ViewSwitch = ({views, value, onChange}) => (
  <div className="row" style={{gap:2}}>
    {views.map(v =>
      <button key={v.k} className={'btn sm' + (value===v.k ? ' primary' : ' ghost')}
        onClick={()=>onChange(v.k)} title={v.label}>{v.icon}<span>{v.label}</span></button>)}
  </div>
);


/* ---------------------------------------------------------- notifications */

/** The bell, its unread badge and the dropdown of recent notifications. */
function NotificationBell({unread, onNavigate, refreshState}){
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState(null);
  const [count, setCount] = useState(unread || 0);
  const ref = useRef(null);

  useEffect(()=>{ setCount(unread || 0); }, [unread]);
  useEffect(()=>{
    if (!open) return;
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return ()=>document.removeEventListener('mousedown', h);
  }, [open]);

  const load = async () => {
    try {
      const res = await API.get('/api/notifications?limit=40');
      setItems(res.notifications);
      setCount(res.unread);
    } catch { setItems([]); }
  };
  useEffect(()=>{ if (open) load(); }, [open]);

  // A quiet poll keeps the badge honest without the page needing a refresh.
  useEffect(()=>{
    const timer = setInterval(async () => {
      try {
        const res = await API.get('/api/notifications?limit=1');
        setCount(res.unread);
      } catch { /* offline or signed out; the next state fetch will notice */ }
    }, 60000);
    return ()=>clearInterval(timer);
  }, []);

  const markAll = async () => {
    try { const res = await API.post('/api/notifications/read', {}); setCount(res.unread); await load(); }
    catch { /* ignore */ }
  };
  const openOne = async (n) => {
    setOpen(false);
    if (!n.isRead) {
      try { const res = await API.post('/api/notifications/read', {ids:[n.id]}); setCount(res.unread); }
      catch { /* ignore */ }
    }
    if (n.url) onNavigate(n.url);
    if (refreshState) refreshState();
  };

  return (
    <div className="acct bell" ref={ref}>
      <button className="icon-btn" onClick={()=>setOpen(o=>!o)}
        title={count ? `${count} unread` : 'Notifications'} style={{position:'relative'}}>
        <I.bell size={18}/>
        {count > 0 && <span className="bell-dot">{count > 99 ? '99+' : count}</span>}
      </button>
      {open &&
        <div className="notif-menu">
          <div className="notif-head">
            <strong style={{fontSize:13.5}}>Notifications</strong>
            {count > 0 && <Badge tone="accent">{count} unread</Badge>}
            <div className="spacer"/>
            {count > 0 && <button className="btn ghost sm" onClick={markAll}>Mark all read</button>}
          </div>
          <div className="notif-list">
            {items === null && <div className="center-empty small">Loading…</div>}
            {items && items.length === 0 &&
              <EmptyState icon={<I.bell size={26}/>} title="Nothing yet"
                body="You will hear about approvals, changes and requests that involve you."/>}
            {items && items.map(n => (
              <div className={'notif' + (n.isRead ? '' : ' unread')} key={n.id} onClick={()=>openOne(n)}>
                <span className={'notif-dot ' + (n.tone || 'info')}/>
                <div style={{flex:1,minWidth:0}}>
                  <div className="notif-title">{n.title}</div>
                  {n.body && <div className="notif-body">{n.body}</div>}
                  <div className="xsmall dim" style={{marginTop:4}}>{relative(n.createdAt)}</div>
                </div>
              </div>
            ))}
          </div>
        </div>}
    </div>
  );
}

/* ----------------------------------------------------------------- login */

function LoginScreen({onSignedIn}){
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);
  const [challengeToken, setChallengeToken] = useState(null);
  const [code, setCode] = useState('');
  const [useBackup, setUseBackup] = useState(false);

  const brand = (
    <div className="login-brand">
      <div className="brand-mark">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round">
          <circle cx="12" cy="5" r="2.4"/><circle cx="5" cy="18" r="2.4"/><circle cx="19" cy="18" r="2.4"/>
          <path d="M10.5 7 6.4 15.7M13.5 7l4.1 8.7M7.6 18h8.8"/>
        </svg>
      </div>
      <div>
        <div className="login-title">Fuse</div>
        <div className="login-sub">{challengeToken ? 'Verify your identity' : 'Sign in to your workspace'}</div>
      </div>
    </div>
  );

  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setError('');
    try {
      const res = await API.post('/api/auth/login', {email, password});
      if (res.needsTotp) { setChallengeToken(res.challengeToken); setBusy(false); return; }
      onSignedIn(res.user);
    } catch (err) { setError(err.message); setBusy(false); }
  };

  const submitCode = async (e) => {
    e.preventDefault();
    setBusy(true); setError('');
    try {
      const res = await API.post('/api/auth/login/totp', {challengeToken, code});
      onSignedIn(res.user);
    } catch (err) { setError(err.message); setBusy(false); }
  };

  if (challengeToken) {
    return (
      <div className="login-wrap">
        <form className="login-card" onSubmit={submitCode}>
          {brand}
          {error && <div className="err">{error}</div>}
          <Field label={useBackup ? 'Backup code' : 'Verification code'}
            hint={useBackup ? 'One of the recovery codes you saved when you set up two-factor authentication.'
              : 'The 6-digit code from your authenticator app.'}>
            <input type="text" inputMode={useBackup ? 'text' : 'numeric'} autoFocus
              value={code} onChange={e=>setCode(e.target.value)}
              placeholder={useBackup ? 'XXXXX-XXXXX' : '123456'}/>
          </Field>
          <button className="btn primary block" type="submit" disabled={busy || !code}>
            {busy ? 'Verifying…' : 'Verify and sign in'}
          </button>
          <div className="row" style={{justifyContent:'space-between', marginTop:12}}>
            <a className="link small" onClick={()=>{ setChallengeToken(null); setCode(''); setError(''); }}>
              Back</a>
            <a className="link small" onClick={()=>{ setUseBackup(u => !u); setCode(''); setError(''); }}>
              {useBackup ? 'Use an authenticator code instead' : 'Use a backup code instead'}</a>
          </div>
        </form>
      </div>
    );
  }

  return (
    <div className="login-wrap">
      <form className="login-card" onSubmit={submit}>
        {brand}
        {error && <div className="err">{error}</div>}
        <Field label="Email">
          <input type="email" value={email} onChange={e=>setEmail(e.target.value)} autoFocus
                 autoComplete="username" required/>
        </Field>
        <Field label="Password">
          <input type="password" value={password} onChange={e=>setPassword(e.target.value)}
                 autoComplete="current-password" required/>
        </Field>
        <button className="btn primary block" type="submit" disabled={busy || !email || !password}>
          {busy ? 'Signing in…' : 'Sign in'}
        </button>
        <div className="login-hint">
          Accounts are created by an administrator. On a fresh installation the administrator
          credentials are printed in the server console at first start.
        </div>
      </form>
    </div>
  );
}

function ChangePasswordModal({onClose, toast, forced}){
  const [current, setCurrent] = useState('');
  const [next, setNext] = useState('');
  const [confirm, setConfirm] = useState('');
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);

  const submit = async () => {
    if (next !== confirm) { setError('The two new passwords do not match'); return; }
    setBusy(true); setError('');
    try {
      await API.post('/api/auth/password', {currentPassword: current, newPassword: next});
      toast('Password changed');
      onClose();
    } catch (err) { setError(err.message); setBusy(false); }
  };

  return (
    <Modal title="Change password" onClose={forced ? ()=>{} : onClose}
      footer={<>
        {!forced && <button className="btn" onClick={onClose}>Cancel</button>}
        <button className="btn primary" onClick={submit} disabled={busy || !current || !next}>
          {busy ? 'Saving…' : 'Change password'}</button>
      </>}>
      {forced && <div className="info-box" style={{marginBottom:16}}>
        Your password was set by an administrator. Please choose a new one.</div>}
      {error && <div className="err">{error}</div>}
      <Field label="Current password">
        <input type="password" value={current} onChange={e=>setCurrent(e.target.value)} autoComplete="current-password"/>
      </Field>
      <Field label="New password" hint="At least 8 characters.">
        <input type="password" value={next} onChange={e=>setNext(e.target.value)} autoComplete="new-password"/>
      </Field>
      <Field label="Confirm new password">
        <input type="password" value={confirm} onChange={e=>setConfirm(e.target.value)} autoComplete="new-password"/>
      </Field>
    </Modal>
  );
}

/** Upload, replace or remove a profile image. */
function AvatarEditor({name, endpoint, hasAvatar, onChanged, toast, size='xl'}){
  const [busy, setBusy] = useState(false);
  const [bust, setBust] = useState(0);
  const inputRef = useRef(null);

  const pick = async (file) => {
    if (!file) return;
    if (!/^image\/(png|jpeg|gif|webp)$/.test(file.type)) {
      toast('Profile images must be PNG, JPEG, GIF or WebP', true); return;
    }
    if (file.size > 2 * 1024 * 1024) { toast('Profile images must be 2MB or smaller', true); return; }
    setBusy(true);
    try {
      await uploadFile(`${endpoint}?type=${encodeURIComponent(file.type)}`, file);
      setBust(Date.now());
      toast('Profile image updated');
      if (onChanged) await onChanged(true);
    } catch (err) { toast(err.message, true); }
    setBusy(false);
  };

  const remove = async () => {
    setBusy(true);
    try {
      await API.del(endpoint);
      setBust(Date.now());
      toast('Profile image removed');
      if (onChanged) await onChanged(false);
    } catch (err) { toast(err.message, true); }
    setBusy(false);
  };

  return (
    <div className="avatar-edit">
      <Avatar name={name} size={size} src={hasAvatar ? `${endpoint}?v=${bust}` : null}/>
      <div className="stack" style={{gap:8}}>
        <input ref={inputRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp"
          style={{display:'none'}} onChange={e=>{ pick(e.target.files[0]); e.target.value=''; }}/>
        <div className="row" style={{gap:8}}>
          <button className="btn sm" disabled={busy} onClick={()=>inputRef.current.click()}>
            <I.camera size={13}/>{hasAvatar ? 'Replace' : 'Upload'}</button>
          {hasAvatar && <button className="btn sm danger" disabled={busy} onClick={remove}>Remove</button>}
        </div>
        <span className="xsmall dim">PNG, JPEG, GIF or WebP. Up to 2MB.</span>
      </div>
    </div>
  );
}

/* ====================================================================
   RICH TEXT — shared sanitizer, highlighter and read-only renderer.
   The editor itself (public/js/07-richtext.jsx) is loaded after this file
   and reuses everything here. See lib/core/richtext.js for the server-side
   sanitizer, which is the actual security boundary — this client copy is a
   DOM-tree walker (not a string parser) used for paste-cleaning while
   editing and as a defense-in-depth pass before dangerouslySetInnerHTML.
   ==================================================================== */

const RTE_ALLOWED_TAGS = new Set([
  'p','br','strong','em','u','s','h1','h2','h3',
  'ul','ol','li','blockquote','pre','code',
  'table','thead','tbody','tr','th','td','a','img','hr',
  // TipTap's TaskList/TaskItem structure: <ul data-type="taskList"><li data-checked
  // data-type="taskItem"><label><input type="checkbox">...</label><div>content</div></li></ul>
  'label','input','span','div'
]);
const RTE_VOID_TAGS = new Set(['br','img','hr','input']);
const RTE_TAG_ALIAS = {b:'strong', i:'em', strike:'s'};
const RTE_LANGUAGES = new Set(['js','ts','python','bash','sh','sql','json','html','css','plain']);
const RTE_MAX_DEPTH = 40;

function rteSafeHref(v){
  const stripped = String(v||'').replace(/[\x00-\x20]/g,'').toLowerCase();
  return /^(https?:|mailto:)/.test(stripped) ? String(v).trim() : null;
}
function rteSafeImgSrc(v){
  const raw = String(v||'').trim();
  return /^\/api\/attachments\/[A-Za-z0-9_-]+\/download$/.test(raw) ? raw : null;
}
const rteEscape = (s) => String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const rteEscapeAttr = (s) => rteEscape(s).replace(/"/g,'&quot;');

/** Walk one real DOM node (live contenteditable or an inert parsed fragment) into clean, allowlisted HTML. */
function rteWalk(node, depth){
  if (depth > RTE_MAX_DEPTH) return '';
  if (node.nodeType === Node.TEXT_NODE) return rteEscape(node.nodeValue);
  if (node.nodeType !== Node.ELEMENT_NODE) return '';

  const tag = RTE_TAG_ALIAS[node.tagName.toLowerCase()] || node.tagName.toLowerCase();
  const childHtml = () => Array.from(node.childNodes).map(c => rteWalk(c, depth + 1)).join('');

  if (!RTE_ALLOWED_TAGS.has(tag)) return childHtml();

  if (RTE_VOID_TAGS.has(tag)) {
    if (tag === 'img') {
      const src = rteSafeImgSrc(node.getAttribute('src'));
      if (!src) return '';
      const parts = [`src="${src}"`, `alt="${rteEscapeAttr((node.getAttribute('alt')||'').slice(0,300))}"`];
      const width = (node.getAttribute('width')||'').trim();
      if (/^\d+$/.test(width) && Number(width) >= 40 && Number(width) <= 2000) parts.push(`width="${width}"`);
      const align = node.getAttribute('data-align');
      if (align === 'left' || align === 'center' || align === 'right') parts.push(`data-align="${align}"`);
      return `<img ${parts.join(' ')}>`;
    }
    if (tag === 'input') {
      if ((node.getAttribute('type')||'').toLowerCase() !== 'checkbox') return '';
      return `<input type="checkbox" disabled${node.hasAttribute('checked') ? ' checked' : ''}>`;
    }
    return `<${tag}>`;
  }

  let attrOut = '';
  if (tag === 'a') {
    const href = rteSafeHref(node.getAttribute('href'));
    if (href) attrOut = ` href="${rteEscapeAttr(href)}" target="_blank" rel="noopener noreferrer"`;
  } else if (tag === 'li') {
    const parts = [];
    const checked = node.getAttribute('data-checked');
    if (checked === 'true' || checked === 'false') parts.push(`data-checked="${checked}"`);
    if (node.getAttribute('data-type') === 'taskItem') parts.push('data-type="taskItem"');
    if (parts.length) attrOut = ' ' + parts.join(' ');
  } else if (tag === 'ul') {
    if (node.getAttribute('data-type') === 'taskList') attrOut = ' data-type="taskList"';
  } else if (tag === 'code') {
    const m = /^language-([a-z0-9]+)$/i.exec((node.getAttribute('class')||'').trim());
    const lang = m ? m[1].toLowerCase() : '';
    if (RTE_LANGUAGES.has(lang)) attrOut = ` class="language-${lang}"`;
  } else if (tag === 'td' || tag === 'th') {
    const parts = [];
    for (const a of ['colspan','rowspan']) {
      const v = node.getAttribute(a);
      if (v && /^[1-9][0-9]?$/.test(v)) parts.push(`${a}="${v}"`);
    }
    if (parts.length) attrOut = ' ' + parts.join(' ');
  }

  const inner = childHtml();
  if (!inner && (tag === 'p' || tag === 'li')) return `<${tag}${attrOut}><br></${tag}>`;
  return `<${tag}${attrOut}>${inner}</${tag}>`;
}

/** Clean every child of a live or inert DOM container into an allowlisted HTML string. */
const rteCleanNodes = (container) => Array.from(container.childNodes).map(n => rteWalk(n, 0)).join('');

/** Parse an HTML string via an inert, script-disabled document, then clean it. Safe for untrusted input. */
function rteCleanHtmlString(html){
  const doc = new DOMParser().parseFromString(String(html||''), 'text/html');
  return rteCleanNodes(doc.body);
}

const RTE_BLOCK_START_RE = /^\s*<(p|h1|h2|h3|ul|ol|blockquote|pre|table|hr)\b/i;
/** Distinguishes rich HTML (saved through this feature) from plain text saved before it existed. */
const looksLikeRichText = (v) => RTE_BLOCK_START_RE.test(String(v||''));

const RTE_EMPTY_VALUES = new Set(['', '<p><br></p>', '<p></p>']);
/** True for an empty/whitespace-only rich text value — an empty edited paragraph still counts as empty. */
const rteIsBlank = (v) => RTE_EMPTY_VALUES.has(String(v||'').trim());

/** Plain legacy text -> the same paragraph/line-break shape the editor would have produced. */
function rtePlainTextToHtml(text){
  const paras = String(text).split(/\n{2,}/);
  return paras.map(p => `<p>${rteEscape(p).replace(/\n/g,'<br>')}</p>`).join('');
}

/* ------------------------------------------------------- syntax highlighting */

const HLJS_VERSION = '11.9.0';
let hljsPromise = null;
/** Lazily loads highlight.js, once per page — used only by the read-only renderer.
 *  The editor gets its own (live, cursor-safe) highlighting from lowlight/CodeBlockLowlight
 *  instead; see public/js/07-richtext.jsx. Neither ever affects what's stored: code blocks
 *  are saved as plain escaped text plus a language class, highlighting is always re-derived
 *  at render time. */
function loadHljs(){
  if (!hljsPromise) hljsPromise = import(`https://esm.sh/highlight.js@${HLJS_VERSION}`).then(m => m.default);
  return hljsPromise;
}

/** Read-only renderer for any rich text field. Handles legacy plain-text values transparently. */
function RichText({value, className}){
  const ref = useRef(null);
  const raw = String(value == null ? '' : value);
  const html = useMemo(() => {
    if (!raw.trim()) return '';
    return looksLikeRichText(raw) ? rteCleanHtmlString(raw) : rtePlainTextToHtml(raw);
  }, [raw]);

  useEffect(() => {
    if (!ref.current) return;
    const langOf = (el) => { const m = /(?:^|\s)language-(\w+)/.exec(el.className); return m ? m[1] : null; };
    const blocks = Array.from(ref.current.querySelectorAll('code[class*="language-"]'))
      .filter(el => { const l = langOf(el); return l && l !== 'plain'; });
    if (!blocks.length) return;
    let cancelled = false;
    loadHljs().then(hljs => {
      if (cancelled) return;
      blocks.forEach(el => {
        try { el.innerHTML = hljs.highlight(el.textContent, { language: langOf(el) }).value; }
        catch { /* language not recognized by hljs — leave the plain escaped text as-is */ }
      });
    }).catch(() => { /* offline: code blocks just render unhighlighted, still perfectly readable */ });
    return () => { cancelled = true; };
  }, [html]);

  if (!html) return null;
  return <div ref={ref} className={'rte-content' + (className ? ' ' + className : '')}
    dangerouslySetInnerHTML={{__html: html}}/>;
}
