// Dialect: mautrix-discord v0.7.6 (16 Feb 2026). The bridge runs on the
// LEGACY mautrix command framework — `maunium.net/go/mautrix/bridge/commands`,
// NOT bridgev2. As of January 2026 the mautrix maintainers flagged Discord
// as «not yet migrated to bridgev2» (mau.fi/blog/2026-01-mautrix-release/),
// so this dialect is the canonical one until the v2 migration lands.
//
// Each regex below is paired with its upstream source line in
// github.com/mautrix/discord/blob/v0.7.6/commands.go. If wording drifts in
// a future patch, replace this file with a sibling `legacy_v077.ts`
// (or whatever) and switch the import in ../parser.ts.
//
// Body encoding note: legacy mautrix commands use `ce.Reply(...)` which
// renders through `format.RenderMarkdown` in the bridge framework. Our
// host driver strips `formatted_body` (Phase 2 contract), so the widget
// only sees the markdown source — backticks, asterisks, escaped angle-
// brackets stay literal.

import type { LoginEvent, ParsableEvent } from '../types';

// --- Regex table ----------------------------------------------------------

// Ping replies — commands.go:fnPing (l.297-310 in v0.7.6). All four are
// distinct phrasings; we capture each separately so the state machine can
// route them to different status pills.
//
// «You're logged in as @<username> (`<id>`)» — the trailing parens hold the
// numeric Discord snowflake wrapped in markdown backticks. Both are useful
// for surfacing in the UI.
const PING_LOGGED_IN_RE = /^you'?re logged in as\s+@?(.+?)\s+\(`?(\d+)`?\)\.?$/i;
// «You're not logged in» — exact match, no period. The legacy framework
// doesn't append punctuation here.
const PING_NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
// «You have a Discord token stored, but are not connected for some reason 🤔»
// — the emoji is part of the literal upstream string and we tolerate optional
// trailing whitespace / period.
const PING_TOKEN_STORED_RE = /^you have a discord token stored, but are not connected/i;
// «You're logged in, but the Discord connection seems to be dead 💥»
const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection seems to be dead/i;

// login-token / login-qr success — commands.go:fnLoginToken (l.156) and
// fnLoginQR (l.220). Format: `Successfully logged in as @<username>`. The
// QR-login path doesn't include the snowflake; the token-login path has
// «Connecting to Discord as user ID %d» BEFORE the success line, but we
// only need the success terminator. Capturing the handle is enough — App
// fires `ping` after to pick up the snowflake.
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;

// login-qr CAPTCHA path — Vojo-patched bridge sends a single-line
// `VOJO-CAPTCHA-CHALLENGE-V1 {json}` m.notice carrying the hCaptcha
// sitekey, session_id, rqdata, rqtoken. The sentinel is markdown-inert by
// design (no `_`, `*`, `` ` ``, `[`, `<` characters): even if a future
// patch routes the bridge reply through goldmark again, the prefix
// survives intact. The bridge currently sends the notice via
// SendMessageEvent directly to bypass the framework's markdown round-trip.
// See bridge `commands_captcha.go` for the producer side.
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;

// Vojo-patched bridge emits this sentinel right after «Successfully logged
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
// matrix.to URL of the user's personal Discord space so the widget can
// render a CTA. Same markdown-inert + structured-JSON discipline as the
// captcha sentinel above; the bridge sends this via SendMessageEvent to
// bypass goldmark round-trip.
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;

// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// instead». Kept so a deployment running unpatched bridge still produces a
// useful hint rather than a generic Go-error tail.
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;

// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
// branches funnel through the same Reply call). Capture the Go-error tail
// as `reason`. Order matters: CAPTCHA_REQUIRED must be checked BEFORE this
// trap because the captcha case is a more specific subset.
const LOGIN_FAILED_RE = /^error logging in:\s*(.*)$/i;

// «Error connecting to login websocket: %v» — fnLoginQR l.184. Pre-QR
// failure (couldn't reach Discord's remoteauth gateway). Distinguish from
// LOGIN_FAILED so the App can surface a more accurate message.
const LOGIN_WEBSOCKET_FAILED_RE = /^error connecting to login websocket:\s*(.*)$/i;

// «Error connecting after login: %v» — fnLoginQR l.213. Post-QR rare path:
// remoteauth handed us a token but the immediate Discord connect failed.
const CONNECT_AFTER_LOGIN_FAILED_RE = /^error connecting after login:\s*(.*)$/i;

