// AuditLog: the admin-only Audit Log page (a permanent, read-only record of who did what and when),
// and ConcernActivity, the history timeline shown on each concern.
// Exports: AuditLogView, ConcernActivity

const AU_GROUPS = [
  ['all', 'All'], ['access', 'Access'], ['people', 'People'], ['clients', 'Clients'], ['concerns', 'Concerns'], ['types', 'Ticket types'],
];
// each group gets a colour from the same palette the ticket types use
const AU_TONE = { access: 'slate', people: 'violet', clients: 'teal', concerns: 'blue', types: 'amber', system: 'orange' };
const AU_ROLE = { admin: 'admin', agent: 'developer', client: 'client user' };

const AU_CSS = `
.au-page { max-width: 980px; }
.au-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
.au-title { margin: 0; font-size: 28px; font-weight: 800; letter-spacing: -0.02em; color: ${CV.navy}; }
.au-count { font-size: 13px; font-weight: 700; color: ${CV.mute}; background: #fff; border: 1px solid ${CV.line}; border-radius: 999px; padding: 3px 11px; font-variant-numeric: tabular-nums; }
.au-sub { margin: 6px 0 0; font-size: 14px; color: ${CV.mute}; max-width: 560px; line-height: 1.5; }
.au-filters { padding: 16px; display: flex; flex-direction: column; gap: 14px; margin-bottom: 4px; }
.au-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.au-label { font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: ${CV.mute}; }
.au-field .cv-input { width: 100%; box-sizing: border-box; }
.au-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.au-tabrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.au-tabs { display: inline-flex; gap: 4px; background: ${CV.wash}; padding: 3px; border-radius: 12px; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.au-tabs::-webkit-scrollbar { display: none; }
.au-tabs .cv-btn { flex-shrink: 0; }
.au-alert { margin: 14px 0; padding: 10px 14px; border-radius: 10px; background: #FEF2F2; color: #B91C1C; border: 1px solid #FECACA; font-size: 13px; }
.cv1.au-page { max-width: 1560px; }
.au-day { margin: 0; padding: 22px 4px 10px; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: ${CV.mute}; background: ${CV.wash}; }
.au-list { overflow: hidden; }
.au-item + .au-item { border-top: 1px solid ${CV.line}; }
.au-btn { display: flex; gap: 14px; align-items: flex-start; width: 100%; padding: 14px 16px; border: 0; background: transparent; font: inherit; color: inherit; text-align: left; cursor: pointer; transition: background .14s; }
.au-btn:hover { background: #FAFBFD; }
.au-btn:focus-visible { outline: 2px solid ${CV.orange}; outline-offset: -2px; }
.au-ico { width: 36px; height: 36px; border-radius: 50%; display: inline-grid; place-items: center; flex-shrink: 0; }
.au-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.au-line { font-size: 14px; line-height: 1.55; color: #334155; overflow-wrap: anywhere; }
.au-line b { color: ${CV.navy}; font-weight: 800; }
.au-meta { display: flex; align-items: center; gap: 6px 10px; flex-wrap: wrap; font-size: 12px; color: ${CV.faint}; }
.au-when { font-variant-numeric: tabular-nums; font-weight: 600; color: ${CV.mute}; }
.au-chip { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
.au-client { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: #475569; }
.au-ip { font-variant-numeric: tabular-nums; }
.au-more { padding: 0 16px 16px 66px; }
.au-det { margin: 0; padding: 10px 12px; background: ${CV.wash}; border-radius: 10px; font-size: 12.5px; color: ${CV.mute}; display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; }
.au-det dt { font-weight: 700; color: ${CV.ink}; }
.au-det dd { margin: 0; word-break: break-word; }
.au-foot { display: flex; align-items: center; justify-content: center; gap: 14px; flex-wrap: wrap; padding: 24px 0 8px; font-size: 12.5px; color: ${CV.faint}; }
.au-feed { container-type: inline-size; container-name: au; }
.au-cols, .au-wide { display: none; }
.au-narrow { display: flex; gap: 14px; width: 100%; }
.au-chg { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.au-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; border-radius: 999px; padding: 3px 10px; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.au-day-n { margin-left: 10px; font-weight: 600; letter-spacing: 0; text-transform: none; color: ${CV.faint}; }
.au-who { display: flex; align-items: center; gap: 10px; min-width: 0; }
.au-av { display: flex; flex-shrink: 0; }
.au-who b { display: block; font-size: 14px; font-weight: 800; color: ${CV.navy}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.au-who small, .au-when2 small { display: block; font-size: 11.5px; color: ${CV.faint}; margin-top: 1px; }
.au-what { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.au-kind { display: flex; align-items: center; gap: 7px; font-size: 11.5px; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; color: ${CV.mute}; }
.au-kind i { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.au-obj { font-size: 14px; font-weight: 700; color: ${CV.ink}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.au-pair { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
.au-then { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.au-cl-logo { display: flex; flex-shrink: 0; }
.au-change { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.au-cl { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; font-weight: 600; color: #475569; }
.au-cl-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.au-when2 { text-align: right; }
.au-when2 b { font-size: 13px; font-weight: 800; color: ${CV.ink}; font-variant-numeric: tabular-nums; }
@container au (min-width: 540px) {
  .au-narrow { display: none; }
  .au-wide, .au-cols { display: grid; grid-template-columns: minmax(80px, 1fr) minmax(100px, 1.2fr) minmax(116px, 1.4fr) minmax(70px, 0.9fr) 58px 12px; column-gap: 12px; align-items: center; width: 100%; }
  .au-wide { grid-template-areas: "who what change cl when chev"; }
  .au-who { grid-area: who; } .au-what { grid-area: what; } .au-change { grid-area: change; } .au-cl { grid-area: cl; } .au-when2 { grid-area: when; } .au-wide > svg { grid-area: chev; }
  .au-av { display: none; }
  .au-long { display: none; }
  .au-cl { flex-direction: column; align-items: flex-start; gap: 5px; }
  .au-who b { font-size: 13px; line-height: 1.25; white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
  .au-obj { font-size: 13px; line-height: 1.3; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
  .au-kind { font-size: 10.5px; letter-spacing: .02em; }
  .au-pill { font-size: 11.5px; padding: 2px 8px; white-space: normal; overflow-wrap: anywhere; text-align: left; max-width: 100%; }
  .au-then { white-space: normal; flex-wrap: nowrap; max-width: 100%; min-width: 0; }
  .au-then > :first-child { flex-shrink: 0; }
  .au-cl-name { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: 12.5px; line-height: 1.3; }
  .au-cols { padding: 11px 16px; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: ${CV.navy}; background: ${CV.wash}; border-bottom: 2px solid ${CV.line}; margin-bottom: 2px; }
  .au-btn { align-items: center; padding: 14px 16px; }
  .au-more { padding: 0 16px 16px 16px; }
}
@container au (min-width: 760px) {
  .au-wide, .au-cols { grid-template-columns: minmax(150px, 1.1fr) minmax(170px, 1.4fr) minmax(190px, 1.7fr) minmax(120px, 0.9fr) 76px 18px; column-gap: 18px; }
  .au-av { display: flex; }
  .au-long { display: revert; }
  .au-cl { flex-direction: row; align-items: center; gap: 10px; }
  .au-who b { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .au-obj { font-size: 14px; white-space: nowrap; display: block; overflow: hidden; text-overflow: ellipsis; }
  .au-kind { font-size: 11.5px; letter-spacing: .03em; }
  .au-pill { font-size: 12px; padding: 3px 10px; white-space: nowrap; }
  .au-then { white-space: nowrap; }
  .au-cl-name { white-space: nowrap; display: block; font-size: 13px; }
}
.au-tl { position: relative; padding-left: 22px; }
.au-tl::before { content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 2px; background: ${CV.line}; border-radius: 2px; }
.au-tl-item { position: relative; padding: 7px 0; font-size: 13px; color: #475569; line-height: 1.5; }
.au-tl-item::before { content: ''; position: absolute; left: -22px; top: 13px; width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 2px solid #CBD5E1; box-sizing: border-box; }
.au-tl-item[data-tone="status"]::before { border-color: ${CV.blue}; }
.au-tl-item[data-tone="priority"]::before { border-color: #B4233C; }
.au-tl-item[data-tone="assign"]::before { border-color: #17745A; }
.au-tl-item[data-tone="system"]::before { border-color: ${CV.orange}; background: ${CV.orange}; }
@media (max-width: 760px) { .au-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
  .au-btn { padding: 13px 14px; gap: 12px; }
  .au-more { padding: 0 14px 14px 14px; }
  .au-export { width: 100%; justify-content: center; }
  .au-title { font-size: 26px; }
}
`;

