// Login state machine — consumes LoginEvent (one per inbound bridge bot
// reply) and emits a typed UI state. The widget renders the QR panel and
// the status pill from this state, never from raw reply strings.
//
// Discord vs Telegram differences:
//   - QR-only: there's no phone/code/password ladder, so the state space
//     is much smaller than the Telegram reducer.
//   - status comes from `ping` (legacy mautrix command system), not
//     `list-logins` (bridgev2). The four ping replies map to four states:
//     disconnected / connected / connection_dead / token_stored.
//   - no list-logins-derived `loginId`; logout is the bare `logout` verb,
//     so the connected state doesn't need to gate on a login id.
//   - the QR is NOT rotated by Discord remoteauth (single image per
//     login attempt). The state machine still tracks `qrEventId` so the
//     redaction handler can match against it and ignore unrelated cleanup.
//
// State-gating policy: late-arriving replies from cancelled flows must
// not resurrect dead state. The `cancel_pending` action ALWAYS lands us
// in `disconnected` immediately; later bridge events arriving after
// cancel are filtered by the live reducer.

import type { LoginEvent } from './bridge-protocol/types';

export type LoginErrorFlag =
  | { kind: 'login_failed'; reason?: string }
  | { kind: 'captcha_required' }
  // Local rollback flag — fired when the widget couldn't deliver
  // `login-captcha <token>` to the bridge (transport / Matrix-API failure
  // before the bridge even saw the command). Distinct from
  // `login_failed` (which IS a bridge reply) so the UX can read «sign-in
  // didn't reach the bot, retry» instead of hinting at a Discord-side
  // problem the user can't act on.
  | { kind: 'captcha_send_failed' }
  // The captcha challenge timed out (rqtoken expiry on Discord's side)
  // before the user solved it. Surfaced as a soft warning to retry
  // login-qr from scratch.
  | { kind: 'captcha_expired' }
  | { kind: 'login_websocket_failed'; reason?: string }
  | { kind: 'connect_after_login_failed'; reason?: string }
  | { kind: 'prepare_login_failed'; reason?: string }
  | { kind: 'already_logged_in' }
  | { kind: 'unknown_command' };
// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live
// reducer routes that event back to `connected_dead` (no error surface
// there — the connected-dead pill IS the error indicator) without
// staging a reason for `localizeError`. If a future UI change wants to
// surface the reason, add `lastError?: ...` to the connected_dead state
// shape and route `reconnect_failed` through it.

// A live form is open and waiting for user action. M-discord ships with
// only one: the QR panel. Hydrate's restorable shape collapses to this
// single variant + the `qr_verifying` interstitial.
export type PendingFormState = {
  kind: 'awaiting_qr_scan';
  discordUrl: string;
  qrEventId: string;
  firstShownAt: number;
  lastError?: LoginErrorFlag;
};

// hCaptcha challenge surfaced by the Vojo-patched bridge after Discord
// returned 400+captcha-required. The widget renders the hCaptcha iframe
// from `sitekey` + `rqdata`; on solve, the App sends `login-captcha
// <token>` and we transition to `qr_verifying` until the bridge replies.
export type CaptchaSolveState = {
  kind: 'awaiting_captcha_solve';
  service: string;
  sitekey: string;
  sessionId: string;
  rqdata: string;
  rqtoken: string;
  firstShownAt: number;
};