// «Failed to prepare login: %v» — fnLoginQR l.176. Pre-QR initialisation
// failure (remoteauth couldn't even start). Routes back to disconnected.
const PREPARE_LOGIN_FAILED_RE = /^failed to prepare login:\s*(.*)$/i;

// «You're already logged in» — both fnLoginToken (l.117) and fnLoginQR
// (l.171). Replied when the user clicks login but ping would have shown
// connected. We dispatch a re-ping to reconcile.
const ALREADY_LOGGED_IN_RE = /^you'?re already logged in\.?$/i;

// Logout — commands.go:fnLogout (l.275-280).
const LOGOUT_OK_RE = /^logged out successfully\.?$/i;
const LOGOUT_NO_OP_RE = /^you weren'?t logged in, but data was re-cleared/i;

// Disconnect — commands.go:fnDisconnect (l.318-326). User-typed-only path
// (the widget never sends `disconnect`), but recognising the replies keeps
// chat-fallback typists from confusing the state machine.
const DISCONNECT_OK_RE = /^successfully disconnected\.?$/i;
const DISCONNECT_NO_OP_RE = /^you'?re already not connected\.?$/i;
const DISCONNECT_FAILED_RE = /^error while disconnecting:\s*(.*)$/i;

// Reconnect — commands.go:fnReconnect (l.339-347). Used as recovery from
// `connection_dead` / `token_stored_not_connected` ping replies.
const RECONNECT_OK_RE = /^successfully reconnected\.?$/i;
const RECONNECT_NO_OP_RE = /^you'?re already connected\.?$/i;
const RECONNECT_FAILED_RE = /^error while reconnecting:\s*(.*)$/i;

// Unknown command — bridge/commands/processor.go (legacy framework). The
// exact wording differs between framework versions; this regex tolerates
// the canonical «Unknown command. Try `help`.» phrasing.
const UNKNOWN_COMMAND_RE = /^unknown command\.?\s*(?:try\s+)?`?help`?/i;

// --- Body parser ----------------------------------------------------------

const trimReplyBody = (raw: string): string => raw.trim();