// entries written before the address was tidied still carry the ::ffff: form
const auIp = ip => ip ? ip.replace(/^::ffff:/, '') : ip;
const auTone = c => TYPE_PALETTE[AU_TONE[c] || 'slate'];

// "Pradeep changed the status of Login page… from Open to In Progress", as readable text with links
function AuditSentence({ e, tickets, onNav }) {
  const d = e.details || {};
  const B = ({ children }) => <b style={{ color: CV.navy, fontWeight: 800 }}>{children}</b>;
  const who = e.actor_name === 'System' && !e.actor_id ? <B>System</B> : <B>{e.actor_name}</B>;
  const t = e.target_label || 'this item';
  const ticket = e.ticket_id ? tickets.find(x => x.dbId === e.ticket_id) : null;
  const concern = e.ticket_id ? (
    ticket && onNav
      ? <button className="au-link" onClick={() => onNav('ticket_detail', ticket)}>{t}</button>
      : <span><B>{t}</B>{!e.ticket_exists && <span style={{ color: CV.faint }}> (deleted)</span>}</span>
  ) : <B>{t}</B>;
  const list = a => (a || []).join(', ');
  const R = r => AU_ROLE[r] || r;
  switch (e.action) {
    case 'auth.login':              return <>{who} signed in</>;
    case 'auth.login_failed':       return <>Failed sign-in for <B>{t}</B> <span style={{ color: CV.faint }}>({d.reason || 'refused'})</span></>;
    case 'auth.password_changed':   return <>{who} changed their password</>;
    case 'auth.reset_requested':    return <>{who} asked for a password reset link</>;
    case 'auth.reset_completed':    return <>{who} set a new password from a reset link</>;
    case 'user.created':            return <>{who} added <B>{t}</B> as {R(d.role) === 'admin' ? 'an' : 'a'} {R(d.role)}</>;
    case 'user.role_changed':       return <>{who} changed <B>{t}</B> from {R(d.from)} to {R(d.to)}</>;
    case 'user.offboarded':         return <>{who} offboarded <B>{t}</B></>;
    case 'user.reactivated':        return <>{who} reactivated <B>{t}</B></>;
    case 'user.deleted':            return <>{who} deleted the account of <B>{t}</B></>;
    case 'user.password_reset':     return <>{who} reset the password for <B>{t}</B></>;
    case 'user.photo_changed':      return <>{who} changed the photo of <B>{t}</B></>;
    case 'user.photo_removed':      return <>{who} removed the photo of <B>{t}</B></>;
    case 'user.clients_changed':    return <>{who} changed the clients <B>{t}</B> serves{d.added && d.added.length ? <>, added {list(d.added)}</> : null}{d.removed && d.removed.length ? <>, removed {list(d.removed)}</> : null}</>;
    case 'client.created':          return <>{who} added the client <B>{t}</B></>;
    case 'client.renamed':          return <>{who} renamed the client <B>{d.from}</B> to <B>{d.to}</B></>;
    case 'client.deleted':          return <>{who} deleted the client <B>{t}</B></>;
    case 'client.user_added':       return <>{who} added <B>{t}</B> to <B>{d.client || 'a client'}</B></>;
    case 'client.user_removed':     return <>{who} removed <B>{t}</B> from <B>{d.client || 'a client'}</B></>;
    case 'client.developers_changed': return <>{who} changed the developers for <B>{t}</B>{d.added && d.added.length ? <>, added {list(d.added)}</> : null}{d.removed && d.removed.length ? <>, removed {list(d.removed)}</> : null}</>;
    case 'client.logo_changed':     return <>{who} changed the logo of <B>{t}</B></>;
    case 'client.logo_removed':     return <>{who} removed the logo of <B>{t}</B></>;
    case 'type.created':            return <>{who} added the ticket type <B>{t}</B></>;
    case 'type.updated':            return <>{who} changed the ticket type <B>{t}</B>{d.changes ? <>: {Object.entries(d.changes).map(([k, v]) => `${k === 'color' ? 'colour' : k} ${v.from || 'default'} to ${v.to || 'default'}`).join(', ')}</> : null}</>;
    case 'type.deleted':            return <>{who} deleted the ticket type <B>{t}</B></>;
    case 'ticket.created':          return <>{who} raised {concern}{d.type ? <> as {d.type}</> : null}{d.client ? <> for <B>{d.client}</B></> : null}</>;
    case 'ticket.status_changed':   return <>{who} changed the status of {concern} from {d.from} to {d.to}</>;
    case 'ticket.priority_changed': return <>{who} changed the priority of {concern} from {d.from} to {d.to}</>;
    case 'ticket.assigned':         return d.to === 'Unassigned' ? <>{who} unassigned {concern}</> : <>{who} assigned {concern} to <B>{d.to}</B></>;
    case 'ticket.commented':        return <>{who} commented on {concern}</>;
    case 'ticket.deleted':          return <>{who} deleted the concern <B>{t}</B>{d.bulk ? ' (part of a bulk delete)' : ''}</>;
    case 'ticket.escalated':        return <>{who} escalated {concern}{d.hours_overdue != null ? <>, {d.hours_overdue}h past the limit</> : null}</>;
    case 'mail.changed':            return <>{who} saved the email account <B>{t}</B></>;
    case 'mail.removed':            return <>{who} removed the email account <B>{t}</B></>;
    case 'mail.test_sent':          return <>{who} sent a test email to <B>{t}</B></>;
    default:                        return <>{who} {e.action.replace(/[._]/g, ' ')} <B>{t}</B></>;
  }
}

