// /settings/notifications — the §15.4 / §15.5 / §15.6 / §15.8 surface.
//
// Topic 13 settled the schema and the per-category rules; Slice 6 wired
// the endpoints; Slice 7 lands the surface where a contributor finds
// the per-category email toggles, the digest cadence dropdown, the
// quiet-hours editor, the watches overview, and the per-user mute
// list. The §15.4 email footer's "Manage all preferences" link points
// at this route.
//
// Each sub-section is intentionally thin — the rules are settled in
// §15; this page renders them. The one piece of voice the spec
// commits to and the surface inherits: the `email_watched_churn`
// toggle renders as permanently disabled with the §15.4 refusal
// tooltip ("Per-commit and per-message email is intentionally not
// offered. The digest aggregates this activity weekly."). Naming the
// refusal is the spec's commitment; silently omitting the toggle
// would let the contract drift.

import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { entryPath, useProjectId } from '../lib/entryPaths'
import {
  getNotificationPreferences,
  setNotificationPreferences,
  getQuietHours,
  setQuietHours,
  listWatches,
  setWatch,
  unmuteUser,
  muteUser,
  searchUsers,
  getCookieConsent,
  getMe,
  setPasscode,
  clearPasscode,
  listMyDevices,
  revokeMyDevice,
  revokeAllMyDevices,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'

const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'

export default function NotificationSettings({ viewer }) {
  return (
    <div className="settings-page">
      <header className="settings-header">
        <h1>Notification settings</h1>
        <p className="settings-sub">
          Which signals reach you, how, and when. The rules live in §15;
          this page is where you tune them.
        </p>
      </header>

      <EmailPreferencesSection />
      <DigestCadenceSection />
      <QuietHoursSection />
      <WatchesSection />
      <MutesSection viewer={viewer} />
      <SignInSection />
      <DevicesSection />
      <PrivacyCookiesSection />
    </div>
  )
}

// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
//
// Lists the user's active device-trust rows and lets them revoke any
// or all. A revoke marks the row dead server-side; the matching
// device's next visit will be refused and the cookie cleared. The
// surface intentionally does not single out the row whose cookie the
// current request carries — every row reads identically, so the user
// can revoke "this device" alongside any other from a single page.

function DevicesSection() {
  const [devices, setDevices] = useState(null)
  const [error, setError] = useState(null)
  const [busy, setBusy] = useState(false)

  useEffect(() => {
    refresh()
  }, [])

  async function refresh() {
    try {
      const { items } = await listMyDevices()
      setDevices(items || [])
      setError(null)
    } catch (e) {
      setError(e.message || 'Could not load trusted devices.')
    }
  }

  async function onRevoke(deviceId) {
    setBusy(true)
    try {
      await revokeMyDevice(deviceId)
      await refresh()
    } catch (e) {
      setError(e.message || 'Could not revoke device.')
    } finally {
      setBusy(false)
    }
  }

  async function onRevokeAll() {
    if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
      return
    }
    setBusy(true)
    try {
      await revokeAllMyDevices()
      await refresh()
    } catch (e) {
      setError(e.message || 'Could not revoke devices.')
    } finally {
      setBusy(false)
    }
  }

  return (
    <SectionShell
      title="Trusted devices"
      subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
    >
      {devices === null && <p className="settings-note">Loading…</p>}
      {devices !== null && devices.length === 0 && (
        <p className="device-empty">
          No trusted devices. Sign in and check “Trust this device for 30 days”
          to add the device you're on now.
        </p>
      )}
      {devices !== null && devices.length > 0 && (
        <>
          <ul className="device-list">
            {devices.map(d => (
              <li key={d.id} className="device-list-item">
                <div className="device-meta">
                  <div className="device-ua">{d.user_agent || 'Unknown device'}</div>
                  <div className="device-stamps">
                    Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
                  </div>
                </div>
                <button
                  type="button"
                  onClick={() => onRevoke(d.id)}
                  disabled={busy}
                  title="Revoke trust on this device"
                >
                  Revoke
                </button>
              </li>
            ))}
          </ul>
          <button
            type="button"
            className="device-revoke-all"
            onClick={onRevokeAll}
            disabled={busy}
          >
            Revoke all devices
          </button>
        </>
      )}
      {error && <p className="settings-note warning">{error}</p>}
    </SectionShell>
  )
}