export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
  const body = trimReplyBody(rawBody);
  if (body.length === 0) return { kind: 'unknown' };

  // ORDER MATTERS:
  // 1. CAPTCHA must be checked before the generic LOGIN_FAILED — captcha
  //    bodies match LOGIN_FAILED_RE but carry the more-specific suffix.
  // 2. LOGIN_SUCCESS_RE has a permissive `(.+?)` capture; we keep it AFTER
  //    explicit ping replies so a future ping wording drift can't swallow
  //    a success line.

  // Ping replies (most common) — try first.
  if (PING_NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
  if (PING_TOKEN_STORED_RE.test(body)) return { kind: 'token_stored_not_connected' };
  if (PING_CONNECTION_DEAD_RE.test(body)) return { kind: 'connection_dead' };
  const pingLoggedInMatch = PING_LOGGED_IN_RE.exec(body);
  if (pingLoggedInMatch) {
    return {
      kind: 'logged_in',
      handle: pingLoggedInMatch[1].trim(),
      discordId: pingLoggedInMatch[2],
    };
  }

  // Login lifecycle.
  // Vojo-patched bridge: structured hCaptcha challenge wins over every
  // legacy regex — checked first so the JSON payload survives.
  if (body.startsWith(CAPTCHA_CHALLENGE_PREFIX)) {
    const match = CAPTCHA_CHALLENGE_RE.exec(body);
    if (match) {
      try {
        const payload = JSON.parse(match[1]) as Record<string, unknown>;
        const service = typeof payload.service === 'string' ? payload.service : '';
        const sitekey = typeof payload.sitekey === 'string' ? payload.sitekey : '';
        const sessionId = typeof payload.session_id === 'string' ? payload.session_id : '';
        const rqdata = typeof payload.rqdata === 'string' ? payload.rqdata : '';
        const rqtoken = typeof payload.rqtoken === 'string' ? payload.rqtoken : '';
        if (sitekey && rqtoken) {
          return { kind: 'captcha_challenge', service, sitekey, sessionId, rqdata, rqtoken };
        }
      } catch {
        // fall through — malformed payload is treated as unknown
      }
    }
    return { kind: 'unknown' };
  }
  if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };

  // Vojo login-space sentinel: structured JSON with the personal Discord
  // space's matrix.to URL. Checked alongside the captcha sentinel —
  // markdown-inert prefix means it lands verbatim from the bridge, parsed
  // into a `space_ready` event for the reducer to attach to connected state.
  // Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
  // silently dropped as `unknown` rather than surfacing a stale CTA.
  if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
    const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
    if (match) {
      try {
        const payload = JSON.parse(match[1]) as Record<string, unknown>;
        const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
        if (matrixToUrl) {
          return { kind: 'space_ready', matrixToUrl };
        }
      } catch {
        // fall through — malformed payload is treated as unknown
      }
    }
    return { kind: 'unknown' };
  }

  const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
  if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };

  const connectAfterMatch = CONNECT_AFTER_LOGIN_FAILED_RE.exec(body);
  if (connectAfterMatch)
    return { kind: 'connect_after_login_failed', reason: connectAfterMatch[1].trim() };

  const prepareMatch = PREPARE_LOGIN_FAILED_RE.exec(body);
  if (prepareMatch) return { kind: 'prepare_login_failed', reason: prepareMatch[1].trim() };

  const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
  if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };

  if (ALREADY_LOGGED_IN_RE.test(body)) return { kind: 'already_logged_in' };

  // Login success — capture the handle. Discord usernames may include `.`
  // and other ASCII punctuation; the regex's `(.+?)` is greedy-enough.
  const successMatch = LOGIN_SUCCESS_RE.exec(body);
  if (successMatch) {
    const handleRaw = successMatch[1].trim();
    return { kind: 'login_success', handle: handleRaw };
  }

  // Logout / disconnect / reconnect lifecycle.
  if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
  if (LOGOUT_NO_OP_RE.test(body)) return { kind: 'logout_no_op' };

  if (DISCONNECT_OK_RE.test(body)) return { kind: 'disconnect_ok' };
  if (DISCONNECT_NO_OP_RE.test(body)) return { kind: 'disconnect_no_op' };
  const disconnectFailedMatch = DISCONNECT_FAILED_RE.exec(body);
  if (disconnectFailedMatch)
    return { kind: 'disconnect_failed', reason: disconnectFailedMatch[1].trim() };

  if (RECONNECT_OK_RE.test(body)) return { kind: 'reconnect_ok' };
  if (RECONNECT_NO_OP_RE.test(body)) return { kind: 'reconnect_no_op' };
  const reconnectFailedMatch = RECONNECT_FAILED_RE.exec(body);
  if (reconnectFailedMatch)
    return { kind: 'reconnect_failed', reason: reconnectFailedMatch[1].trim() };

  if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };

  return { kind: 'unknown' };
};

// --- Full-event parser ----------------------------------------------------
//
// `parseEventLegacyV076` dispatches on `event.type`:
//
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
//   redaction's `redacts` against the active QR event id; an unrelated
//   redaction is dropped silently.
//
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
//   contains a Discord remoteauth URL. Discord doesn't rotate the QR (no
//   m.replace edits), but we still honour `m.relates_to.rel_type=m.replace`
//   for forward-compat with a hypothetical future bridge that does.
//
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
//   `parseLegacyV076Body(body)` path.

// Discord remoteauth URLs encode the auth handshake in a path on
// `discordapp.com` (the OLD Discord domain — Discord still uses it as
// the canonical remoteauth host because the URL is consumed by the
// mobile app's deep-link handler, not by browser routing).
//
// Verified upstream: mautrix/discord/remoteauth/serverpackets.go at v0.7.6
// builds the QR string as `"https://discordapp.com/ra/" + Fingerprint` —
// see https://github.com/mautrix/discord/blob/v0.7.6/remoteauth/serverpackets.go.
//
// We accept both `discordapp.com` (canonical) AND `discord.com` because
// Discord has been gradually consolidating onto discord.com over years
// and a future bridge release could flip — keeping both means the
// widget survives the transition without a co-ordinated push.
// Subdomains (`canary.`, `ptb.`) aren't expected here (bridge talks to
// production remoteauth) but we tolerate them as belt-and-suspenders.
const DISCORD_REMOTEAUTH_URL_RE =
  /https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/[A-Za-z0-9/_\-+=.~?&]+/i;

const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null && !Array.isArray(value);