export type LoginState =
  // Pre-handshake / pre-ping. Status pill: --faint.
  | { kind: 'unknown' }
  // ping returned `not_logged_in`, OR logout completed. Status pill:
  // --rose. The card grid offers the QR-login affordance.
  | { kind: 'disconnected'; lastError?: LoginErrorFlag }
  // QR-login in progress. Optimistically transitioned by `start_qr_login`;
  // overwritten with real discordUrl/qrEventId by the live `qr_displayed`
  // event. Status pill: --amber.
  | PendingFormState
  // hCaptcha challenge from Discord — Vojo-patched bridge surfaced the
  // sitekey + rqdata via `VOJO-CAPTCHA-CHALLENGE-V1` notice. The widget
  // renders the hCaptcha iframe; on solve we send `login-captcha <token>`
  // and transition to `qr_verifying`. Status pill: --amber.
  | CaptchaSolveState
  // QR was redacted (i.e. the bridge accepted a scan), but we don't yet
  // know whether login succeeded. Held as an intermediate spinner until
  // the next bridge signal arrives. Status pill: --amber.
  | { kind: 'qr_verifying' }
  // logout in flight — waiting for `Logged out successfully.`. Status
  // pill: --amber.
  | { kind: 'logging_out' }
  // reconnect in flight (recovery from connection_dead / token_stored).
  // Waiting for `Successfully reconnected` or `You're already connected`.
  // Status pill: --amber. `handle` is carried through from the
  // connected_dead state so a successful reconnect can flip directly to
  // `connected{handle}` without bouncing through a transient `unknown`
  // (which would briefly paint a faint «Проверка статуса…» pill — bad
  // UX immediately after the user took an action).
  | { kind: 'reconnecting'; handle?: string }
  // Live session — ping or login_success confirmed. Discord legacy bridge
  // doesn't have a per-account loginId concept (single Discord account
  // per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
  // is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
  // right after login_success; it survives the post-login re-ping and the
  // reconnect-ok transitions so the «Open in Channels» card stays visible
  // until logout. Absent until the sentinel arrives (and absent forever
  // against an UNPATCHED bridge — the card simply never appears).
  | { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
  // ping says we have a token but the connection's down. Status pill:
  // green-ish but with a Reconnect recovery action exposed. The reducer
  // distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
  // (we have the token but never got far enough to connect), but the UI
  // collapses both into the same shape — they share the recovery path.
  | { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string };

// States that the hydrate path can restore after a reload. The QR panel
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
// timeline; `qr_verifying` covers the post-scan pre-success interstitial;
// `awaiting_captcha_solve` covers the case where the user reloads while
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
// often valid for a couple of minutes — fresh enough to reuse). Other
// transient states (logging_out, reconnecting) deliberately don't survive.
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };

// Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions optimistically advance state —
// the App rolls them back on send-failure where the bot would otherwise
// leave us stuck.
export type LoginAction =
  | { kind: 'event'; event: LoginEvent }
  | { kind: 'start_qr_login' } // user clicked «Войти по QR»
  | { kind: 'request_logout' } // user clicked «Выйти из Discord»
  // user clicked «Переподключиться» — App passes the current handle
  // (from `connected_dead.handle` or `connected.handle`) so the
  // transient `reconnecting` state carries it forward; without this the
  // post-reconnect_ok branch can't paint the connected pill until the
  // follow-up ping resolves.
  | { kind: 'request_reconnect'; handle?: string }
  // Discord legacy mautrix has no `cancel` command. Cancel is LOCAL —
  // returns the widget to disconnected immediately; the bridge's
  // remoteauth websocket eventually times out on its own. The action is
  // kept symmetrical with TG's reducer for shape consistency, but
  // dispatching it doesn't trigger any send.
  | { kind: 'cancel_pending' }
  // User finished an hCaptcha challenge — token is non-empty. Optimistic
  // transition to `qr_verifying`; the App fires `login-captcha <token>`
  // and the bridge's reply (`login_success` / chained `captcha_challenge`
  // / `login_failed`) lands via the live event stream.
  | { kind: 'submit_captcha_token' }
  // Rollback for `submit_captcha_token` — the App couldn't deliver the
  // command to the bridge. Routes back to disconnected with a localized
  // error so the user sees what happened.
  | { kind: 'captcha_send_failed' }
  // hCaptcha challenge expired (server rqtoken TTL or local 90s timer).
  // Routes to disconnected with a localized warn.
  | { kind: 'captcha_expired' }
  | { kind: 'hydrate'; state: HydrateRestoredState };

export const initialLoginState: LoginState = { kind: 'unknown' };

const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';

