/* project-board.jsx — Board tab for Project Detail: swimlane Kanban + List view.
   ─────────────────────────────────────────────────────────────────────────────
   Columns = statuses · Lanes = clusters (categories) · Cards = tickets.
   Drag a card into any cell to change BOTH its status (column) and cluster (lane).

   <ProjectBoardTab> is the tab body. It hosts a List / Board segmented toggle:
     • List  → the existing window.ProjectTicketsPanel with shared edit drawer
     • Board → <TicketsBoard> below
   Both read/write the shared planning draft (see project-planning-state.jsx), so
   edits on either survive a tab switch and save through the one planning Save bar.

   Requires: window.Icon, window.Panel, window.ProjectTicketsPanel,
             window.AC.PROJECT_TICKET_STATUSES, and project-board.css.
   Exports:  window.ProjectTicketEditDrawer, window.TicketsBoard, window.ProjectBoardTab
   ───────────────────────────────────────────────────────────────────────────── */
(function () {
  const { useState, useRef, useEffect } = React;

  /* Status → color system. Reuses project-tickets.jsx's exported palette when present
     so the pills/dots stay identical, with an inline fallback. */
  const STATUS_META = window.PROJECT_TICKET_STATUS_META || {
    'Open':          { fg: '#64748b', bg: '#f1f4f9', bd: '#d4dbe6', dot: '#94a3b8' },
    'In progress':    { fg: '#1e429f', bg: '#eaf0fb', bd: '#c5d6f5', dot: '#2856c4' },
    'Partially done': { fg: '#b45309', bg: '#fbf1e3', bd: '#f0d9b8', dot: '#d08a3a' },
    'Missing info':   { fg: '#0e7490', bg: '#e7f2f5', bd: '#c2dee5', dot: '#0e9bb8' },
    'Blocked':        { fg: '#c0392b', bg: '#fcecea', bd: '#f0c7c1', dot: '#d65648' },
    'Done':           { fg: '#15803d', bg: '#e7f5ec', bd: '#bfe3cc', dot: '#1f9a64' },
  };
  const META = (s) => STATUS_META[s] || STATUS_META['Open'];
  const gid = () => (window.crypto && crypto.randomUUID ? crypto.randomUUID()
    : '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
      (Number(c) ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> Number(c) / 4).toString(16)));
  function commitLabel(link) {
    const label = String((link && link.label) || '').trim();
    if (label) return label;
    const url = String((link && link.url) || '').trim();
    const match = url.match(/\/commit\/([0-9a-f]{7,40})/i) || url.match(/\b([0-9a-f]{7,40})\b/i);
    return match ? match[1].slice(0, 12) : url;
  }
  function threadTime(value) {
    if (!value) return '';
    const date = new Date(value);
    if (Number.isNaN(date.getTime())) return '';
    return new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }).format(date);
  }

  /* ───────────────────────── card ───────────────────────── */
  function Card({ project, ticket, milestones, execution, testRun, testing, onRunTests, onRespondApproval, onAction, onDragStart, onDragEnd, onOpen, dragging }) {
    const m = META(ticket.status);
    const ms = ticket.milestoneId && milestones.find((x) => x.id === ticket.milestoneId);
    const missingMs = ticket.milestoneId && !ms;
    const [docOver, setDocOver] = useState(false);
    const docTarget = { ticketId: ticket.id };
    const dropProps = window.projectDocumentDropProps ? window.projectDocumentDropProps(project, onAction, docTarget, setDocOver) : {};
    const number = Number(ticket.number) || 0;
    const category = window.AC.cleanProjectTicketCategory(ticket.category);
    const linkCount = (ticket.gitCommitLinks || []).filter((link) => link && link.url).length;
    const messageCount = (ticket.messages || []).filter((message) => message && message.body).length;
    const testBusy = testRun && ['Queued', 'Running'].includes(testRun.status);
    const canRunTests = testing && testing.defaultSuite && (testing.primaryEnvironment || project.projectAppUrl);
    const runTests = (event) => {
      event.stopPropagation();
      if (!testBusy && canRunTests && onRunTests) onRunTests(ticket.id);
    };
    return (
      <div
        className={'kb-card' + (ticket.status === 'Done' ? ' kb-done' : '') + (dragging ? ' kb-dragging' : '') + (docOver ? ' doc-over' : '')}
        style={{ '--kb-accent': m.dot }}
        draggable
        {...dropProps}
        onDragStart={(e) => onDragStart(e, ticket)}
        onDragEnd={onDragEnd}
        onClick={() => onOpen(ticket)}
        role="button" tabIndex={0}
        onKeyDown={(e) => { if (e.key === 'Enter') onOpen(ticket); }}
        title="Drag to move · click to edit"
      >
        <div className="kb-card-head">
          <span className="kb-card-number">{number > 0 ? number : '—'}</span>
          <div className="kb-card-title">{ticket.title || 'Untitled ticket'}</div>
        </div>
        {(category || ms || missingMs || linkCount > 0 || messageCount > 0 || execution) && (
          <div className="kb-card-meta">
            {execution && <ExecutionChip execution={execution} ticketId={ticket.id} onRespondApproval={onRespondApproval} />}
            {onRunTests && <button className={'kb-chip kb-exec-chip' + (testRun && testRun.status === 'Failed' ? ' error' : '')} type="button" disabled={testBusy || !canRunTests} onClick={runTests} title={canRunTests ? 'Run tests for this ticket' : 'Create a test suite and target before running tests'}>
              {testBusy ? <span className="pd-test-spinner" aria-hidden="true"></span> : <Icon name={testRun && testRun.status === 'Passed' ? 'check' : 'server'} size={11} />}
              <span>{testBusy ? testRun.status : testRun && testRun.status ? testRun.status : 'Tests'}</span>
            </button>}
            <span className="kb-chip" title={'Category: ' + window.AC.projectTicketCategoryLabel(category, project)}><Icon name="tag" size={11} /><span>{window.AC.projectTicketCategoryLabel(category, project)}</span></span>
            {ms && <span className="kb-chip" title={'Milestone: ' + ms.title}><span className="kb-dia" /><span>{ms.title}</span></span>}
            {!ticket.milestoneId && <span className="kb-chip kb-chip-empty" title="No milestone assigned"><span className="kb-dia" /><span>No milestone</span></span>}
            {missingMs && <span className="kb-chip kb-chip-missing" title="Assigned milestone is not in this feature"><span className="kb-dia" /><span>Missing milestone</span></span>}
            {linkCount > 0 && <span className="kb-chip" title="Git commit links"><Icon name="link" size={11} /><span>{linkCount}</span></span>}
            {messageCount > 0 && <span className="kb-chip" title="Messages"><Icon name="doc" size={11} /><span>{messageCount}</span></span>}
          </div>
        )}
      </div>
    );
  }

  function ExecutionChip({ execution, ticketId, onRespondApproval }) {
    const busy = execution && ['Queued', 'Running'].includes(execution.status);
    const done = execution && execution.status === 'Completed';
    const approval = execution && execution.approvalRequest && !execution.approvalRequest.response ? execution.approvalRequest : null;
    const canOpen = execution && execution.appUrl && !busy;
    const label = canOpen ? 'Open thread' : busy ? execution.status : done ? 'Executed' : 'Failed';
    const openThread = (event) => {
      event.stopPropagation();
      if (execution.appUrl) window.open(execution.appUrl, '_blank', 'noreferrer');
    };
    const respond = (event, decision) => {
      event.stopPropagation();
      if (approval && onRespondApproval) onRespondApproval(ticketId, approval.requestId, decision);
    };
    if (approval) {
      return (
        <span className="kb-chip kb-exec-chip approval" title={approval.body || approval.title || 'Codex approval required'}>
          <Icon name="alert" size={11} />
          <span>Approval needed</span>
          <button type="button" className="kb-exec-action" onClick={(event) => respond(event, 'accept')}>Allow</button>
          <button type="button" className="kb-exec-action" onClick={(event) => respond(event, 'decline')}>Decline</button>
        </span>
      );
    }
    if (canOpen) {
      return (
        <button className={'kb-chip kb-exec-chip' + (done ? ' done' : '')} type="button" onClick={openThread} title="Open Codex thread">
          {busy ? <span className="pd-test-spinner" aria-hidden="true"></span> : <Icon name="external" size={11} />}
          <span>{label}</span>
        </button>
      );
    }
    return (
      <span className={'kb-chip kb-exec-chip' + (done ? ' done' : '') + (!busy && !done ? ' error' : '')} title={execution.error || label}>
        {busy ? <span className="pd-test-spinner" aria-hidden="true"></span> : <Icon name={done ? 'check' : 'alert'} size={11} />}
        <span>{label}</span>
      </span>
    );
  }

  /* ───────────────────────── shared edit drawer ───────────────────────── */
  function ProjectTicketEditDrawer({ project, target, categories, milestones, requirements, statuses, execution, testRun, testing, onExecute, onRunTests, onRespondApproval, onAction, onClose, onSave, onMove, onSaveAndPersist, onMoveAndPersist, onRemove }) {
    const cat = target && categories.find((c) => c.id === target.catId);
    const item = cat && cat.items.find((i) => i.id === target.ticketId);
    const [draft, setDraft] = useState(item || null);
    const [newMessage, setNewMessage] = useState('');
    const [saving, setSaving] = useState(false);
    useEffect(() => { setDraft(item ? { ...item, _catId: target.catId } : null); }, [target && target.ticketId, target && target.catId]);
    useEffect(() => { setNewMessage(''); }, [target && target.ticketId]);
    if (!target || !item || !draft) return null;
    const set = (patch) => setDraft((d) => ({ ...d, ...patch }));
    const links = Array.isArray(draft.gitCommitLinks) ? draft.gitCommitLinks : [];
    const messages = Array.isArray(draft.messages) ? draft.messages : [];
    const reqs = Array.isArray(requirements) ? requirements : [];
    const categoryOptions = window.AC.projectTicketCategoryOptions(project);
    const currentRequirement = reqs.find((requirement) => requirement.id === draft.requirementId);
    const addCommitLink = () => set({ gitCommitLinks: [...links, { id: gid(), position: links.length, url: '', label: '' }] });
    const updateCommitLink = (index, patch) => set({ gitCommitLinks: links.map((link, i) => i === index ? { ...link, ...patch } : link) });
    const removeCommitLink = (index) => set({ gitCommitLinks: links.filter((_, i) => i !== index) });
    const addMessage = () => {
      const body = newMessage.trim();
      if (!body) return;
      set({
        messages: [...messages, { id: gid(), position: messages.length, author: 'web', body, createdAt: new Date().toISOString() }],
      });
      setNewMessage('');
    };
    const removeMessage = (index) => set({ messages: messages.filter((_, i) => i !== index) });
    const draftBody = () => ({
        title: draft.title, status: draft.status, done: draft.status === 'Done',
        category: window.AC.cleanProjectTicketCategory(draft.category),
        remarks: draft.remarks, after: draft.after, milestoneId: draft.milestoneId, requirementId: draft.requirementId,
        gitCommitLinks: links, messages,
      });
    const applyDraft = () => {
      const patch = draftBody();
      if (draft._catId && draft._catId !== target.catId) {
        onMove(target.catId, target.ticketId, draft._catId, draft.status, patch);
      } else {
        onSave(target.catId, target.ticketId, patch);
      }
      return patch;
    };
    const save = async () => {
      const patch = draftBody();
      setSaving(true);
      try {
        if (draft._catId && draft._catId !== target.catId) {
          const move = onMoveAndPersist || onMove;
          await move(target.catId, target.ticketId, draft._catId, draft.status, patch);
        } else {
          const persist = onSaveAndPersist || onSave;
          await persist(target.catId, target.ticketId, patch);
        }
        onClose();
      } finally {
        setSaving(false);
      }
    };
    const execute = async () => {
      const body = applyDraft();
      if (onExecute) await onExecute(target.ticketId, body);
    };
    const runTests = async () => {
      applyDraft();
      if (onRunTests) await onRunTests(target.ticketId);
    };
    const configuredStatuses = statuses && statuses.length ? statuses : Object.keys(STATUS_META);
    const statusOptions = configuredStatuses.includes(draft.status) ? configuredStatuses : [draft.status, ...configuredStatuses];
    const executionBusy = execution && ['Queued', 'Running'].includes(execution.status);
    const executionLabel = executionBusy ? execution.status : execution && execution.status === 'Completed' ? 'Done' : 'Execute';
    const testBusy = testRun && ['Queued', 'Running'].includes(testRun.status);
    const suiteCount = testing && testing.suites ? testing.suites.length : 0;
    const hasTestTarget = testing && testing.defaultSuite && (testing.primaryEnvironment || project.projectAppUrl);
    const testLabel = testBusy ? testRun.status : 'Run tests';
    const approval = execution && execution.approvalRequest && !execution.approvalRequest.response ? execution.approvalRequest : null;
    const respondApproval = (decision) => {
      if (approval && onRespondApproval) onRespondApproval(target.ticketId, approval.requestId, decision);
    };
    return (
      <React.Fragment>
        <div className="overlay kb-drawer-overlay" onClick={onClose} />
        <aside className="drawer kb-drawer" role="dialog" aria-label="Edit ticket">
          <header>
            <div style={{ flex: 1, minWidth: 0 }}>
              <h2>Edit ticket</h2>
              <div className="sub">{(cat && cat.name) || 'Ticket'}</div>
            </div>
            {onExecute && <button className="btn sm" type="button" onClick={execute} disabled={executionBusy} title="Execute this ticket in Codex">
              {executionBusy ? <span className="pd-test-spinner" aria-hidden="true"></span> : <Icon name={execution && execution.status === 'Completed' ? 'check' : 'server'} size={14} />}
              {executionLabel}
            </button>}
            {onRunTests && <button className="btn sm ghost" type="button" onClick={runTests} disabled={testBusy || !hasTestTarget} title={suiteCount ? 'Run the default test suite for this ticket' : 'Create a test suite before running ticket tests'}>
              {testBusy ? <span className="pd-test-spinner" aria-hidden="true"></span> : <Icon name="check" size={14} />}
              {testLabel}
            </button>}
            <button className="icon-btn" onClick={onClose}><Icon name="close" size={17} /></button>
          </header>
          {execution && execution.error && <div className="kb-exec-error">{execution.error}</div>}
          {testRun && (testRun.error || testRun.message) && <div className="kb-exec-error">{testRun.error || testRun.message}</div>}
          {approval && (
            <div className="kb-exec-approval">
              <strong>{approval.title || 'Codex approval required'}</strong>
              {approval.body && <pre>{approval.body}</pre>}
              <span>
                <button className="btn sm" type="button" onClick={() => respondApproval('accept')}>Allow</button>
                <button className="btn sm ghost" type="button" onClick={() => respondApproval('decline')}>Decline</button>
              </span>
            </div>
          )}
          <div className="body">
            <div className="kb-drawer-grid">
              <div className="kb-drawer-main">
                <div className="field">
                  <label>Title</label>
                  <input className="inp" autoFocus value={draft.title} onChange={(e) => set({ title: e.target.value })} placeholder="What needs doing?" />
                </div>
                <div className="field">
                  <label>Status — column</label>
                  <select className="inp" value={draft.status} onChange={(e) => set({ status: e.target.value })}>
                    {statusOptions.map((s) => <option key={s} value={s}>{s}</option>)}
                  </select>
                </div>
                <div className="field">
                  <label>Category</label>
                  <select className="inp" value={window.AC.cleanProjectTicketCategory(draft.category)} onChange={(e) => set({ category: e.target.value })}>
                    {categoryOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
                  </select>
                </div>
                <div className="field-row">
                  <div className="field">
                    <label>Cluster — lane</label>
                    <select className="inp" value={draft._catId || target.catId} onChange={(e) => set({ _catId: e.target.value })}>
                      {categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
                    </select>
                  </div>
                  <div className="field">
                    <label>Milestone</label>
                    <select className="inp" value={draft.milestoneId || ''} onChange={(e) => set({ milestoneId: e.target.value })}>
                      <option value="">None</option>
                      {milestones.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
                    </select>
                  </div>
                </div>
                <div className="field">
                  <label>Requirement</label>
                  <select className="inp" value={draft.requirementId || ''} onChange={(e) => set({ requirementId: e.target.value })}>
                    <option value="">None</option>
                    {draft.requirementId && !currentRequirement && <option value={draft.requirementId}>Missing requirement</option>}
                    {reqs.map((requirement) => <option key={requirement.id} value={requirement.id}>{requirement.title || 'Untitled requirement'}</option>)}
                  </select>
                </div>
                <div className="field">
                  <label>Remarks</label>
                  <textarea className="inp" rows="5" value={draft.remarks || ''} onChange={(e) => set({ remarks: e.target.value })} placeholder="Implementation notes, context, handoff details…" />
                </div>
                <div className="field">
                  <label>After / dependency</label>
                  <input className="inp" value={draft.after || ''} onChange={(e) => set({ after: e.target.value })} placeholder="e.g. Cart service contract" />
                </div>
                <div className="field kb-thread-section">
                  <div className="kb-section-head">
                    <label>Git commits</label>
                    <button className="btn sm ghost" type="button" onClick={addCommitLink}><Icon name="plus" size={13} />Commit</button>
                  </div>
                  <div className="kb-link-list">
                    {links.map((link, index) => (
                      <div className="kb-link-row" key={link.id || index}>
                        <input className="inp" value={link.url || ''} onChange={(e) => updateCommitLink(index, { url: e.target.value })} placeholder="https://github.com/org/repo/commit/..." />
                        <input className="inp" value={link.label || ''} onChange={(e) => updateCommitLink(index, { label: e.target.value })} placeholder="Label" />
                        {link.url ? <a className="icon-btn" href={link.url} target="_blank" rel="noreferrer" title={commitLabel(link)} onClick={(e) => e.stopPropagation()}><Icon name="external" size={14} /></a> : <span className="icon-btn disabled"><Icon name="link" size={14} /></span>}
                        <button className="icon-btn" type="button" title="Remove commit link" onClick={() => removeCommitLink(index)}><Icon name="trash" size={14} /></button>
                      </div>
                    ))}
                    {links.length === 0 && <span className="muted" style={{ fontSize: 12 }}>No commit links.</span>}
                  </div>
                </div>
                {window.ProjectDocumentArea && (
                  <div className="field">
                    <window.ProjectDocumentArea project={project} target={{ ticketId: item.id }} onAction={onAction} compact />
                  </div>
                )}
              </div>
              <div className="field kb-thread-section kb-drawer-messages">
                <div className="kb-section-head">
                  <label>Messages</label>
                  <span className="muted" style={{ fontSize: 12 }}>{messages.length} message{messages.length === 1 ? '' : 's'}</span>
                </div>
                <div className="kb-message-list">
                  {messages.map((message, index) => (
                    <div className="kb-message" key={message.id || index}>
                      <div className="kb-message-meta">
                        <strong>{message.author || 'web'}</strong>
                        <span>{threadTime(message.createdAt)}</span>
                        <button className="icon-btn" type="button" title="Remove message" onClick={() => removeMessage(index)}><Icon name="trash" size={13} /></button>
                      </div>
                      <div className="kb-message-body">{message.body}</div>
                    </div>
                  ))}
                  {messages.length === 0 && <span className="muted" style={{ fontSize: 12 }}>No messages.</span>}
                </div>
                <div className="kb-message-compose">
                  <textarea className="inp" rows="3" value={newMessage} onChange={(e) => setNewMessage(e.target.value)} placeholder="Add a message to the ticket thread..." />
                  <button className="btn sm" type="button" onClick={addMessage} disabled={!newMessage.trim()}><Icon name="plus" size={13} />Add message</button>
                </div>
              </div>
            </div>
          </div>
          <footer style={{ justifyContent: 'space-between' }}>
            <button className="btn ghost" onClick={() => { onRemove(target.catId, target.ticketId); onClose(); }} style={{ color: 'var(--neg)' }}>
              <Icon name="trash" size={14} />Delete
            </button>
            <span style={{ flex: 1 }} />
            <button className="btn ghost" onClick={onClose} disabled={saving}>Cancel</button>
            <button className="btn" onClick={save} disabled={saving}><Icon name="check" size={14} />{saving ? 'Saving…' : 'Save'}</button>
          </footer>
        </aside>
      </React.Fragment>
    );
  }

  /* ───────────────────────── board ───────────────────────── */
  function TicketsBoard({ project, categories, statuses, milestones, requirements, executionsByTicket, testRunsByTicket, testing, onExecuteTicket, onRunTicketTests, onRespondApproval, onAction, onMoveCard, onPatch, onSaveAndPersist, onMoveAndPersist, onAddTicket, onRemove, onRemoveCluster }) {
    const [drag, setDrag] = useState(null);          // { ticketId, fromCatId }
    const [over, setOver] = useState(null);          // `${catId}|${status}`
    const [editing, setEditing] = useState(null);    // { catId, ticketId }
    const [msFilter, setMsFilter] = useState('all'); // 'all' | milestoneId | 'none'
    const [hideDone, setHideDone] = useState(false);
    const [collapsed, setCollapsed] = useState(() => new Set());
    const dragRef = useRef(null);

    const seenStatuses = [];
    categories.forEach((c) => (c.items || []).forEach((i) => {
      const status = i.status || 'Open';
      if (!seenStatuses.includes(status)) seenStatuses.push(status);
    }));
    const cols = [...statuses, ...seenStatuses.filter((s) => !statuses.includes(s))]
      .filter((s) => !(hideDone && s === 'Done'));

    const hasVisibleMilestone = (it) => it.milestoneId && milestones.some((m) => m.id === it.milestoneId);
    const matchMs = (it) =>
      msFilter === 'all' ? true :
      msFilter === 'none' ? !hasVisibleMilestone(it) :
      it.milestoneId === msFilter;

    const onDragStart = (e, ticket, fromCatId) => {
      dragRef.current = { ticketId: ticket.id, fromCatId };
      setDrag({ ticketId: ticket.id, fromCatId });
      document.body.classList.add('kb-dragging');
      try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ticket.id); } catch (_) {}
    };
    const onDragEnd = () => { setDrag(null); setOver(null); document.body.classList.remove('kb-dragging'); dragRef.current = null; };

    const onCellDrop = (toCatId, status) => {
      const d = dragRef.current; if (!d) return;
      onMoveCard(d.fromCatId, d.ticketId, toCatId, status);
      onDragEnd();
    };

    // column totals (respecting milestone filter)
    const colCount = (status) => categories.reduce((n, c) => n + c.items.filter((i) => i.status === status && matchMs(i)).length, 0);

    const totalCount = categories.reduce((n, c) => n + c.items.length, 0);
    const noMilestoneCount = categories.reduce((n, c) => n + c.items.filter((i) => !hasVisibleMilestone(i)).length, 0);
    const colCounts = Object.fromEntries(cols.map((s) => [s, colCount(s)]));
    const boardColumns = `var(--rail-w, 170px) ${cols.map((s) => colCounts[s] === 0 ? 'max-content' : 'minmax(var(--kb-card-w), 1fr)').join(' ')}`;

    return (
      <div className="kb">
        <div className="kb-bar">
          <div className="kb-filt">
            <span className="kb-fl">Milestone</span>
            <select className="kb-msfilter" value={msFilter} onChange={(e) => setMsFilter(e.target.value)}>
              <option value="all">All tickets ({totalCount})</option>
              {milestones.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
              <option value="none">No milestone ({noMilestoneCount})</option>
            </select>
          </div>
          <span className="kb-spacer" />
          <label className="kb-toggle">
            <input type="checkbox" checked={hideDone} onChange={(e) => setHideDone(e.target.checked)} />
            Hide “Done” column
          </label>
        </div>

        <div className="kb-scroll">
          <div className={'kb-grid' + (hideDone ? ' kb-hide-done' : '')} style={{ '--cols': cols.length, gridTemplateColumns: boardColumns }}>
            {/* header row */}
            <div className="kb-corner"><span className="kb-corner-l">Cluster ╲ Status</span></div>
            {cols.map((s) => {
              const m = META(s);
              const emptyStatus = colCounts[s] === 0;
              return (
                <div key={s} className={'kb-colhead' + (s === 'Done' ? ' kb-done-col' : '') + (emptyStatus ? ' kb-empty-status' : '')}>
                  <span className="kb-sdot" style={{ background: m.dot, '--kb-dot-ring': m.bg }} />
                  <span className="kb-cname">{s}</span>
                  {!emptyStatus && <span className="kb-ccount">{colCounts[s]}</span>}
                </div>
              );
            })}

            {/* lanes */}
            {categories.map((cat) => {
              const laneItems = cat.items.filter(matchMs);
              const total = laneItems.length;
              const done = laneItems.filter((i) => i.status === 'Done').length;
              const pct = total ? done / total : 0;
              const canDeleteCluster = cat.items.length === 0;
              const isCollapsed = collapsed.has(cat.id);
              const toggleCollapsed = () => setCollapsed((prev) => {
                const next = new Set(prev);
                if (next.has(cat.id)) next.delete(cat.id);
                else next.add(cat.id);
                return next;
              });
              return (
                <div className="kb-lane" key={cat.id}>
                  <div className={'kb-rail' + (isCollapsed ? ' kb-collapsed' : '')}>
                    <div className="kb-rail-head">
                      <button className="icon-btn kb-rail-collapse" title={isCollapsed ? 'Expand cluster' : 'Collapse cluster'} onClick={toggleCollapsed}>
                        <Icon name="chevR" size={13} className={isCollapsed ? '' : 'open'} />
                      </button>
                      <span className="kb-rail-glyph" />
                      <span className={'kb-rail-name' + (cat.name ? '' : ' ghosted')} title={cat.name || 'Untitled cluster'}>{cat.name || 'Untitled cluster'}</span>
                      <button className="icon-btn kb-rail-delete" title={canDeleteCluster ? 'Delete empty cluster' : 'Only empty clusters can be deleted'} disabled={!canDeleteCluster} onClick={() => onRemoveCluster(cat.id)}>
                        <Icon name="trash" size={13} />
                      </button>
                    </div>
                    <div className="kb-rail-meta">
                      <span className="kb-rail-count">{done}/{total}</span>
                      <span className="kb-rail-bar"><span style={{ width: (pct * 100) + '%', background: total && done === total ? 'var(--pos)' : 'var(--primary)' }} /></span>
                    </div>
                    <button className="kb-rail-add" onClick={() => {
                      const id = onAddTicket(cat.id);
                      if (id) setEditing({ catId: cat.id, ticketId: id });
                    }}><Icon name="plus" size={13} />Add ticket</button>
                  </div>

                  {cols.map((s) => {
                    const cell = laneItems.filter((i) => i.status === s);
                    const key = cat.id + '|' + s;
                    const isOver = over === key;
                    const emptyStatus = colCounts[s] === 0;
                    return (
                      <div
                        key={s}
                        className={'kb-cell' + (s === 'Done' ? ' kb-done-col' : '') + (emptyStatus ? ' kb-empty-status' : '') + (cell.length === 0 ? ' kb-empty' : '') + (isOver ? ' kb-over' : '') + (isCollapsed ? ' kb-collapsed' : '')}
                        onDragOver={(e) => { if (dragRef.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (over !== key) setOver(key); } }}
                        onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setOver((o) => (o === key ? null : o)); }}
                        onDrop={(e) => { e.preventDefault(); onCellDrop(cat.id, s); }}
                      >
                        {!isCollapsed && cell.map((ticket) => (
                          <Card key={ticket.id} project={project} ticket={ticket} milestones={milestones} execution={executionsByTicket && executionsByTicket[ticket.id]} testRun={testRunsByTicket && testRunsByTicket[ticket.id]} testing={testing} onRunTests={onRunTicketTests} onRespondApproval={onRespondApproval} onAction={onAction}
                            dragging={drag && drag.ticketId === ticket.id}
                            onDragStart={(e, t) => onDragStart(e, t, cat.id)}
                            onDragEnd={onDragEnd}
                            onOpen={() => setEditing({ catId: cat.id, ticketId: ticket.id })} />
                        ))}
                        {!isCollapsed && <div className="kb-cell-hint"><Icon name="plus" size={12} />Drop here</div>}
                      </div>
                    );
                  })}
                </div>
              );
            })}
          </div>
        </div>

        <ProjectTicketEditDrawer project={project} target={editing} categories={categories} milestones={milestones} requirements={requirements || []} statuses={statuses} execution={editing && executionsByTicket && executionsByTicket[editing.ticketId]} testRun={editing && testRunsByTicket && testRunsByTicket[editing.ticketId]} testing={testing} onExecute={onExecuteTicket} onRunTests={onRunTicketTests} onRespondApproval={onRespondApproval} onAction={onAction}
          onClose={() => setEditing(null)} onSave={onPatch} onMove={onMoveCard} onSaveAndPersist={onSaveAndPersist} onMoveAndPersist={onMoveAndPersist} onRemove={onRemove} />
      </div>
    );
  }

  /* ───────────────────────── Board tab (List / Board toggle) ───────────────────────── */
  function ProjectBoardTab({ planning, project, statuses, onAction, Inline }) {
    const [view, setView] = useState('board'); // 'board' | 'list'
    const [executionsByTicket, setExecutionsByTicket] = useState({});
    const [testRunsByTicket, setTestRunsByTicket] = useState({});
    const [testing, setTesting] = useState({ suites: [], environments: [], defaultSuite: null, primaryEnvironment: null });
    const active = planning.active;
    const cats = active ? planning.cats : [];
    const sts = statuses || window.AC.PROJECT_TICKET_STATUSES;
    const showSoftwareFields = window.AC.projectHasSoftwareFields(project);
    // milestones scoped to the active feature (same rule as the Planning tab)
    const ms = active ? (project.milestones || []).filter((m) =>
      m.featureId === active.id || (!m.featureId && active.position === 0)) : [];
    const requirements = (active ? active.requirementItems || [] : []).map((item) => ({
      ...item,
      featureId: active.id,
    }));
    const total = cats.reduce((n, c) => n + c.items.length, 0);
    const done = cats.reduce((n, c) => n + c.items.filter((i) => i.status === 'Done').length, 0);
    const setTicketExecution = (ticketId, patch) => setExecutionsByTicket((current) => {
      const previous = current[ticketId] || {};
      const next = { ...previous, ...patch };
      const same = Object.keys(next).every((key) => next[key] === previous[key])
        && Object.keys(previous).every((key) => next[key] === previous[key]);
      return same ? current : { ...current, [ticketId]: next };
    });
    const setTicketTestRun = (ticketId, patch) => setTestRunsByTicket((current) => {
      const previous = current[ticketId] || {};
      const next = { ...previous, ...patch };
      const same = Object.keys(next).every((key) => next[key] === previous[key])
        && Object.keys(previous).every((key) => next[key] === previous[key]);
      return same ? current : { ...current, [ticketId]: next };
    });
    const applyTestingDashboard = React.useCallback((dashboard) => {
      const suites = (dashboard && dashboard.suites) || [];
      const environments = (dashboard && dashboard.environments) || [];
      setTesting({
        suites,
        environments,
        defaultSuite: suites.find((suite) => String(suite.status || '').toLowerCase() !== 'archived') || null,
        primaryEnvironment: environments[0] || null,
      });
    }, []);
    const approvalFromJob = (job) => job && job.approvalRequestId ? {
      requestId: job.approvalRequestId,
      method: job.approvalRequestMethod || '',
      title: job.approvalRequestTitle || '',
      body: job.approvalRequestBody || '',
      payloadJson: job.approvalRequestPayloadJson || '',
      response: job.approvalResponse || '',
    } : null;
    const executeTicket = async (ticketId, body, localError) => {
      if (localError) {
        setTicketExecution(ticketId, { status: 'Error', error: localError });
        return;
      }

      setTicketExecution(ticketId, { status: 'Queued', error: '', approvalRequest: null });
      try {
        const result = await window.API.startProjectTicketCodex(project.id, ticketId, body || {});
        setTicketExecution(ticketId, { jobId: result.jobId, status: result.status || 'Queued', error: '', appUrl: result.appUrl || '', approvalRequest: null });
      } catch (error) {
        setTicketExecution(ticketId, { status: 'Error', error: error.message || String(error) });
      }
    };
    const runTicketTests = async (ticketId) => {
      let context = testing;
      if (!context.defaultSuite) {
        try {
          const dashboard = await window.API.getProjectTesting(project.id);
          const suites = (dashboard && dashboard.suites) || [];
          const environments = (dashboard && dashboard.environments) || [];
          context = {
            suites,
            environments,
            defaultSuite: suites.find((suite) => String(suite.status || '').toLowerCase() !== 'archived') || null,
            primaryEnvironment: environments[0] || null,
          };
          setTesting(context);
        } catch (error) {
          setTicketTestRun(ticketId, { status: 'Error', error: error.message || String(error) });
          return;
        }
      }

      if (!context.defaultSuite) {
        setTicketTestRun(ticketId, { status: 'Error', error: 'Create a test suite before running tests for this ticket.' });
        return;
      }

      if (!context.primaryEnvironment && !project.projectAppUrl) {
        setTicketTestRun(ticketId, { status: 'Error', error: 'Add a test environment or project app URL before running tests.' });
        return;
      }

      setTicketTestRun(ticketId, { status: 'Queued', error: '', message: '' });
      try {
        const result = await window.API.startProjectTestRun(project.id, {
          suiteId: context.defaultSuite.id,
          environmentId: context.primaryEnvironment ? context.primaryEnvironment.id : null,
          targetUrl: context.primaryEnvironment ? null : project.projectAppUrl,
          ticketId,
        });
        const run = result && result.run ? result.run : result;
        setTicketTestRun(ticketId, {
          runId: run && run.id,
          status: run && run.status ? run.status : 'Queued',
          suiteName: run && run.suiteName ? run.suiteName : context.defaultSuite.name,
          message: result && result.message ? result.message : '',
          error: '',
        });
      } catch (error) {
        setTicketTestRun(ticketId, { status: 'Error', error: error.message || String(error) });
      }
    };
    const respondTicketApproval = async (ticketId, requestId, decision) => {
      const execution = executionsByTicket[ticketId];
      if (!execution || !execution.jobId || !requestId) return;
      const currentApproval = execution.approvalRequest && execution.approvalRequest.requestId === requestId
        ? { ...execution.approvalRequest, response: decision }
        : execution.approvalRequest;
      setTicketExecution(ticketId, { approvalRequest: currentApproval });
      try {
        const job = await window.API.respondCodexPromptApproval(execution.jobId, requestId, decision);
        setTicketExecution(ticketId, {
          jobId: job.id || execution.jobId,
          status: job.status || execution.status,
          error: job.error || '',
          appUrl: job.appUrl || execution.appUrl || '',
          approvalRequest: approvalFromJob(job),
        });
      } catch (error) {
        setTicketExecution(ticketId, { error: error.message || String(error) });
      }
    };

    useEffect(() => {
      const busy = Object.entries(executionsByTicket).filter(([, execution]) =>
        execution && execution.jobId && ['Queued', 'Running'].includes(execution.status));
      if (!busy.length) return;

      let cancelled = false;
      const poll = async () => {
        await Promise.all(busy.map(async ([ticketId, execution]) => {
          try {
            const job = await window.API.getCodexPromptJob(execution.jobId);
            if (cancelled) return;
            setTicketExecution(ticketId, {
              jobId: job.id || execution.jobId,
              status: job.status || execution.status,
              error: job.error || '',
              appUrl: job.appUrl || '',
              approvalRequest: approvalFromJob(job),
            });
          } catch (error) {
            if (!cancelled) setTicketExecution(ticketId, { status: 'Error', error: error.message || String(error) });
          }
        }));
      };
      const timer = window.setInterval(poll, 2000);
      poll();
      return () => { cancelled = true; window.clearInterval(timer); };
    }, [executionsByTicket]);

    useEffect(() => {
      if (!showSoftwareFields) {
        setTesting({ suites: [], environments: [], defaultSuite: null, primaryEnvironment: null });
        return undefined;
      }
      let cancelled = false;
      window.API.getProjectTesting(project.id)
        .then((dashboard) => { if (!cancelled) applyTestingDashboard(dashboard); })
        .catch(() => { if (!cancelled) setTesting({ suites: [], environments: [], defaultSuite: null, primaryEnvironment: null }); });
      return () => { cancelled = true; };
    }, [project.id, applyTestingDashboard, showSoftwareFields]);

    useEffect(() => {
      const busy = Object.entries(testRunsByTicket).filter(([, run]) =>
        run && run.runId && ['Queued', 'Running'].includes(run.status));
      if (!busy.length) return;

      let cancelled = false;
      const poll = async () => {
        try {
          const status = await window.API.getProjectTestingStatus(project.id);
          if (cancelled) return;
          const runs = (status && status.runs) || [];
          busy.forEach(([ticketId, current]) => {
            const run = runs.find((item) => item.id === current.runId);
            if (!run) return;
            setTicketTestRun(ticketId, {
              status: run.status || current.status,
              error: run.error || '',
              suiteName: run.suiteName || current.suiteName,
            });
          });
        } catch (error) {
          if (!cancelled) {
            busy.forEach(([ticketId]) => setTicketTestRun(ticketId, { error: error.message || String(error) }));
          }
        }
      };
      const timer = window.setInterval(poll, 2000);
      poll();
      return () => { cancelled = true; window.clearInterval(timer); };
    }, [project.id, testRunsByTicket]);

    if (!active) return <Panel title="Tickets"><span className="muted">No feature.</span></Panel>;

    const activeFeature = (project.features || []).find((feature) => feature.id === active.id) || null;
    const FeatureScopeBar = window.FeatureScopeBar;
    const featureScopeBar = FeatureScopeBar
      ? <FeatureScopeBar planning={planning} project={project} onAction={onAction} Inline={Inline} />
      : null;
    const phaseBar = activeFeature && (
      <span style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
        <span className={'pd-phase-badge ' + String(activeFeature.phase || '').toLowerCase()}>{activeFeature.phase}</span>
        <window.FeaturePhaseAgentButton project={project} feature={activeFeature} onAction={onAction} />
      </span>
    );
    const viewToggle = (
      <div className="segmented" role="tablist" aria-label="Ticket view">
        <button className={view === 'list' ? 'on' : ''} onClick={() => setView('list')}>List</button>
        <button className={view === 'board' ? 'on' : ''} onClick={() => setView('board')}>Board</button>
      </div>
    );

    if (view === 'list') {
      return (
        <React.Fragment>
          {featureScopeBar}
          <div className="grid kb-list-panel">
            <div className="kb-viewbar">
              <span className="kb-viewmeta"><span className="mono">{done}/{total}</span> done · list view</span>
              {phaseBar}
              {viewToggle}
            </div>
            <ProjectTicketsPanel
              project={project} categories={cats} milestones={ms} statuses={sts} onAction={onAction}
              requirements={requirements}
              onRenameCluster={planning.renameCat}
              executionsByTicket={executionsByTicket}
              testRunsByTicket={testRunsByTicket}
              testing={testing}
              onExecuteTicket={showSoftwareFields ? executeTicket : null}
              onRunTicketTests={showSoftwareFields ? runTicketTests : null}
              onRespondApproval={respondTicketApproval}
              onAddCluster={planning.addCluster}
              onRemoveCluster={planning.removeCluster}
              onAddTicket={planning.addTicket}
              onPatchTicket={planning.patchTicket}
              onMoveTicket={planning.moveTicket}
              onMoveTicketStatus={planning.moveTicketStatus}
              onPatchTicketAndSave={planning.patchTicketAndSave}
              onMoveTicketStatusAndSave={planning.moveTicketStatusAndSave}
              onRemoveTicket={planning.removeTicket} />
          </div>
        </React.Fragment>
      );
    }
    return (
      <React.Fragment>
        {featureScopeBar}
        <Panel title="Tickets" className="kb-board-panel"
          sub={<span><span className="mono">{done}/{total}</span> done · board view · drag cards to change status &amp; cluster</span>}
          actions={<span style={{ display: 'flex', alignItems: 'center', gap: 9 }}>{phaseBar}{viewToggle}<button className="btn sm ghost" onClick={planning.addCluster}><Icon name="plus" size={14} />Cluster</button></span>}>
          <TicketsBoard
            project={project} categories={cats} statuses={sts} milestones={ms} onAction={onAction}
            requirements={requirements}
            executionsByTicket={executionsByTicket}
            testRunsByTicket={testRunsByTicket}
            testing={testing}
            onExecuteTicket={showSoftwareFields ? executeTicket : null}
            onRunTicketTests={showSoftwareFields ? runTicketTests : null}
            onRespondApproval={respondTicketApproval}
            onMoveCard={planning.moveTicketStatus}
            onPatch={planning.patchTicket}
            onSaveAndPersist={planning.patchTicketAndSave}
            onMoveAndPersist={planning.moveTicketStatusAndSave}
            onAddTicket={planning.addTicket}
            onRemove={planning.removeTicket}
            onRemoveCluster={planning.removeCluster} />
        </Panel>
      </React.Fragment>
    );
  }

  Object.assign(window, { ProjectTicketEditDrawer, TicketsBoard, ProjectBoardTab, BOARD_STATUS_META: STATUS_META });
})();
