/* FEEDBACK#21 — Nelawa: the feedback sheet. Removal: docs/FEEDBACK-TEARDOWN.md.

   Two doors into one channel, because there are two kinds of moment:
     • "Something's off?"    — a bug, a confusion, a wish. Opened from the More
                               sheet. Needs nothing but a sentence.
     • "In the wrong place?" — opened from a task's ⋯ menu. The single most
                               useful report this app can receive, because the
                               sorter getting it wrong is this app's recurring
                               bug class. It MOVES the task as well as
                               reporting, so the user gets the fix rather than
                               doing unpaid QA.

   THE DESIGN RULE, from docs/BETA-FEEDBACK-PLAN.md: the user types one
   sentence; everything else is attached automatically. Every field added costs
   reports — a 12-field form is completed by ~30% of people. So: one textarea,
   and for wrong-place, one row of destination chips.

   WHAT IS SENT, and it is exactly what the sheet shows: the sentence, the
   context chips (version / screen / mobile), and — for wrong-place only — the
   task's title, its current home, the chosen home, and the source line if one
   was stored. Never the task list, never a screenshot, never the DOM. The
   server attaches the account ref; the email never leaves (see
   tests/feedback-route.mjs).

   TO SWITCH THE WHOLE FEATURE OFF (teardown L2): set FEEDBACK_ON to false
   below and bump sw.js. Both doors vanish; the route stays up and D1 keeps
   whatever was already collected. To stop only the GitHub forward with no
   deploy at all (L1), delete GITHUB_TOKEN in the Cloudflare dashboard. */

const FEEDBACK_ON = true;

/* The homes a task can be moved to, matching DumpUtil.surfaceOf's verdicts.
   DELIBERATELY NOT HERE: "Today", which is a derived view and not a home — so
   there is no mutation to move a task to it; and "Groceries", which is a
   separate store whose routing only happens at capture time, so it would need
   a new compound action (add the item, toss the task). Both were in an early
   mockup and would have been dead buttons.
   WHETHER A HOME CAN BE MOVED TO IS NOT A PROPERTY OF THE HOME — it depends on
   the task. Calendar always needs a date; Held is fine for a loose task but
   drops a deadline and orphans a project step. So the decision lives in
   declineReason(task, home) below, not in a flag here. An earlier version
   carried a `moves:` boolean on each row, which was wrong in the way that
   matters: it made the answer look task-independent, and that is precisely the
   assumption that let a project step be moved out from under its keeper. */
const FB_HOMES = [
  { key: 'board',     label: 'Board',      hint: 'a task to do' },
  { key: 'calendar',  label: 'Calendar',   hint: 'it happens on a day' },
  { key: 'held',      label: 'Held',       hint: 'not now, but not gone' },
  { key: 'reference', label: 'Reference',  hint: 'something to keep, not do' },
  { key: 'gone',      label: 'Not a task', hint: 'shouldn’t have been captured' }
];

/* Read from the meta tag index.html already carries, rather than a new global —
   the tag is one of the three markers guard 3 keeps in step with app/VERSION,
   so this can never report a version the deploy doesn't have. */
function appVersion() {
  try {
    const m = document.querySelector('meta[name="app-version"]');
    return (m && m.getAttribute('content')) || null;
  } catch (e) { return null; }
}