// States that a fresh captcha challenge can clobber: the QR scan has
// landed (or is mid-flight), or we're already showing a previous
// challenge that Discord chained on top.
const isCaptchaAcceptingState = (
  s: LoginState
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
  s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';

export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
  if (action.kind === 'hydrate') {
    // hydrate is a one-shot mount-time seed. If a live event already
    // moved us off `unknown`, the live truth wins; the cached timeline
    // snapshot is by definition older than what the live event just told
    // us. Without this gate, a stale `awaiting_qr_scan` from a previous
    // session could overwrite a legitimate `connected` that arrived
    // during the readTimeline await.
    if (state.kind !== 'unknown') return state;
    return action.state;
  }
  if (action.kind === 'start_qr_login') {
    // Optimistic placeholder; the live `qr_displayed` event overwrites
    // discordUrl + qrEventId + firstShownAt. If the `!discord login-qr`
    // send fails, the App rolls back to `disconnected`.
    return {
      kind: 'awaiting_qr_scan',
      discordUrl: '',
      qrEventId: '',
      firstShownAt: Date.now(),
    };
  }
  if (action.kind === 'request_logout') {
    return { kind: 'logging_out' };
  }
  if (action.kind === 'request_reconnect') {
    return { kind: 'reconnecting', handle: action.handle };
  }
  if (action.kind === 'cancel_pending') {
    // Optimistic: drop straight back to disconnected. Discord legacy mautrix
    // has no `cancel` command — the bridge's remoteauth websocket continues
    // until it succeeds or times out internally. From the user's POV the
    // widget returns to disconnected, and any later QR redaction / login
    // success / login failure event from the abandoned flow is filtered
    // by the per-event gates below (qr_redacted gated on awaiting_qr_scan,
    // login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
    return { kind: 'disconnected' };
  }
  if (action.kind === 'submit_captcha_token') {
    // User solved hCaptcha and we're about to fire login-captcha. Hold
    // `qr_verifying` until the bridge replies (success / chained challenge
    // / generic login_failed). Only honour from the captcha state — this
    // action is App-emitted right after the hCaptcha callback, so it
    // shouldn't ever fire from anywhere else, but defensive: a stale send
    // shouldn't suddenly paint a verifying spinner over a connected pill.
    if (state.kind !== 'awaiting_captcha_solve') return state;
    return { kind: 'qr_verifying' };
  }
  if (action.kind === 'captcha_send_failed') {
    // App couldn't ship the `login-captcha` command (transport / Matrix
    // API failure before the bridge saw it). Roll the optimistic
    // `qr_verifying` back to disconnected with a localized error.
    //
    // Honour ONLY from `qr_verifying` — narrowed from also accepting
    // `awaiting_captcha_solve` to avoid clobbering a fresh chained
    // captcha that may have arrived between the optimistic dispatch and
    // the failed-send rollback. If the live state already moved to a
    // newer challenge, the stale send-failure should be silently dropped.
    if (state.kind !== 'qr_verifying') return state;
    return { kind: 'disconnected', lastError: { kind: 'captcha_send_failed' } };
  }
  if (action.kind === 'captcha_expired') {
    // Local 90s timer or hCaptcha's expired-callback fired — the rqtoken
    // is dead, the user has nothing to solve. Route to disconnected with
    // a localized warn so they retry login-qr from scratch.
    if (state.kind !== 'awaiting_captcha_solve') return state;
    return { kind: 'disconnected', lastError: { kind: 'captcha_expired' } };
  }

  const event = action.event;
  switch (event.kind) {
    // --- ping replies ----------------------------------------------------

    case 'not_logged_in':
      // Accept from states where flipping to disconnected is correct.
      // Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan
      // (which was started after the ping was fired but before the reply
      // landed) — that's the same race the TG reducer guards against.
      if (
        state.kind === 'unknown' ||
        state.kind === 'disconnected' ||
        state.kind === 'logging_out' ||
        state.kind === 'qr_verifying' ||
        state.kind === 'reconnecting' ||
        state.kind === 'connected_dead'
      ) {
        return { kind: 'disconnected' };
      }
      return state;

    case 'logged_in':
      // Authoritative source — accept from any state. Used by both the
      // initial ping AND the post-`login_success` re-ping that picks up
      // the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
      // `connected` so the post-login_success re-ping doesn't blank the
      // CTA before the user gets a chance to click it.
      return {
        kind: 'connected',
        handle: event.handle,
        discordId: event.discordId,
        spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
      };

    case 'connection_dead':
      // ping says token's good but the WS is down. Show the connected
      // chrome with a Reconnect recovery action.
      return {
        kind: 'connected_dead',
        reason: 'connection_dead',
        handle: state.kind === 'connected' ? state.handle : undefined,
      };

    case 'token_stored_not_connected':
      return {
        kind: 'connected_dead',
        reason: 'token_stored',
        handle: state.kind === 'connected' ? state.handle : undefined,
      };

    // --- QR lifecycle ----------------------------------------------------

    case 'qr_displayed': {
      // Defence-in-depth: an inbound qr_displayed MUST carry a non-empty
      // event id (the host driver rejects empty event_id at the sanitizer;
      // this is a redundant guard).
      if (event.eventId.length === 0) return state;

      // Initial QR from a fresh login attempt — accept from:
      //   * `unknown` — cold-start before ping resolves;
      //   * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login.
      //
      // We DO NOT accept from `disconnected`. Discord legacy mautrix has
      // no cancel command, so when the user clicks Cancel locally the
      // bridge's remoteauth goroutine continues until success / failure
      // / internal timeout. The widget transitions to `disconnected`
      // immediately, but the bridge eventually emits the m.image. If we
      // accepted that here, the user would see a QR they didn't ask for
      // — the bridge has no way to know the user moved on. Drop it
      // silently; the user has to click «Войти по QR» again to express
      // intent (which resets the placeholder and lets the next m.image
      // land).
      if (
        state.kind === 'unknown' ||
        (state.kind === 'awaiting_qr_scan' && state.qrEventId === '')
      ) {
        return {
          kind: 'awaiting_qr_scan',
          discordUrl: event.discordUrl,
          qrEventId: event.eventId,
          firstShownAt:
            state.kind === 'awaiting_qr_scan' && state.firstShownAt
              ? state.firstShownAt
              : Date.now(),
        };
      }

      if (state.kind !== 'awaiting_qr_scan') return state;

      // Hypothetical edit pointing at our anchor — repaint URL, keep id.
      // Discord doesn't currently edit QRs but the path stays for
      // forward-compat (cheaper to keep than to reconstruct).
      if (event.replacesEventId === state.qrEventId) {
        return { ...state, discordUrl: event.discordUrl };
      }

      // Fresh non-edit qr_displayed while we're already tracking one —
      // could be a bridge-side restart (rare). Adopt as new anchor.
      if (!event.replacesEventId) {
        return {
          kind: 'awaiting_qr_scan',
          discordUrl: event.discordUrl,
          qrEventId: event.eventId,
          firstShownAt: Date.now(),
        };
      }

      // Edit pointing at something we don't track — ignore.
      return state;
    }

    case 'qr_redacted': {
      // Bridge cleaned up the QR after a successful scan (commands.go
      // l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)`
      // — only fires on the success path). Held as `qr_verifying` until
      // the success line lands. Only honour from awaiting_qr_scan with a
      // matching event id.
      if (state.kind !== 'awaiting_qr_scan') return state;
      if (state.qrEventId !== event.redactsEventId) return state;
      return { kind: 'qr_verifying' };
    }

    case 'login_success':
      // Honour from any non-terminal state. The bridge's success line
      // doesn't include the discordId; the App fires `ping` afterwards
      // to upgrade to the full `connected{handle, discordId}` shape.
      return { kind: 'connected', handle: event.handle };

    case 'login_failed':
      // Generic Discord-side login failure — bridge replies «Error logging
      // in: <go-error>». Routes back to disconnected with the verbatim
      // reason as a warn line. Only honour when a QR flow is in flight,
      // OR while the user is solving a captcha (the Vojo-patched bridge
      // can also reply «Error logging in: …» AFTER `login-captcha` if
      // Discord rejects the post-solve replay). Otherwise it's stale
      // (e.g. an old failure replaying after page reload while the user
      // is already connected).
      if (!isCaptchaAcceptingState(state)) return state;
      return {
        kind: 'disconnected',
        lastError: { kind: 'login_failed', reason: event.reason },
      };

    case 'captcha_required':
      // UNPATCHED bridge fallback: «CAPTCHAs are currently not supported».
      // Surface as a hint suggesting token-login (chat-fallback only). On
      // a Vojo-patched bridge this branch never fires — see captcha_challenge.
      if (!isCaptchaAcceptingState(state)) return state;
      return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };

    case 'captcha_challenge':
      // Vojo-patched bridge surfaced an hCaptcha challenge — pivot the
      // widget to the captcha screen. Accept from awaiting_qr_scan
      // (challenge landed before the QR was redacted), qr_verifying
      // (challenge landed while we were still in the post-redact spinner)
      // or awaiting_captcha_solve (Discord chained another challenge after
      // the previous solve). Other states drop the event silently — a
      // stale challenge from an abandoned flow shouldn't repaint UI.
      if (!isCaptchaAcceptingState(state)) return state;
      return {
        kind: 'awaiting_captcha_solve',
        service: event.service,
        sitekey: event.sitekey,
        sessionId: event.sessionId,
        rqdata: event.rqdata,
        rqtoken: event.rqtoken,
        firstShownAt: Date.now(),
      };

    case 'login_websocket_failed':
      // Pre-QR failure: couldn't reach Discord remoteauth. The QR was
      // never displayed in the first place. State `awaiting_qr_scan` with
      // empty discordUrl is the placeholder set by `start_qr_login`;
      // this fires before the first qr_displayed lands.
      if (!isCaptchaAcceptingState(state)) return state;
      return {
        kind: 'disconnected',
        lastError: { kind: 'login_websocket_failed', reason: event.reason },
      };

    case 'connect_after_login_failed':
      // Post-scan rare: remoteauth gave us a token, but the bridge couldn't
      // connect to Discord with it. The bridge has the token cached and
      // might recover on next ping; we still route to disconnected so the
      // user can retry.
      if (!isCaptchaAcceptingState(state)) return state;
      return {
        kind: 'disconnected',
        lastError: { kind: 'connect_after_login_failed', reason: event.reason },
      };

    case 'prepare_login_failed':
      if (!isCaptchaAcceptingState(state)) return state;
      return {
        kind: 'disconnected',
        lastError: { kind: 'prepare_login_failed', reason: event.reason },
      };

    case 'already_logged_in':
      // The user clicked «Войти по QR» but the bridge is already logged
      // in — race against ping. Surface a soft warning and let the App's
      // re-ping reconcile to the connected state.
      if (isFormState(state)) {
        return { ...state, lastError: { kind: 'already_logged_in' } };
      }
      return state;

    // --- logout ----------------------------------------------------------

    case 'logout_ok':
    case 'logout_no_op':
      // Late `Logged out` from a previous session can arrive while the
      // user is mid-new-flow. Only honour from logging_out; other states
      // keep their flow.
      if (state.kind !== 'logging_out') return state;
      return { kind: 'disconnected' };

    // --- disconnect (read-only, never sent by widget) -------------------

    case 'disconnect_ok':
    case 'disconnect_no_op':
      // User typed `disconnect` manually in chat-fallback while the widget
      // was open. Reflect the bridge's truth: no token-loss, but no live
      // connection either — same shape as `token_stored`. Both
      // `connected` (string handle) and `connected_dead` (handle?:
      // string) expose `handle` on the same key, so a single read works.
      if (state.kind === 'connected' || state.kind === 'connected_dead') {
        return {
          kind: 'connected_dead',
          reason: 'token_stored',
          handle: state.handle,
        };
      }
      return state;

    case 'disconnect_failed':
      // Manual disconnect attempt failed — keep current state, the widget
      // doesn't surface a UI for this since it never sent the command.
      return state;

    // --- reconnect -------------------------------------------------------

    case 'reconnect_ok':
    case 'reconnect_no_op':
      // After a successful reconnect, ping is the source of truth for the
      // handle. The App fires `ping` after this event lands to refresh.
      // We flip to `connected` immediately so the user sees an immediate
      // green pill confirming their click; the post-event ping refreshes
      // the handle / discordId within ~100ms. Both `reconnecting` and
      // `connected_dead` carry `handle?` — a missing handle still flips
      // green with an empty handle, which the UI's
      // `state.handle ? connected-as : connected` ternary tolerates.
      // This avoids the `unknown` flap that the previous draft would
      // produce when no handle was stashed. spaceMatrixToUrl is not
      // restorable from connected_dead (the dead state never carried it),
      // so the CTA stays hidden until a fresh sentinel arrives — bridge
      // does NOT re-emit on reconnect, but the card returns once the user
      // explicitly re-logs in.
      if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
        return { kind: 'connected', handle: state.handle ?? '' };
      }
      return state;

    case 'space_ready':
      // Vojo-patched bridge surfaced the personal Discord space — attach
      // its matrix.to URL to the connected state so the «Open in Channels»
      // card renders. Late-arriving sentinels from an abandoned flow drop
      // here silently (e.g. a sentinel that lands during `logging_out`
      // mustn't resurrect a connected state). Honour only from the
      // canonical alive states.
      if (state.kind === 'connected') {
        return { ...state, spaceMatrixToUrl: event.matrixToUrl };
      }
      return state;

    case 'reconnect_failed':
      if (state.kind !== 'reconnecting') return state;
      // Roll back to connected_dead carrying the previous handle. The
      // user can hit Reconnect again or refresh. We don't surface the
      // error reason here — the connected_dead pill itself reads as
      // «something is wrong, try Reconnect» — adding a transient red
      // banner adjacent to a recovery affordance is overkill.
      return {
        kind: 'connected_dead',
        reason: 'connection_dead',
        handle: state.handle,
      };

    // --- bridge-side errors ---------------------------------------------

    case 'unknown_command':
      // Shouldn't happen — we only send commands the bridge knows. Visible
      // when /config.json's commandPrefix drifts from the bridge's actual
      // command_prefix. Surface loudly on disconnected.
      return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };

    case 'unknown':
      return state;

    default: {
      // Exhaustiveness check — TS flags this if a new LoginEvent kind is
      // added without a case here.
      const exhaustive: never = event;
      return exhaustive;
    }
  }
};