function formatStamp(stamp) {
  // The server emits SQLite `datetime('now')` strings (UTC, no
  // timezone marker). Parse defensively; fall back to the raw stamp
  // if Date can't make sense of it.
  if (!stamp) return '—'
  const d = new Date(stamp.replace(' ', 'T') + 'Z')
  if (Number.isNaN(d.getTime())) return stamp
  return d.toLocaleString()
}

// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────

function SignInSection() {
  // Source of truth for `has_passcode` and `passcode_set_at` is the
  // /api/auth/me payload (v0.10.0 added both fields). We re-read after
  // every mutation so the surface reflects what just landed.
  const [me, setMe] = useState(null)
  const [error, setError] = useState(null)
  const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
  const [draft, setDraft] = useState('')
  const [busy, setBusy] = useState(false)
  const [savedNote, setSavedNote] = useState('')

  useEffect(() => {
    getMe()
      .then(payload => setMe(payload.user || null))
      .catch(e => setError(e.message))
  }, [])

  async function refresh() {
    const payload = await getMe()
    setMe(payload.user || null)
  }

  async function save(e) {
    if (e) e.preventDefault()
    const pc = draft.trim()
    if (pc.length < 4) {
      setError('Passcode must be at least 4 characters.')
      return
    }
    setBusy(true)
    setError(null)
    setSavedNote('')
    try {
      await setPasscode(pc)
      setDraft('')
      setMode('idle')
      setSavedNote('Passcode saved.')
      await refresh()
    } catch (err) {
      // 422 carries the validation message verbatim (denylist /
      // length); surface it as-is so the user knows what to change.
      setError(err.message || 'Could not save passcode. Try a different one.')
    } finally {
      setBusy(false)
      setTimeout(() => setSavedNote(''), 2000)
    }
  }

  async function remove() {
    if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
      return
    }
    setBusy(true)
    setError(null)
    setSavedNote('')
    try {
      await clearPasscode()
      setSavedNote('Passcode removed.')
      await refresh()
    } catch (err) {
      setError(err.message || 'Could not remove passcode.')
    } finally {
      setBusy(false)
      setTimeout(() => setSavedNote(''), 2000)
    }
  }

  if (!me) return <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />

  const hasPasscode = !!me.has_passcode

  return (
    <SectionShell
      title="Sign-in"
      subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
    >
      <div className="settings-row">
        <span className="settings-note">
          <strong>Passcode:</strong>{' '}
          {hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
        </span>
      </div>
      {hasPasscode && me.passcode_set_at && (
        <p className="settings-note muted">Set on {me.passcode_set_at}.</p>
      )}

      {mode === 'idle' && (
        <div className="settings-row">
          {hasPasscode ? (
            <>
              <button
                className="btn-primary"
                onClick={() => { setMode('change'); setDraft(''); setError(null) }}
                disabled={busy}
              >
                Change passcode
              </button>
              <button
                className="btn-link-muted"
                onClick={remove}
                disabled={busy}
              >
                Remove passcode
              </button>
            </>
          ) : (
            <button
              className="btn-primary"
              onClick={() => { setMode('set'); setDraft(''); setError(null) }}
              disabled={busy}
            >
              Set passcode
            </button>
          )}
        </div>
      )}

      {(mode === 'set' || mode === 'change') && (
        <form className="settings-row" onSubmit={save}>
          <label>
            {mode === 'change' ? 'New passcode' : 'Passcode'}
            <input
              type="password"
              autoComplete="new-password"
              value={draft}
              onChange={e => setDraft(e.target.value)}
              placeholder="4–20 characters"
              minLength={4}
              maxLength={20}
              required
              disabled={busy}
            />
          </label>
          <button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
            {busy ? 'Saving…' : 'Save'}
          </button>
          <button
            type="button"
            className="btn-link-muted"
            onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
            disabled={busy}
          >
            Cancel
          </button>
        </form>
      )}

      {savedNote && <p className="settings-note">{savedNote}</p>}
      {error && <p className="settings-note warning">{error}</p>}
    </SectionShell>
  )
}

// ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ────────────