// the facts behind a row, for people who want the detail
function auditFacts(e) {
  const d = e.details || {};
  const rows = [];
  rows.push(['When', new Date(e.created_at).toLocaleString()]);
  rows.push(['Who', e.actor_name + (e.actor_role ? ` (${AU_ROLE[e.actor_role] || e.actor_role})` : e.actor_id ? '' : ' (automatic)')]);
  if (e.target_label) rows.push(['Item', e.target_label]);
  if (e.client_name) rows.push(['Client', e.client_name]);
  if (e.ip) rows.push(['IP address', auIp(e.ip)]);
  const skip = ['ticket_no', 'backfilled'];
  Object.entries(d).filter(([k]) => !skip.includes(k)).forEach(([k, v]) => {
    if (k === 'changes') Object.entries(v).forEach(([f, c]) => rows.push([f, `${c.from ?? 'default'} → ${c.to ?? 'default'}`]));
    else rows.push([k.replace(/_/g, ' '), Array.isArray(v) ? (v.join(', ') || 'none') : typeof v === 'object' && v ? JSON.stringify(v) : String(v)]);
  });
  if (d.ticket_no) rows.push(['Concern no.', d.ticket_no]);
  if (d.backfilled) rows.push(['Note', 'Recorded afterwards from the concern itself']);
  return rows;
}