// --- hydrate-from-timeline -----------------------------------------------
//
// Discord's hydrate is simpler than Telegram's because the QR flow has
// fewer states. We walk past→present and let each event freely transition
// the state — like the TG hydrate, this is permissive (no out-of-thin-air
// rejection) because we trust the bridge's durable timeline.

// 3 minutes — Discord remoteauth's server-side timeout sits around 2
// minutes (verified empirically against v0.7.6's remoteauth/client.go;
// no explicit constant in the lib, the server-side gateway closes the
// websocket on inactivity). We use 3 min as a slight safety margin so
// reload-after-success grace still works while the panel is still
// fresh enough to scan. Telegram's QR rotates internally and lives ~10
// min, which is why the TG widget uses 10 min — Discord's single-shot
// remoteauth needs the tighter window.
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
// hCaptcha rqtoken is even more short-lived than the QR ticket — Discord
// invalidates after ~90s in practice. If the user reloads while staring
// at a captcha challenge older than this, restoring the captcha screen
// only sets them up for a server-side rejection on solve. Drop the state
// instead and let live ping reconcile.
const CAPTCHA_HYDRATE_FRESHNESS_MS = 90 * 1000;

export type HydrateInput = {
  ev: LoginEvent;
  ts: number;
};

type HydrateAccumulator = {
  state: LoginState;
  pendingTs: number | null;
  terminated: boolean;
};