function PrivacyCookiesSection() {
  const [consent, setConsent] = useState(() => getConsent())

  useEffect(() => {
    // Pull the server-side row on mount; if it has a recorded_at the
    // local snapshot is updated via hydrate.
    getCookieConsent()
      .then(record => { if (record.recorded_at) hydrateFromServer(record) })
      .catch(() => {})
    return onConsentChange(next => setConsent(next))
  }, [])

  function reopenBanner() {
    // App.jsx listens for this event and bumps the forceOpen tick on
    // <CookieConsentBanner>. The banner pre-selects the current choice
    // from the snapshot, so the user can revise rather than restart.
    window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
  }

  const summary = (() => {
    if (!consent.recorded_at) {
      return 'No choice recorded — the consent banner is being shown to you.'
    }
    if (consent.analytics && consent.other) {
      return 'Essential + analytics + other.'
    }
    if (consent.analytics) {
      return 'Essential + analytics.'
    }
    return 'Essential only.'
  })()

  return (
    <SectionShell
      title="Privacy & cookies"
      subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
    >
      <div className="settings-row">
        <span className="settings-note"><strong>Current choice:</strong> {summary}</span>
      </div>
      {consent.recorded_at && (
        <p className="settings-note muted">Recorded {consent.recorded_at}.</p>
      )}
      <div className="settings-row">
        <button className="btn-primary" onClick={reopenBanner}>
          Change
        </button>
        <Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
        <Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
      </div>
    </SectionShell>
  )
}

// ── §15.4 email category toggles ───────────────────────────────────────────

function EmailPreferencesSection() {
  const [prefs, setPrefs] = useState(null)
  const [saving, setSaving] = useState(false)
  const [savedNote, setSavedNote] = useState('')
  const [error, setError] = useState(null)

  useEffect(() => {
    getNotificationPreferences().then(setPrefs).catch(e => setError(e.message))
  }, [])

  async function update(field, value) {
    setSaving(true)
    setSavedNote('')
    try {
      await setNotificationPreferences({ [field]: value })
      setPrefs(p => ({ ...p, [field]: value }))
      setSavedNote('Saved.')
    } catch (e) {
      setError(e.message)
    } finally {
      setSaving(false)
      setTimeout(() => setSavedNote(''), 1500)
    }
  }

  if (!prefs) return <SectionShell title="Email" subtitle={error || 'Loading…'} />

  return (
    <SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
      <Toggle
        label="Personal direct"
        description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
        checked={!!prefs.email_personal_direct}
        onChange={v => update('email_personal_direct', v)}
        disabled={saving}
      />
      <Toggle
        label="Watched structural"
        description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
        checked={!!prefs.email_watched_structural}
        onChange={v => update('email_watched_structural', v)}
        disabled={saving}
      />
      <Toggle
        label="Admin actionable"
        description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
        checked={!!prefs.email_admin_actionable}
        onChange={v => update('email_admin_actionable', v)}
        disabled={saving}
      />
      <Toggle
        label="Watched churn (per-commit, per-message)"
        description={CHURN_REFUSAL}
        checked={false}
        disabled
        title={CHURN_REFUSAL}
      />
      {!!prefs.email_opt_out_all && (
        <p className="settings-note warning">
          A hard bounce or complaint from your mailbox flipped the global
          email opt-out. No email will be sent until you contact an admin
          to clear the flag, even if individual categories are enabled.
        </p>
      )}
      <p className="settings-note">{savedNote}</p>
    </SectionShell>
  )
}

// ── §15.5 digest cadence ───────────────────────────────────────────────────

function DigestCadenceSection() {
  const [cadence, setCadence] = useState(null)
  const [saving, setSaving] = useState(false)

  useEffect(() => {
    getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly'))
  }, [])

  async function update(value) {
    setSaving(true)
    try {
      await setNotificationPreferences({ digest_cadence: value })
      setCadence(value)
    } finally {
      setSaving(false)
    }
  }

  return (
    <SectionShell
      title="Digest cadence"
      subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
    >
      <div className="settings-row">
        <select
          value={cadence ?? 'weekly'}
          onChange={e => update(e.target.value)}
          disabled={saving || cadence == null}
        >
          <option value="off">Off — never send a digest</option>
          <option value="weekly">Weekly</option>
          <option value="daily">Daily</option>
        </select>
      </div>
    </SectionShell>
  )
}

// ── §15.8 quiet hours ──────────────────────────────────────────────────────