function FeedbackSheet() {
  const [open, setOpen] = useState(null);   // null | {mode:'general'} | {mode:'wrong', task}
  const [text, setText] = useState('');
  const [home, setHome] = useState(null);
  const [busy, setBusy] = useState(false);
  const [sent, setSent] = useState(false);
  const [moved, setMoved] = useState(false);
  const [declined, setDeclined] = useState(null);

  // Opened from anywhere via window — the More sheet lives in app.jsx and the
  // ⋯ menu in tasks.jsx, and neither should have to own this component's state.
  /* The FEEDBACK_ON check must sit OUT HERE, not inside the assigned closure.
     Both doors gate on `window.openFeedback` being truthy, so assigning a
     function that silently returns left the buttons rendered and DEAD — the
     documented L2 kill switch switched nothing off, which is worse than
     leaving the feature on. Watched: flipping the flag now removes both
     buttons. (Finding 4.) */
  React.useEffect(() => {
    if (!FEEDBACK_ON) return undefined;
    window.openFeedback = (mode, task) => {
      setOpen({ mode: mode || 'general', task: task || null });
      setText(''); setHome(null); setSent(false); setBusy(false); setMoved(false); setDeclined(null);
    };
    return () => { delete window.openFeedback; };
  }, []);

  if (!FEEDBACK_ON || !open) return null;

  const wrong = open.mode === 'wrong';
  const task = open.task;
  const close = () => setOpen(null);

  const canSend = wrong ? !!home : !!text.trim();

  const send = async () => {
    if (!canSend || busy) return;
    setBusy(true);
    const st = (() => { try { return DumpStore.getState(); } catch (e) { return {}; } })();
    const payload = {
      kind: wrong ? 'wrong_place' : 'bug',
      body: text.trim() || null,
      appVersion: appVersion(),
      screen: st.settingsOpen ? 'settings' : (st.route || 'today'),
      mobile: window.innerWidth <= 640
    };
    if (wrong && task) {
      payload.taskTitle = task.text || null;
      payload.taskSrc = task.src || null;           // null until the capture change ships
      payload.fromHome = (window.DumpUtil && DumpUtil.surfaceOf)
        ? DumpUtil.surfaceOf(task, new Date().toISOString().slice(0, 10)) : null;
      payload.toHome = home;
    }
    let ok = false;
    try {
      const r = await fetch('/api/feedback', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json', 'X-Enstow': '1' },
        body: JSON.stringify(payload)
      });
      ok = r.ok;
    } catch (e) { ok = false; }

    if (!ok) {
      // Never swallow this. The user wrote something and pressed send; telling
      // them it worked when it didn't is worse than the original bug.
      setBusy(false);
      DumpAPI.toast('Couldn’t send that — check your connection and try again.');
      return;
    }
    // The move is the user's fix, and it happens only after the report lands,
    // so a failed send never silently rearranges their board.
    const res = (wrong && task) ? applyMove(task, home) : { moved: false, reason: null };
    setMoved(res.moved);
    setDeclined(res.reason);
    if (window.DumpTrack) {
      DumpTrack.event('feedback_sent', {
        kind: payload.kind, has_note: !!payload.body,
        moved: res.moved, declined: res.reason || 'none'
      });
    }
    setBusy(false); setSent(true);
  };

  return (
    <React.Fragment>
      <div className="overlay more-overlay" onClick={close}></div>
      <div className="more-sheet fb-sheet" role="dialog" aria-label={wrong ? 'In the wrong place?' : 'Something’s off?'}>
        <div className="more-grab" aria-hidden="true"></div>
        {sent ? (
          <div className="fb-sent">
            <div className="fb-sent-mark" aria-hidden="true">{moved ? '✓' : '🌱'}</div>
            <h3>{moved ? 'Moved — and noted.' : 'Got it — thank you.'}</h3>
            {/* Never claim a move that did not happen. When the app declines,
                say which thing it left alone and why — a vague "thank you"
                after the user picked a destination reads as a silent failure. */}
            <p>{moved
              ? 'That’s fixed for you now. The note helps Nelawa sort it right next time.'
              : wrong
                ? 'Left where it is — ' + (FB_DECLINE[declined] || 'it needs a decision') +
                  ', so that’s your call to make, not Nelawa’s.'
                : 'Every report is read. If it needs a reply, you’ll get an email.'}</p>
            <button className="btn btn-primary fb-done" onClick={close}>Done</button>
          </div>
        ) : (
          <div className="fb-form">
            <h3 className="fb-title">{wrong ? 'In the wrong place?' : 'Something’s off?'}</h3>
            <p className="fb-sub">{wrong
              ? 'Nelawa sorted this one — tell it where it should have gone.'
              : 'One sentence is plenty. This goes straight to the person building Nelawa.'}</p>

            {wrong && task ? (
              <div className="fb-quote">
                {task.src
                  ? <React.Fragment><span>You wrote:</span> <b>“{task.src}”</b><br /></React.Fragment>
                  : null}
                <span>Nelawa made:</span> <b>{task.text}</b>
              </div>
            ) : null}

            {wrong ? (
              <React.Fragment>
                <label className="fb-label">Where should it live?</label>
                <div className="fb-homes">
                  {FB_HOMES.map((h) => (
                    <button key={h.key}
                      className={'fb-home' + (home === h.key ? ' sel' : '') + (h.key === 'gone' ? ' quiet' : '')}
                      onClick={() => setHome(h.key)} title={h.hint}>{h.label}</button>
                  ))}
                </div>
              </React.Fragment>
            ) : null}

            <label className="fb-label" htmlFor="fb-text">
              {wrong ? 'Anything else?' : 'What happened?'}
              {wrong ? <span className="fb-opt"> (optional)</span> : null}
            </label>
            <textarea id="fb-text" className="fb-text" rows={wrong ? 2 : 3}
              value={text} onChange={(e) => setText(e.target.value)} maxLength={4000}
              placeholder={wrong ? 'e.g. It’s a weekly thing, every Thursday'
                                 : 'e.g. The calendar showed Tuesday’s swim on Wednesday'} />

            <label className="fb-label">Sent with it — nothing else</label>
            <div className="fb-chips">
              <span className="fb-chip">v{appVersion() || '?'}</span>
              <span className="fb-chip">this screen</span>
              <span className="fb-chip">{window.innerWidth <= 640 ? 'mobile' : 'desktop'}</span>
              <span className="fb-chip">your account</span>
            </div>

            {/* The label must not promise a move the app can't make — Calendar
                needs a date nobody has asked for. Honest label, honest result. */}
            <button className="btn btn-primary fb-send" disabled={!canSend || busy} onClick={send}>
              {busy ? 'Sending…'
                : !wrong ? 'Send to Nelawa'
                : !home ? 'Pick a home first'
                : (task && declineReason(task, home)) ? 'Tell Nelawa'
                : 'Move it & tell Nelawa'}
            </button>
            <button className="btn btn-ghost fb-cancel" onClick={close}>{wrong ? 'Never mind' : 'Not now'}</button>
          </div>
        )}
      </div>
    </React.Fragment>
  );
}