// repeated failed sign-ins from one place collapse into a single line
function auGroup(items) {
  const out = [];
  for (const e of items) {
    const prev = out[out.length - 1];
    if (e.action === 'auth.login_failed' && prev && prev.kind === 'fails' && prev.actor === e.actor_name && prev.ip === e.ip) { prev.list.push(e); continue; }
    out.push(e.action === 'auth.login_failed' ? { kind: 'fails', actor: e.actor_name, ip: e.ip, list: [e], tone: e.category } : { kind: 'one', e });
  }
  return out;
}

const auDay = d => {
  const x = new Date(d), today = new Date(), y = new Date(); y.setDate(today.getDate() - 1);
  if (x.toDateString() === today.toDateString()) return 'Today';
  if (x.toDateString() === y.toDateString()) return 'Yesterday';
  return x.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
};
const auClock = d => new Date(d).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });

// ── The structured row: Who / What / Change / Client / When ──
// short name for what happened (the "What" column)
const AU_KIND = {
  'auth.login': 'Signed in', 'auth.login_failed': 'Failed sign-in', 'auth.password_changed': 'Password changed',
  'auth.reset_requested': 'Reset link requested', 'auth.reset_completed': 'Password reset',
  'user.created': 'Person added', 'user.role_changed': 'Role changed', 'user.offboarded': 'Person offboarded', 'user.reactivated': 'Person reactivated',
  'user.deleted': 'Account deleted', 'user.password_reset': 'Password reset by admin', 'user.photo_changed': 'Photo changed', 'user.photo_removed': 'Photo removed',
  'user.clients_changed': 'Clients changed',
  'client.created': 'Client added', 'client.renamed': 'Client renamed', 'client.deleted': 'Client deleted', 'client.user_added': 'Client user added',
  'client.user_removed': 'Client user removed', 'client.developers_changed': 'Developers changed', 'client.logo_changed': 'Logo changed', 'client.logo_removed': 'Logo removed',
  'type.created': 'Type added', 'type.updated': 'Type changed', 'type.deleted': 'Type deleted',
  'ticket.created': 'Concern raised', 'ticket.status_changed': 'Status changed', 'ticket.priority_changed': 'Priority changed', 'ticket.assigned': 'Assignment changed',
  'ticket.commented': 'Comment added', 'ticket.deleted': 'Concern deleted', 'ticket.escalated': 'Escalated',
  'mail.changed': 'Email account saved', 'mail.removed': 'Email account removed', 'mail.test_sent': 'Test email sent',
};
const auKind = a => AU_KIND[a] || a.replace(/[._]/g, ' ');
const auCap = t => t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
const AU_PRI = { Urgent: ['#FBE9EC', '#B4233C'], High: ['#FDEEE6', '#B8470F'], Medium: ['#EDF0F4', '#475569'], Low: ['#EDF0F4', '#64748B'] };

function AuPill({ children, bg = '#EDF0F4', fg = '#475569', dot }) {
  return (
    <span className="au-pill" style={{ background: bg, color: fg }}>
      {dot && <span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }} />}{children}
    </span>
  );
}
const AuArrow = () => <span aria-label="to" style={{ color: CV.faint, fontWeight: 700, flexShrink: 0 }}>→</span>;
const AuPair = ({ from, to }) => <span className="au-pair">{from}<span className="au-then"><AuArrow />{to}</span></span>;
const AuGone = () => <AuPill bg="#FBE9EC" fg="#B4233C">Removed<span className="au-long"> for good</span></AuPill>;
const AuStatus = ({ s }) => <AuPill dot={CV_STATUS[s] || CV.faint}>{s}</AuPill>;
const AuPriority = ({ p }) => <AuPill bg={(AU_PRI[p] || AU_PRI.Medium)[0]} fg={(AU_PRI[p] || AU_PRI.Medium)[1]}>{p}</AuPill>;
const AuRole = ({ r }) => <AuPill bg="#EAF1FA" fg="#1A5090">{auCap(AU_ROLE[r] || r)}</AuPill>;
const AuNames = ({ added, removed }) => (
  <>
    {(added || []).map(n => <AuPill key={'a' + n} bg="#E6F4EF" fg="#17745A">+ {n}</AuPill>)}
    {(removed || []).map(n => <AuPill key={'r' + n} bg="#FBE9EC" fg="#B4233C">− {n}</AuPill>)}
  </>
);
const AuMuted = ({ children }) => <span style={{ color: CV.faint, fontSize: 12.5 }}>{children}</span>;