function QuietHoursSection() {
  const [data, setData] = useState(null)
  const [draft, setDraft] = useState({ start: '', end: '', timezone: '' })
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState(null)

  // The browser ships an IANA tz list per §15.8 — preferable to a
  // free-text field, since the API validates the trio.
  const timezones = useMemo(() => {
    try {
      return Intl.supportedValuesOf('timeZone')
    } catch {
      return ['UTC']
    }
  }, [])

  useEffect(() => {
    getQuietHours().then(q => {
      setData(q)
      setDraft({
        start: q.start || '',
        end: q.end || '',
        timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
      })
    })
  }, [])

  async function save() {
    setSaving(true)
    setError(null)
    try {
      await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone })
      setData({ start: draft.start, end: draft.end, timezone: draft.timezone })
    } catch (e) {
      setError(e.message)
    } finally {
      setSaving(false)
    }
  }

  async function clear() {
    setSaving(true)
    setError(null)
    try {
      await setQuietHours({ start: null, end: null, timezone: null })
      setData({ start: null, end: null, timezone: null })
      setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })
    } catch (e) {
      setError(e.message)
    } finally {
      setSaving(false)
    }
  }

  if (!data) return <SectionShell title="Quiet hours" subtitle="Loading…" />

  const isSet = !!(data.start && data.end && data.timezone)

  return (
    <SectionShell
      title="Quiet hours"
      subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
    >
      <div className="settings-row quiet-hours-row">
        <label>
          Start
          <input
            type="time"
            value={draft.start}
            onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
          />
        </label>
        <label>
          End
          <input
            type="time"
            value={draft.end}
            onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
          />
        </label>
        <label>
          Timezone
          <select
            value={draft.timezone}
            onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
          >
            {timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
          </select>
        </label>
      </div>
      <div className="settings-row">
        <button className="btn-primary" disabled={saving} onClick={save}>
          {isSet ? 'Update quiet hours' : 'Set quiet hours'}
        </button>
        {isSet && (
          <button className="btn-link-muted" disabled={saving} onClick={clear}>
            Clear
          </button>
        )}
      </div>
      {error && <p className="settings-note warning">{error}</p>}
    </SectionShell>
  )
}

// ── §15.6 watches overview ─────────────────────────────────────────────────