const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
  const { ev, ts } = input;

  // After a terminal event we normally stop — except if a fresh
  // `qr_displayed` shows up, that's the bridge signature of a NEW login
  // flow. The user cancelled (or finished) and is now logging in again;
  // the chain should resume tracking from the new start. Without this
  // re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then-
  // re-login-mid-QR) would return null.
  if (prevAcc.terminated && ev.kind !== 'qr_displayed') {
    return prevAcc;
  }
  // Restart-on-re-entry: clear the terminated bit AND any prior tracked
  // state so the new flow's first event becomes the new anchor without
  // inheriting the old QR's eventId.
  const acc: HydrateAccumulator = prevAcc.terminated
    ? { state: { kind: 'unknown' }, pendingTs: null, terminated: false }
    : prevAcc;

  switch (ev.kind) {
    case 'qr_displayed': {
      // Same anchor logic as the live reducer.
      if (acc.state.kind !== 'awaiting_qr_scan') {
        return {
          state: {
            kind: 'awaiting_qr_scan',
            discordUrl: ev.discordUrl,
            qrEventId: ev.eventId,
            firstShownAt: ts,
          },
          pendingTs: ts,
          terminated: false,
        };
      }
      if (ev.replacesEventId === acc.state.qrEventId) {
        return {
          state: { ...acc.state, discordUrl: ev.discordUrl },
          pendingTs: ts,
          terminated: false,
        };
      }
      if (!ev.replacesEventId) {
        return {
          state: {
            kind: 'awaiting_qr_scan',
            discordUrl: ev.discordUrl,
            qrEventId: ev.eventId,
            firstShownAt: ts,
          },
          pendingTs: ts,
          terminated: false,
        };
      }
      return acc;
    }

    case 'qr_redacted': {
      if (acc.state.kind !== 'awaiting_qr_scan') return acc;
      if (acc.state.qrEventId !== ev.redactsEventId) return acc;
      // Move into qr_verifying and keep the chain open — the success line
      // typically follows in the same scan window.
      return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
    }

    case 'captcha_challenge': {
      // SECURITY: only accept a captcha challenge if the same chain has
      // already seen a `qr_displayed` (i.e. WE initiated the login).
      // Otherwise a malicious / compromised homeserver could craft an
      // m.notice with the sentinel JSON pointing at an attacker-controlled
      // sitekey, the user solves it on reload, and the resulting hCaptcha
      // token is sent verbatim to the bridge — useful free captcha-solving
      // labour for the attacker, and a phishing surface. The live reducer
      // already gates on `isCaptchaAcceptingState` (which requires we're
      // mid-flow), but the hydrate path replays raw timeline events
      // without the live state — drop unsolicited challenges here.
      if (acc.state.kind !== 'awaiting_qr_scan' && acc.state.kind !== 'qr_verifying') {
        return acc;
      }
      // Vojo-patched bridge surfaced an hCaptcha — keep the chain open so a
      // later `login_success` / `login_failed` still lands as terminal.
      // The rqdata/rqtoken are short-lived on Discord's side (~2 min);
      // the captcha-specific freshness gate in `hydrateFromTimeline`
      // (CAPTCHA_HYDRATE_FRESHNESS_MS) drops stale states before they
      // surface to the user.
      return {
        state: {
          kind: 'awaiting_captcha_solve',
          service: ev.service,
          sitekey: ev.sitekey,
          sessionId: ev.sessionId,
          rqdata: ev.rqdata,
          rqtoken: ev.rqtoken,
          firstShownAt: ts,
        },
        pendingTs: ts,
        terminated: false,
      };
    }

    // Terminal events — collapse the chain. State becomes whatever the
    // bot confirmed last; the caller returns null and lets live `ping`
    // reconcile.
    case 'login_success':
    case 'logged_in':
    case 'logout_ok':
    case 'logout_no_op':
    case 'not_logged_in':
    case 'connection_dead':
    case 'token_stored_not_connected':
    case 'reconnect_ok':
    case 'reconnect_no_op':
    case 'reconnect_failed':
    case 'disconnect_ok':
    case 'disconnect_no_op':
    case 'disconnect_failed':
    case 'login_failed':
    case 'captcha_required':
    case 'login_websocket_failed':
    case 'connect_after_login_failed':
    case 'prepare_login_failed':
    case 'unknown_command':
      return { state: acc.state, pendingTs: null, terminated: true };

    case 'already_logged_in':
    case 'unknown':
    case 'space_ready':
      // Soft no-op for hydrate. already_logged_in is a live-flow warning
      // that doesn't reflect persistent state; unknown is a wording-drift
      // catch-all; space_ready is a post-terminal sentinel — hydrate
      // terminates on login_success and lets live ping reconcile, so
      // the URL gets attached on the live path, not here.
      return acc;

    default: {
      const exhaustive: never = ev;
      return exhaustive;
    }
  }
};