// the "Change" column: the actual before and after, drawn instead of described
function AuditChange({ e }) {
  const d = e.details || {};
  switch (e.action) {
    case 'ticket.status_changed':   return <AuPair from={<AuStatus s={d.from} />} to={<AuStatus s={d.to} />} />;
    case 'ticket.priority_changed': return <AuPair from={<AuPriority p={d.from} />} to={<AuPriority p={d.to} />} />;
    case 'ticket.assigned':         return <AuPair from={<AuPill>{d.from}</AuPill>} to={<AuPill bg="#E6F4EF" fg="#17745A">{d.to}</AuPill>} />;
    case 'ticket.created':          return <>{d.type && <AuPill>{d.type}</AuPill>}{d.priority && <AuPriority p={d.priority} />}</>;
    case 'ticket.commented':        return <AuMuted>{d.chars} characters</AuMuted>;
    case 'ticket.escalated':        return <><AuPill bg="#FDEEE6" fg="#B8470F">{d.hours_overdue}h past the limit</AuPill>{d.notified && d.notified.length ? <AuMuted>{d.notified.length} notified</AuMuted> : null}</>;
    case 'ticket.deleted':          return <AuGone />;
    case 'user.created':            return <AuRole r={d.role} />;
    case 'user.role_changed':       return <AuPair from={<AuRole r={d.from} />} to={<AuRole r={d.to} />} />;
    case 'user.offboarded':
    case 'user.reactivated':        return d.role ? <AuRole r={d.role} /> : null;
    case 'user.deleted':            return <>{d.role && <AuRole r={d.role} />}<AuGone /></>;
    case 'user.clients_changed':
    case 'client.developers_changed': return <AuNames added={d.added} removed={d.removed} />;
    case 'client.renamed':          return <AuPair from={<AuPill>{d.from}</AuPill>} to={<AuPill bg="#E6F4EF" fg="#17745A">{d.to}</AuPill>} />;
    case 'client.deleted':          return <><AuGone />{d.users_deactivated ? <AuMuted>{d.users_deactivated} user{d.users_deactivated > 1 ? 's' : ''} deactivated</AuMuted> : null}</>;
    case 'client.user_added':
    case 'client.user_removed':     return d.client ? <AuPill>{d.client}</AuPill> : null;
    case 'type.updated':            return <>{Object.entries(d.changes || {}).map(([k, v]) => (
                                      <span key={k} className="au-pair"><AuMuted>{k === 'color' ? 'colour' : k}</AuMuted><AuPair from={<AuPill>{v.from || 'default'}</AuPill>} to={<AuPill bg="#E6F4EF" fg="#17745A">{v.to || 'default'}</AuPill>} /></span>
                                    ))}</>;
    case 'type.deleted':            return <AuGone />;
    case 'auth.login_failed':       return <AuPill bg="#FBE9EC" fg="#B4233C">{auCap(d.reason || 'refused')}</AuPill>;
    case 'auth.login':              return e.ip ? <AuMuted>from {auIp(e.ip)}</AuMuted> : null;
    default:                        return <AuMuted>—</AuMuted>;
  }
}

// who did it, and in what capacity
function auActorLine(e) {
  if (!e.actor_id && e.actor_name === 'System') return 'Automatic';
  if (e.action === 'auth.login_failed' && !e.actor_id) return 'Unknown account';
  return e.actor_role ? auCap(AU_ROLE[e.actor_role] || e.actor_role) : '';
}

// what it was done to ("this item"), unless it is just the person themselves
function auObject(e) {
  const d = e.details || {};
  if (e.action === 'auth.login' || e.action === 'auth.password_changed' || e.action === 'auth.reset_completed' || e.action === 'auth.reset_requested') return null;
  if (e.action === 'auth.login_failed') return e.target_label;
  if (e.action === 'client.renamed') return d.to;
  return e.target_label;
}

// actions whose "Change" cell says something; the stacked (narrow) layout only shows a change line for these
const AU_CHANGE_ACTIONS = new Set(['ticket.status_changed', 'ticket.priority_changed', 'ticket.assigned', 'ticket.created', 'ticket.commented', 'ticket.escalated', 'ticket.deleted',
  'user.created', 'user.role_changed', 'user.offboarded', 'user.reactivated', 'user.deleted', 'user.clients_changed', 'client.developers_changed', 'client.renamed', 'client.deleted',
  'client.user_added', 'client.user_removed', 'type.updated', 'type.deleted', 'auth.login_failed']);