export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
  if (event.type === 'm.room.redaction') {
    const target =
      typeof event.redacts === 'string'
        ? event.redacts
        : isObject(event.content) && typeof event.content.redacts === 'string'
        ? event.content.redacts
        : undefined;
    if (!target) return { kind: 'unknown' };
    return { kind: 'qr_redacted', redactsEventId: target };
  }

  if (event.type !== 'm.room.message') return { kind: 'unknown' };

  const msgtype = event.content?.msgtype;

  if (msgtype === 'm.image') {
    // Edits replace `body` by spec; bridges typically also mirror the new
    // body into `m.new_content.body`. Discord's bridge doesn't edit QRs in
    // the v0.7.6 timeline, but we read both spots so a future change
    // doesn't quietly break the parser.
    const newContent = isObject(event.content['m.new_content'])
      ? (event.content['m.new_content'] as { body?: unknown })
      : undefined;
    const editedBody = typeof newContent?.body === 'string' ? newContent.body : undefined;
    const directBody = typeof event.content.body === 'string' ? event.content.body : '';
    const body = editedBody ?? directBody;

    const match = body.match(DISCORD_REMOTEAUTH_URL_RE);
    if (!match) return { kind: 'unknown' };

    const relatesTo = isObject(event.content['m.relates_to'])
      ? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
      : undefined;
    const replacesEventId =
      relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
        ? relatesTo.event_id
        : undefined;

    return {
      kind: 'qr_displayed',
      discordUrl: match[0],
      eventId: event.event_id,
      replacesEventId,
    };
  }

  if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };

  const body = typeof event.content.body === 'string' ? event.content.body : '';
  return parseLegacyV076Body(body);
};

// --- DEV sanity assertions ------------------------------------------------
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
// is replaced with the literal `false` and the call site collapses, so the
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
// first regression on reload.

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