/* WHY THIS REFUSES MORE THAN IT ACCEPTS.

   The first version of this function moved anything, anywhere, and an
   adversarial review found five separate ways that was wrong. The lesson is
   worth stating because it generalises: this file introduced a NEW mutation
   path, and the existing paths in tasks.jsx already encode rules it walked
   straight past — each with a comment saying why:

     tasks.jsx:151  "A project's live action never bins silently — the tap
                     almost always means the PROJECT, and guessing either way
                     loses something. Ask once (G1 rule)."
     tasks.jsx:176  "Someday on a step means the project: a lone step in
                     Someday would orphan it."
     tasks.jsx:202  "No silent downgrade: setting aside a task that has a real
                     deadline DROPS that deadline, so it must be an explicit
                     confirm — never a one-tap side effect."

   Reproduced against real store.js before this rewrite: moving a project's
   minted next action to Reference left the project with NO next action
   anywhere and reconcileProjects never minted a replacement — a reference
   status still matches its `live` predicate (`!t.done && t.status !== 'binned'`,
   store.js:3311) — and `stuckCount` uses that same predicate, so nothing
   flagged it either. "Not a task" re-minted an identical card in the same tick.
   "Held" orphaned the step. All three while the sheet said "that's fixed for
   you now".

   THE RULE NOW: a wrong-place report may only move a task whose move is
   unambiguous. Anything that would require a question the sheet does not ask —
   what happens to the project? do you really mean to drop that deadline? — is
   REPORTED AND NOT MOVED, exactly like the Calendar chip. The report is still
   the valuable half; the move is a convenience, and a convenience must never
   quietly break an invariant the rest of the app defends.

   Returns { moved: boolean, reason: string|null } — reason names WHY it
   declined, so the sheet can say something true instead of "Moved". */

const FB_DECLINE = {
  project: 'it’s a step in a project',
  container: 'it’s a whole project',
  deadline: 'it has a deadline',
  needsDate: 'that needs a date',
  blocker: 'something else is waiting on it',
  gone: 'it’s no longer there',
  noop: 'it’s already there'
};