function AuditLogView({ tickets = [], onNav }) {
  const [items, setItems]     = React.useState([]);
  const [total, setTotal]     = React.useState(null);
  const [page, setPage]       = React.useState(1);
  const [size, setSize]       = useCvPageSize();
  const [loading, setLoading] = React.useState(true);
  const [error, setError]     = React.useState(null);
  const [open, setOpen]       = React.useState({});             // expanded rows
  const [f, setF]             = React.useState({ category: 'all', q: '', actor_id: '', client_id: '', from: '', to: '' });
  const [people, setPeople]   = React.useState([]);
  const [clients, setClients] = React.useState([]);
  const [exporting, setExporting] = React.useState(false);
  const seq = React.useRef(0);
  const h = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('hub_token') || '') });

  const query = () => {
    const p = new URLSearchParams();
    if (f.category !== 'all') p.set('category', f.category);
    ['q', 'actor_id', 'client_id', 'from', 'to'].forEach(k => f[k] && p.set(k, f[k]));
    return p;
  };

  React.useEffect(() => {
    fetch('/api/users', { headers: h() }).then(r => r.ok ? r.json() : []).then(setPeople);
    fetch('/api/clients', { headers: h() }).then(r => r.ok ? r.json() : []).then(setClients);
  }, []);

  // reload when a filter changes (typing in search waits a moment first)
  React.useEffect(() => {
    const mine = ++seq.current;
    setLoading(true); setError(null);
    const t = setTimeout(async () => {
      try {
        const r = await fetch('/api/audit?' + query() + `&page=${page}&limit=${size}`, { headers: h() });
        const d = await r.json();
        if (mine !== seq.current) return;
        if (!r.ok) throw new Error(d.error || 'Could not load the audit log');
        setItems(d.items); setTotal(d.total); if (d.page !== page) setPage(d.page); setOpen({});
      } catch (err) { if (mine === seq.current) setError(err.message); }
      finally { if (mine === seq.current) setLoading(false); }
    }, f.q ? 300 : 0);
    return () => clearTimeout(t);
  }, [f, page, size]);

  async function exportCsv() {
    setExporting(true);
    try {
      const r = await fetch('/api/audit/export.csv?' + query(), { headers: h() });
      if (!r.ok) throw new Error('Export failed');
      const url = URL.createObjectURL(await r.blob());
      const a = document.createElement('a'); a.href = url; a.download = 'audit-log.csv'; a.click();
      setTimeout(() => URL.revokeObjectURL(url), 2000);
    } catch (err) { setError(err.message); }
    finally { setExporting(false); }
  }

  const set = (k, v) => { setPage(1); setF(cur => ({ ...cur, [k]: v })); };
  const toggle = key => setOpen(o => ({ ...o, [key]: !o[key] }));
  const filtered = f.category !== 'all' || f.q || f.actor_id || f.client_id || f.from || f.to;
  const groups = auGroup(items);
  const days = [];
  groups.forEach(g => {
    const when = g.kind === 'one' ? g.e.created_at : g.list[0].created_at;
    const label = auDay(when);
    if (!days.length || days[days.length - 1].label !== label) days.push({ label, rows: [] });
    days[days.length - 1].rows.push(g);
  });

  const Field = ({ label, children, style }) => (
    <label className="au-field" style={style}><span className="au-label">{label}</span>{children}</label>
  );
  const Chev = ({ isOpen }) => <IC name="chevron_r" size={15} color={CV.faint} style={{ marginLeft: 'auto', flexShrink: 0, transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />;
  const Chip = ({ category }) => {
    const [bg, fg] = auTone(category);
    return <span className="au-chip" style={{ color: fg, background: bg }}>{category === 'types' ? 'Ticket types' : category}</span>;
  };

  return (
    <div className="cv1 au-page">
      <style>{CV_CSS + CV_PAGE_CSS + AU_CSS}</style>

      {/* heading */}
      <header className="au-head">
        <div style={{ minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
            <h2 className="au-title">Audit log</h2>
            {total != null && <span className="au-count">{total.toLocaleString()} {total === 1 ? 'entry' : 'entries'}{filtered ? ' match' : ''}</span>}
          </div>
          <p className="au-sub">A permanent record of what people did in the tool. Entries can never be edited or deleted.</p>
        </div>
        <button className="cv-btn cv-soft au-export" onClick={exportCsv} disabled={exporting}><IC name="download" size={14} />{exporting ? 'Preparing…' : 'Export CSV'}</button>
      </header>

      {/* filters */}
      <section className="cv-panel au-filters" aria-label="Filters">
        <Field label="Search">
          <span style={{ position: 'relative', display: 'block' }}>
            <span style={{ position: 'absolute', left: 11, top: 11, display: 'flex' }}><IC name="search" size={15} color={CV.faint} /></span>
            <input className="cv-input" style={{ width: '100%', boxSizing: 'border-box', paddingLeft: 34 }} placeholder="Search by person or item" value={f.q} onChange={e => set('q', e.target.value)} />
          </span>
        </Field>
        <div className="au-grid">
          <Field label="Person">
            <select className="cv-input" value={f.actor_id} onChange={e => set('actor_id', e.target.value)}>
              <option value="">Anyone</option>
              {people.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </Field>
          <Field label="Client">
            <select className="cv-input" value={f.client_id} onChange={e => set('client_id', e.target.value)}>
              <option value="">Any client</option>
              {clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
            </select>
          </Field>
          <Field label="From">
            <input className="cv-input" type="date" value={f.from} max={f.to || undefined} onChange={e => set('from', e.target.value)} />
          </Field>
          <Field label="To">
            <input className="cv-input" type="date" value={f.to} min={f.from || undefined} onChange={e => set('to', e.target.value)} />
          </Field>
        </div>
        <div className="au-tabrow">
          <div role="tablist" aria-label="Group" className="au-tabs">
            {AU_GROUPS.map(([k, label]) => (
              <button key={k} role="tab" aria-selected={f.category === k} className="cv-btn" onClick={() => set('category', k)}
                style={{ padding: '6px 13px', background: f.category === k ? CV.navy : 'transparent', color: f.category === k ? '#fff' : CV.mute }}>{label}</button>
            ))}
          </div>
          {filtered && <button className="cv-btn cv-ghost" onClick={() => { setPage(1); setF({ category: 'all', q: '', actor_id: '', client_id: '', from: '', to: '' }); }}><IC name="x" size={13} />Clear filters</button>}
        </div>
      </section>

      {error && <div role="alert" className="au-alert">{error}</div>}
      {loading && items.length === 0 && <div style={{ padding: '28px 4px', fontSize: 14, color: CV.faint }}>Loading the audit log…</div>}
      {!loading && !error && items.length === 0 && (
        <div className="cv-panel" style={{ margin: '18px 0', padding: '48px 24px', textAlign: 'center', color: CV.mute, fontSize: 14, lineHeight: 1.6 }}>
          {filtered ? 'Nothing matches these filters.' : 'Nothing has been recorded yet.'}
        </div>
      )}

      {/* the feed, a day at a time */}
      <div className="au-feed" style={{ opacity: loading && items.length ? 0.55 : 1, transition: 'opacity .15s' }}>
        {days.map(day => (
          <section key={day.label} aria-label={day.label}>
            <h3 className="au-day">{day.label}<span className="au-day-n">{day.rows.length} {day.rows.length === 1 ? 'entry' : 'entries'}</span></h3>
            <div className="au-cols" aria-hidden="true"><span>Who</span><span>What</span><span>Change</span><span>Client</span><span style={{ textAlign: 'right' }}>When</span><span /></div>
            <div className="cv-panel au-list">
              {day.rows.map(g => {
                /* repeated failed sign-ins from one place, as a single entry */
                if (g.kind === 'fails') {
                  const key = 'f' + g.list[0].id, isOpen = !!open[key], oldest = g.list[g.list.length - 1], latest = g.list[0];
                  const reason = (latest.details || {}).reason || 'refused';
                  return (
                    <div key={key} className="au-item">
                      <button className="au-btn" aria-expanded={isOpen} onClick={() => toggle(key)}>
                        <span className="au-narrow">
                          <span className="au-ico" style={{ background: '#FEF2F2', color: '#B91C1C' }}><IC name="alert" size={17} /></span>
                          <span className="au-body">
                            <span className="au-line"><b>{g.list.length} failed sign-in{g.list.length > 1 ? 's' : ''}</b> for <b>{g.actor}</b></span>
                            <span className="au-chg"><AuPill bg="#FBE9EC" fg="#B4233C">{auCap(reason)}</AuPill></span>
                            <span className="au-meta">
                              <span className="au-when">{auClock(latest.created_at)}{g.list.length > 1 ? ` (from ${auClock(oldest.created_at)})` : ''}</span>
                              <Chip category="access" />
                              {g.ip && <span className="au-ip">{auIp(g.ip)}</span>}
                              <Chev isOpen={isOpen} />
                            </span>
                          </span>
                        </span>
                        <span className="au-wide">
                          <span className="au-who"><span className="au-av"><span className="au-ico" style={{ background: '#FEF2F2', color: '#B91C1C', width: 32, height: 32 }}><IC name="alert" size={16} /></span></span><span style={{ minWidth: 0 }}><b>{g.actor}</b><small>Not signed in</small></span></span>
                          <span className="au-what"><span className="au-kind"><i style={{ background: auTone('access')[1] }} />Failed sign-in{g.list.length > 1 ? 's' : ''}</span><span className="au-obj">{g.list.length} attempt{g.list.length > 1 ? 's' : ''}{g.ip ? ` from ${auIp(g.ip)}` : ''}</span></span>
                          <span className="au-change"><AuPill bg="#FBE9EC" fg="#B4233C">{auCap(reason)}</AuPill></span>
                          <span className="au-cl au-cl-none"><AuMuted>—</AuMuted></span>
                          <span className="au-when2"><b>{auClock(latest.created_at)}</b><small>{auRelative(latest.created_at)}</small></span>
                          <Chev isOpen={isOpen} />
                        </span>
                      </button>
                      {isOpen && (
                        <div className="au-more">
                          <dl className="au-det">{g.list.map(e => <React.Fragment key={e.id}><dt>{auClock(e.created_at)}</dt><dd>{(e.details || {}).reason || 'refused'}</dd></React.Fragment>)}</dl>
                        </div>
                      )}
                    </div>
                  );
                }
                const e = g.e, key = 'e' + e.id, isOpen = !!open[key];
                const isSystem = !e.actor_id && e.actor_name === 'System';
                const linked = e.ticket_id && e.ticket_exists ? tickets.find(x => x.dbId === e.ticket_id) : null;
                const obj = auObject(e), tone = auTone(e.category)[1];
                const avatar = size => isSystem
                  ? <span className="au-ico" style={{ background: '#FDEEE6', color: CV.orange, width: size, height: size }}><IC name="settings" size={Math.round(size * 0.47)} /></span>
                  : <CvAvatar name={e.actor_name} size={size} id={e.actor_id} />;
                return (
                  <div key={key} className="au-item">
                    <button className="au-btn" aria-expanded={isOpen} aria-label={`Details, ${auKind(e.action)}`} onClick={() => toggle(key)}>
                      {/* narrow screens: a sentence, then the change, then the details line */}
                      <span className="au-narrow">
                        {avatar(36)}
                        <span className="au-body">
                          <span className="au-line"><AuditSentence e={e} tickets={tickets} onNav={null} /></span>
                          {AU_CHANGE_ACTIONS.has(e.action) && <span className="au-chg"><AuditChange e={e} /></span>}
                          <span className="au-meta">
                            <span className="au-when">{auClock(e.created_at)}</span>
                            <Chip category={e.category} />
                            {e.client_name && (
                              <span className="au-client">
                                <ClientLogo clientId={e.client_id} name={e.client_name} hasLogo={e.client_has_logo} version={e.client_logo_version} size={24} radius={12} />{e.client_name}
                              </span>
                            )}
                            <Chev isOpen={isOpen} />
                          </span>
                        </span>
                      </span>
                      {/* wide screens: one row, a column for each question */}
                      <span className="au-wide">
                        <span className="au-who"><span className="au-av">{avatar(32)}</span><span style={{ minWidth: 0 }}><b>{e.actor_name}</b><small>{auActorLine(e)}</small></span></span>
                        <span className="au-what">
                          <span className="au-kind"><i style={{ background: tone }} />{auKind(e.action)}</span>
                          {obj ? <span className="au-obj" title={obj}>{obj}</span> : <span className="au-obj" style={{ color: CV.faint, fontWeight: 600 }}>Own account</span>}
                        </span>
                        <span className="au-change"><AuditChange e={e} /></span>
                        <span className={'au-cl' + (e.client_name ? '' : ' au-cl-none')}>
                          {e.client_name
                            ? <><span className="au-cl-logo"><ClientLogo clientId={e.client_id} name={e.client_name} hasLogo={e.client_has_logo} version={e.client_logo_version} size={34} radius={9} /></span><span className="au-cl-name">{e.client_name}</span></>
                            : <AuMuted>—</AuMuted>}
                        </span>
                        <span className="au-when2" title={new Date(e.created_at).toLocaleString()}><b>{auClock(e.created_at)}</b><small>{auRelative(e.created_at)}</small></span>
                        <Chev isOpen={isOpen} />
                      </span>
                    </button>
                    {isOpen && (
                      <div className="au-more">
                        <dl className="au-det">{auditFacts(e).map(([k, v], i) => <React.Fragment key={i}><dt>{k}</dt><dd>{v}</dd></React.Fragment>)}</dl>
                        {linked && onNav && <button className="cv-btn cv-soft" style={{ marginTop: 10 }} onClick={() => onNav('ticket_detail', linked)}>Open concern<IC name="chevron_r" size={14} /></button>}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          </section>
        ))}
      </div>

      {items.length > 0 && <CvPager total={total || 0} page={page} size={size} onPage={setPage} onSize={n => { setPage(1); setSize(n); }} noun="entries" />}
    </div>
  );
}

// ── Concern activity: this one concern's history, oldest first ──
function ConcernActivity({ events }) {
  const word = e => {
    switch (e.action) {
      case 'ticket.created':          return ['raised this concern', 'status'];
      case 'ticket.status_changed':   return [`changed the status from ${e.from} to ${e.to}`, 'status'];
      case 'ticket.priority_changed': return [`changed the priority from ${e.from} to ${e.to}`, 'priority'];
      case 'ticket.assigned':         return [e.to === 'Unassigned' ? 'unassigned this concern' : `assigned this concern to ${e.to}`, 'assign'];
      case 'ticket.commented':        return ['added a comment', null];
      case 'ticket.escalated':        return ['escalated this concern because it had waited too long', 'system'];
      default:                        return [e.action.replace(/[._]/g, ' '), null];
    }
  };
  if (!events) return <div style={{ padding: '18px 22px', fontSize: 13, color: '#94A3B8' }}>Loading history…</div>;
  if (events.length === 0) return <div style={{ padding: '18px 22px', fontSize: 13, color: '#94A3B8' }}>No history recorded yet.</div>;
  return (
    <div style={{ padding: '14px 22px 16px' }}>
      <style>{AU_CSS}</style>
      <div className="au-tl">
        {events.map(e => {
          const [text, tone] = word(e);
          const system = e.actor_name === 'System';
          return (
            <div key={e.id} className="au-tl-item" data-tone={tone || undefined}>
              <b style={{ color: '#0F172A', fontWeight: 700 }}>{system ? 'System' : e.actor_name}</b> {text}
              <span title={new Date(e.created_at).toLocaleString()} style={{ color: '#94A3B8', fontSize: 12, marginLeft: 8, whiteSpace: 'nowrap' }}>{auRelative(e.created_at)}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function auRelative(ts) {
  const ms = Date.now() - new Date(ts).getTime();
  if (!isFinite(ms)) return '';
  const m = Math.floor(ms / 60000), h = Math.floor(ms / 3600000), d = Math.floor(ms / 86400000);
  if (m < 1) return 'just now';
  if (m < 60) return m + 'm ago';
  if (h < 24) return h + 'h ago';
  if (d < 7) return d + 'd ago';
  return new Date(ts).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}

Object.assign(window, { AuditLogView, ConcernActivity });