function WatchesSection() {
  const [watches, setWatches] = useState(null)
  const [updating, setUpdating] = useState({})
  const pid = useProjectId()

  useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])

  async function update(slug, state) {
    setUpdating(u => ({ ...u, [slug]: true }))
    try {
      const r = await setWatch(slug, state)
      setWatches(prev => prev.map(w => w.rfc_slug === slug
        ? { ...w, state: r.state, set_by: 'explicit' }
        : w
      ))
    } finally {
      setUpdating(u => ({ ...u, [slug]: false }))
    }
  }

  if (!watches) return <SectionShell title="Watches" subtitle="Loading…" />

  return (
    <SectionShell
      title="Watches"
      subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
    >
      {watches.length === 0 && (
        <p className="muted">No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.</p>
      )}
      {watches.length > 0 && (
        <table className="settings-table">
          <thead>
            <tr>
              <th>RFC</th>
              <th>State</th>
              <th>Set by</th>
              <th>Last participation</th>
            </tr>
          </thead>
          <tbody>
            {watches.map(w => (
              <tr key={w.rfc_slug}>
                <td>
                  <Link to={entryPath(pid, w.rfc_slug)}>{w.rfc_title || w.rfc_slug}</Link>
                </td>
                <td>
                  <select
                    value={w.state}
                    onChange={e => update(w.rfc_slug, e.target.value)}
                    disabled={!!updating[w.rfc_slug]}
                  >
                    <option value="watching">Watching</option>
                    <option value="following">Following</option>
                    <option value="muted">Muted</option>
                  </select>
                </td>
                <td>
                  <span className={`set-by set-by-${w.set_by}`}>
                    {w.set_by === 'explicit' ? 'You' : 'Auto'}
                  </span>
                </td>
                <td className="muted">
                  {w.last_participation_at || '—'}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </SectionShell>
  )
}

// ── §15.8 per-user mute list + typeahead add ───────────────────────────────

function MutesSection({ viewer }) {
  const [mutes, setMutes] = useState(null)
  const [error, setError] = useState(null)

  async function refresh() {
    try {
      const r = await listMutes()
      setMutes(r.items || [])
    } catch (e) {
      setError(e.message)
    }
  }

  useEffect(() => { refresh() }, [])

  async function remove(userId) {
    await unmuteUser(userId)
    setMutes(prev => prev.filter(m => m.muted_user_id !== userId))
  }

  return (
    <SectionShell
      title="Muted users"
      subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
    >
      {viewer.role === 'owner' || viewer.role === 'admin' ? (
        <p className="muted">
          Owners and admins cannot mute notifications — the role requires
          receiving signals from everyone. (§15.8)
        </p>
      ) : (
        <MuteTypeahead onMuted={refresh} />
      )}
      {mutes && mutes.length === 0 && (
        <p className="muted">No active mutes.</p>
      )}
      {mutes && mutes.length > 0 && (
        <ul className="mutes-list">
          {mutes.map(m => (
            <li key={m.muted_user_id} className="mutes-row">
              <span className="mute-handle">@{m.gitea_login}</span>
              <span className="mute-name muted">{m.display_name}</span>
              <span className="mute-when muted">since {m.muted_at}</span>
              <button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
                Unmute
              </button>
            </li>
          ))}
        </ul>
      )}
      {error && <p className="settings-note warning">{error}</p>}
    </SectionShell>
  )
}

// The mute-list read endpoint isn't a separate route in the API today
// (§17 names add/delete only); we read the join here against /api/users/me
// via a tiny dedicated endpoint that mirrors the watches shape. For
// Slice 7's v1 surface, we compute the list client-side from a server
// endpoint that returns the joined rows — added in api_admin.py's
// neighborhood for proximity.
async function listMutes() {
  const res = await fetch('/api/users/me/notification-mutes')
  if (!res.ok) {
    const detail = await res.text()
    throw new Error(detail || `HTTP ${res.status}`)
  }
  return res.json()
}

function MuteTypeahead({ onMuted }) {
  const [q, setQ] = useState('')
  const [results, setResults] = useState([])
  const [open, setOpen] = useState(false)
  const [busy, setBusy] = useState(false)
  const [hint, setHint] = useState('')

  useEffect(() => {
    const t = setTimeout(() => {
      searchUsers(q).then(r => setResults(r.items || []))
    }, 120)
    return () => clearTimeout(t)
  }, [q])

  async function mute(user) {
    setBusy(true)
    setHint('')
    try {
      await muteUser(user.id)
      setQ('')
      setOpen(false)
      onMuted?.()
    } catch (e) {
      setHint(e.message)
    } finally {
      setBusy(false)
    }
  }

  return (
    <div className="mute-typeahead">
      <input
        type="text"
        placeholder="Mute a user by login or name…"
        value={q}
        onChange={e => { setQ(e.target.value); setOpen(true) }}
        onFocus={() => setOpen(true)}
        onBlur={() => setTimeout(() => setOpen(false), 150)}
        disabled={busy}
      />
      {open && results.length > 0 && (
        <ul className="mute-typeahead-results">
          {results.map(r => (
            <li key={r.id}>
              <button onClick={() => mute(r)} disabled={busy}>
                <span className="mute-handle">@{r.gitea_login}</span>
                <span className="muted">{r.display_name}</span>
                {(r.role === 'owner' || r.role === 'admin') && (
                  <span className="muted">{r.role}</span>
                )}
              </button>
            </li>
          ))}
        </ul>
      )}
      {hint && <p className="settings-note warning">{hint}</p>}
    </div>
  )
}

// ── Small layout primitives ────────────────────────────────────────────────

function SectionShell({ title, subtitle, children }) {
  return (
    <section className="settings-section">
      <header>
        <h2>{title}</h2>
        {subtitle && <p className="settings-sub">{subtitle}</p>}
      </header>
      <div className="settings-section-body">{children}</div>
    </section>
  )
}

function Toggle({ label, description, checked, onChange, disabled, title }) {
  return (
    <label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
      <input
        type="checkbox"
        checked={!!checked}
        onChange={e => onChange?.(e.target.checked)}
        disabled={disabled}
      />
      <span className="toggle-text">
        <span className="toggle-label">{label}</span>
        {description && <span className="toggle-desc">{description}</span>}
      </span>
    </label>
  )
}