export const hydrateFromTimeline = (
  inputs: ReadonlyArray<HydrateInput>,
  now: number = Date.now()
): HydrateRestoredState | null => {
  const acc = inputs.reduce<HydrateAccumulator>(stepHydrate, {
    state: { kind: 'unknown' },
    pendingTs: null,
    terminated: false,
  });

  if (acc.terminated) return null;
  if (acc.pendingTs === null) return null;
  // Tighter freshness gate for captcha state — rqtoken expires faster
  // than the QR ticket. This protects the user from a "solved captcha,
  // bridge rejects, user confused" UX after a slow reload.
  const freshness =
    acc.state.kind === 'awaiting_captcha_solve'
      ? CAPTCHA_HYDRATE_FRESHNESS_MS
      : HYDRATE_FRESHNESS_MS;
  if (now - acc.pendingTs > freshness) return null;
  if (acc.state.kind === 'qr_verifying') return acc.state;
  if (acc.state.kind === 'awaiting_captcha_solve') return acc.state;
  if (!isFormState(acc.state)) return null;
  return acc.state;
};

// --- DEV sanity assertions ------------------------------------------------

if (import.meta.env.DEV) {
  runHydrateSanity();
}

function runHydrateSanity(): void {
  const t0 = 1_700_000_000_000;
  const recent = (offset: number) => t0 + offset;
  const now = t0 + 60 * 1000;

  const cases: Array<{
    name: string;
    inputs: HydrateInput[];
    expected: LoginState | null;
    nowOverride?: number;
  }> = [
    { name: 'empty timeline → null', inputs: [], expected: null },
    {
      name: 'lone qr_displayed → awaiting_qr_scan',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
      ],
      expected: {
        kind: 'awaiting_qr_scan',
        discordUrl: 'https://discord.com/ra/A',
        qrEventId: '$qrA',
        firstShownAt: recent(0),
      },
    },
    {
      name: 'qr_redacted with mismatched target → ignored',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
        { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) },
      ],
      expected: {
        kind: 'awaiting_qr_scan',
        discordUrl: 'https://discord.com/ra/A',
        qrEventId: '$qrA',
        firstShownAt: recent(0),
      },
    },
    {
      name: 'qr scan → no follow-up → qr_verifying (reload during the gap)',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
        { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
      ],
      expected: { kind: 'qr_verifying' },
    },
    {
      name: 'qr scan → login_success → null (terminal — let ping reconcile)',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
        { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
        { ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) },
      ],
      expected: null,
    },
    {
      name: 'login_failed after qr → null (terminal)',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
        { ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) },
      ],
      expected: null,
    },
    {
      name: 'captcha_required after qr → null (terminal)',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
          ts: recent(0),
        },
        { ev: { kind: 'captcha_required' }, ts: recent(10000) },
      ],
      expected: null,
    },
    {
      name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)',
      inputs: [
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' },
          ts: recent(0),
        },
        { ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) },
        { ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) },
        { ev: { kind: 'logout_ok' }, ts: recent(20000) },
        {
          ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' },
          ts: recent(25000),
        },
      ],
      expected: {
        kind: 'awaiting_qr_scan',
        discordUrl: 'https://discord.com/ra/NEW',
        qrEventId: '$qrNew',
        firstShownAt: recent(25000),
      },
    },
    {
      name: 'pending too old (5 min) → null (freshness guard, 3-min window)',
      inputs: [
        {
          ev: {
            kind: 'qr_displayed',
            discordUrl: 'https://discordapp.com/ra/A',
            eventId: '$qrA',
          },
          ts: t0 - 5 * 60 * 1000,
        },
      ],
      expected: null,
      nowOverride: t0,
    },
    {
      name: 'pending just inside window (2 min) → state',
      inputs: [
        {
          ev: {
            kind: 'qr_displayed',
            discordUrl: 'https://discordapp.com/ra/A',
            eventId: '$qrA',
          },
          ts: t0 - 2 * 60 * 1000,
        },
      ],
      expected: {
        kind: 'awaiting_qr_scan',
        discordUrl: 'https://discordapp.com/ra/A',
        qrEventId: '$qrA',
        firstShownAt: t0 - 2 * 60 * 1000,
      },
      nowOverride: t0,
    },
    {
      name: 'connection_dead alone → null (terminal — let live ping reconcile)',
      inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }],
      expected: null,
    },
    {
      name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)',
      inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }],
      expected: null,
    },
    {
      name: 'logged_in alone → null (terminal — let live ping reconcile)',
      inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }],
      expected: null,
    },
    {
      name: 'unknown alone → null',
      inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }],
      expected: null,
    },
  ];

  for (const c of cases) {
    const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now);
    if (!sameLoginState(actual, c.expected)) {
      // eslint-disable-next-line no-console
      console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected });
      throw new Error(`hydrate sanity failed: ${c.name}`);
    }
  }
}

function sameLoginState(a: LoginState | null, b: LoginState | null): boolean {
  if (a === null || b === null) return a === b;
  return JSON.stringify(a) === JSON.stringify(b);
}