function runSanityChecks(): void {
  // Body-only cases (`parseLegacyV076Body`).
  const cases: Array<[string, LoginEvent]> = [
    // Ping replies.
    ["You're not logged in", { kind: 'not_logged_in' }],
    ["You're not logged in.", { kind: 'not_logged_in' }],
    [
      'You have a Discord token stored, but are not connected for some reason 🤔',
      { kind: 'token_stored_not_connected' },
    ],
    [
      "You're logged in, but the Discord connection seems to be dead 💥",
      { kind: 'connection_dead' },
    ],
    [
      "You're logged in as @example (`123456789`)",
      { kind: 'logged_in', handle: 'example', discordId: '123456789' },
    ],
    // Discord usernames support `.` since the 2026 username migration.
    [
      "You're logged in as @user.name (`987654321`)",
      { kind: 'logged_in', handle: 'user.name', discordId: '987654321' },
    ],

    // Login success (post-QR scan). No snowflake in this line; App fires
    // `ping` afterwards to pick up the discordId.
    ['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
    ['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],

    // Login failure paths.
    ['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
    // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
    // unpatched upstream v0.7.6.
    [
      'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
      { kind: 'captcha_required' },
    ],
    // Vojo-patched bridge — structured hCaptcha challenge. Sentinel prefix
    // is checked before any regex so the JSON body is never misclassified.
    // Use a realistic-shape rqtoken (JWT-style segments separated by `.`,
    // base64url payload with `_`/`-`/`=`) so a regression where the regex
    // accidentally trips on those characters is caught in CI.
    [
      'VOJO-CAPTCHA-CHALLENGE-V1 {"service":"hcaptcha","sitekey":"a9b5fb07-92ff-493f-86fe-352a2803b3df","session_id":"e971514e-4a6e-4a45-a869-01e61421327c","rqdata":"fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax","rqtoken":"IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A"}',
      {
        kind: 'captcha_challenge',
        service: 'hcaptcha',
        sitekey: 'a9b5fb07-92ff-493f-86fe-352a2803b3df',
        sessionId: 'e971514e-4a6e-4a45-a869-01e61421327c',
        rqdata:
          'fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax',
        rqtoken:
          'IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A',
      },
    ],
    // Malformed challenge JSON falls through to `unknown` so a corrupted
    // bridge build doesn't crash the parser.
    ['VOJO-CAPTCHA-CHALLENGE-V1 {not-json', { kind: 'unknown' }],
    [
      'Error connecting to login websocket: dial tcp i/o timeout',
      { kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
    ],
    [
      'Error connecting after login: gateway timeout',
      { kind: 'connect_after_login_failed', reason: 'gateway timeout' },
    ],
    [
      'Failed to prepare login: remoteauth init failed',
      { kind: 'prepare_login_failed', reason: 'remoteauth init failed' },
    ],
    ["You're already logged in", { kind: 'already_logged_in' }],

    // Logout.
    ['Logged out successfully.', { kind: 'logout_ok' }],
    ["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],

    // Disconnect / reconnect.
    ['Successfully disconnected', { kind: 'disconnect_ok' }],
    ["You're already not connected", { kind: 'disconnect_no_op' }],
    [
      'Error while disconnecting: connection already closed',
      { kind: 'disconnect_failed', reason: 'connection already closed' },
    ],
    ['Successfully reconnected', { kind: 'reconnect_ok' }],
    ["You're already connected", { kind: 'reconnect_no_op' }],
    [
      'Error while reconnecting: dial tcp connection refused',
      { kind: 'reconnect_failed', reason: 'dial tcp connection refused' },
    ],

    // Unknown command — the bridge framework's wording.
    ['Unknown command. Try `help`.', { kind: 'unknown_command' }],

    // Catch-all.
    ['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }],
  ];

  for (const [body, expected] of cases) {
    const actual = parseLegacyV076Body(body);
    if (!sameEvent(actual, expected)) {
      // eslint-disable-next-line no-console
      console.error('[legacy_v076 sanity] mismatch', { body, actual, expected });
      throw new Error(
        `legacy_v076 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
      );
    }
  }

  // Full-event cases — m.image / m.room.redaction / m.notice fall-through.
  const eventCases: Array<[ParsableEvent, LoginEvent]> = [
    [
      // Canonical upstream form — `discordapp.com` (verified at v0.7.6
      // serverpackets.go). The legacy domain is what the Discord mobile
      // app's deep-link handler accepts.
      {
        type: 'm.room.message',
        event_id: '$qr1',
        sender: '@discordbot:vojo.chat',
        content: { msgtype: 'm.image', body: 'https://discordapp.com/ra/ABCDEF' },
      },
      { kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/ABCDEF', eventId: '$qr1' },
    ],
    [
      // Forward-compat: a future bridge release could flip to
      // `discord.com`. The regex tolerates both.
      {
        type: 'm.room.message',
        event_id: '$qr1b',
        sender: '@discordbot:vojo.chat',
        content: { msgtype: 'm.image', body: 'https://discord.com/ra/ABCDEF' },
      },
      { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/ABCDEF', eventId: '$qr1b' },
    ],
    [
      // Bare m.image without a discord URL — bridge has no business sending
      // these here, but the parser declines to invent state.
      {
        type: 'm.room.message',
        event_id: '$rand',
        sender: '@discordbot:vojo.chat',
        content: { msgtype: 'm.image', body: 'random non-discord image caption' },
      },
      { kind: 'unknown' },
    ],
    [
      // Forward-compat: hypothetical future edit. Verifies the rotation
      // path works even though Discord doesn't currently rotate.
      {
        type: 'm.room.message',
        event_id: '$qr2',
        sender: '@discordbot:vojo.chat',
        content: {
          msgtype: 'm.image',
          body: 'https://discordapp.com/ra/OLD',
          'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
          'm.new_content': { msgtype: 'm.image', body: 'https://discordapp.com/ra/ROTATED' },
        },
      },
      {
        kind: 'qr_displayed',
        discordUrl: 'https://discordapp.com/ra/ROTATED',
        eventId: '$qr2',
        replacesEventId: '$qr1',
      },
    ],
    [
      // Redaction — top-level `redacts` (host sanitizer mirrors there).
      {
        type: 'm.room.redaction',
        event_id: '$red1',
        sender: '@discordbot:vojo.chat',
        content: { redacts: '$qr1' },
        redacts: '$qr1',
      },
      { kind: 'qr_redacted', redactsEventId: '$qr1' },
    ],
    [
      // Redaction missing target — sanitizer should already reject; defence
      // in depth.
      {
        type: 'm.room.redaction',
        event_id: '$red2',
        sender: '@discordbot:vojo.chat',
        content: {},
      },
      { kind: 'unknown' },
    ],
    [
      // m.notice fall-through — preserves the body-side parser path.
      {
        type: 'm.room.message',
        event_id: '$n1',
        sender: '@discordbot:vojo.chat',
        content: { msgtype: 'm.notice', body: "You're not logged in" },
      },
      { kind: 'not_logged_in' },
    ],
  ];

  for (const [event, expected] of eventCases) {
    const actual = parseEventLegacyV076(event);
    if (!sameEvent(actual, expected)) {
      // eslint-disable-next-line no-console
      console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
      throw new Error(
        `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
          event.content?.msgtype ?? '<none>'
        }`
      );
    }
  }
}

function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
  if (a.kind !== b.kind) return false;
  return JSON.stringify(a) === JSON.stringify(b);
}