function declineReason(task, home) {
  // A project CONTAINER is never a loose task; converting one hides its whole
  // plan (finding 14). A project STEP is minted by reconcileProjects and owned
  // by it — moving it out from under the keeper is what stalls the project.
  if (task.big) return 'container';
  if (task.projectId) return 'project';
  /* Dropping a real deadline — or letting go of something someone else is
     counting on — is an explicit confirm everywhere else in the app, and this
     sheet asks no such question. Threshold matches tasks.jsx:1093 exactly
     (`task.dueDate || task.humanStakes`), and it covers EVERY destination that
     takes the task off the board rather than a hand-listed subset: an earlier
     version guarded only held/reference and left "Not a task" — the most
     destructive chip — wide open. */
  if ((task.dueDate || task.humanStakes) && home !== 'board') return 'deadline';
  /* Reference is not a state the keepers treat as "gone": reconcileBlocked's
     isLiveBlocker is `!t.done && t.status !== 'binned'`, so a reference-status
     blocker still counts as alive — and a Reference item can never be
     completed, so the dependent would wait forever. (Binning it IS recognised,
     and correctly frees the dependent.) The underlying gap is in the keeper,
     not here; this refuses until that is fixed on its own terms. */
  if (home === 'reference' && blocksSomething(task)) return 'blocker';
  if (home === 'calendar') return 'needsDate';
  return null;
}

function blocksSomething(task) {
  try {
    return DumpStore.getState().tasks.some(function (t) {
      return t.blockedBy === task.id && t.status !== 'binned' && !t.done;
    });
  } catch (e) { return false; }
}

/* The move goes through the app's own mutations wherever one exists, so the
   keepers (reconcileProjects / reconcileBlocked) and surfaceOf keep every
   screen honest afterwards — exactly as if the user had done it by hand.
   NOTE the real signatures take a task ID, not a task object. */
function applyMove(task, home) {
  const declined = declineReason(task, home);
  if (declined) return { moved: false, reason: declined };

  const today = new Date().toISOString().slice(0, 10);
  const before = (() => {
    try {
      const live = DumpStore.getState().tasks.find((t) => t.id === task.id);
      return live ? DumpUtil.surfaceOf(live, today) : null;
    } catch (e) { return null; }
  })();
  // The task can be gone by now — a keeper may have retired it while the sheet
  // sat open. Claiming a move on a task that no longer exists is finding 9.
  if (before === null) return { moved: false, reason: 'gone' };

  try {
    if (home === 'gone') {
      DumpStore.set((s) => ({
        tasks: s.tasks.map((t) => (t.id === task.id
          ? { ...t, status: 'binned', binnedAt: new Date().toISOString(), waitingOn: null } : t)),
        // Every other bin path clears the ＋Today pick too (tasks.jsx exitBoard,
        // sweep.jsx, store.js) — otherwise the overwhelm helper stays suppressed
        // by a pick that no longer exists (finding 13).
        focus: Object.assign({}, s.focus, {
          ids: (s.focus.ids || []).filter((id) => id !== task.id)
        })
      }));
    } else if (home === 'reference') {
      DumpStore.set((s) => ({
        tasks: s.tasks.map((t) => (t.id === task.id
          ? { ...t, status: 'reference', bucket: 'reference', gtdBucket: 'reference',
              deferUntil: null, binnedAt: null } : t)),
        focus: Object.assign({}, s.focus, {
          ids: (s.focus.ids || []).filter((id) => id !== task.id)
        })
      }));
    } else if (home === 'held') {
      /* Held-with-no-date is `status:'someday'` — NOT `deferUntil:null`. The
         first cut used deferTask(id, null), which clears the deadline and
         leaves the task ACTIVE on the board, so it stayed put while the sheet
         said "Moved". Caught by driving the real app and asserting surfaceOf
         actually changed, which is the check worth keeping. */
      DumpStore.set((s) => ({
        tasks: s.tasks.map((t) => (t.id === task.id
          ? { ...t, status: 'someday', waitingOn: null, deferUntil: null, backOn: null } : t))
      }));
    } else if (home === 'board') {
      DumpAPI.bringBackHeld(task.id);
      DumpStore.set((s) => ({
        tasks: s.tasks.map((t) => (t.id === task.id && (t.status === 'someday' || t.status === 'waiting')
          ? { ...t, status: 'active', waitingOn: null } : t))
      }));
    } else {
      return { moved: false, reason: 'needsDate' };
    }
  } catch (e) {
    return { moved: false, reason: 'gone' };
  }

  // Did it ACTUALLY move? Ask surfaceOf rather than trusting the write — the
  // whole class of bug above was the UI asserting a move that never happened.
  let after = null;
  try {
    const live = DumpStore.getState().tasks.find((t) => t.id === task.id);
    after = live ? DumpUtil.surfaceOf(live, today) : 'gone';
  } catch (e) { after = null; }
  if (after === null || after === before) return { moved: false, reason: 'noop' };
  return { moved: true, reason: null };
}

window.FeedbackSheet = FeedbackSheet;
