// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cliSpecFor } from '@struktoai/mirage-core/commands/cli/specs'
import type { CLIDoors } from '@struktoai/mirage-core/commands/cli/types'
import { materialize } from '@struktoai/mirage-core/io/types'
import type { IOResult } from '@struktoai/mirage-core/io/types'
import type { PathSpec } from '@struktoai/mirage-core/types'
import { enoent } from '@struktoai/mirage-core/utils/errors'
import { EmailAccessor } from '../../../../accessor/email.ts'
import type { EmailConfig } from '../../../../core/email/config.ts'
import { messageJsonBytes } from '../../../../core/email/render.ts'
import { Workspace } from '../../../../workspace.ts'
import {
  build,
  composeBody,
  hasPrefix,
  mixedBoundary,
  quoteText,
  splitAddresses,
  type Attachment,
} from './builder.ts'
import { forward } from './forward.ts'
import { HIMALAYA } from './index.ts'
import { listEnvelopes } from './list.ts'
import { pageSlice, parseQuery, QueryError, sortHeaders, uidBudget } from './query.ts'
import { read } from './read.ts'
import { reply } from './reply.ts'
import { searchEnvelopes } from './search.ts'
import { send } from './send.ts'

const sendRawMock = vi.hoisted(() => vi.fn())
const listFolderEntriesMock = vi.hoisted(() => vi.fn())
const appendMock = vi.hoisted(() => vi.fn())

vi.mock('./smtp.ts', () => ({ sendRaw: sendRawMock }))

vi.mock('../../../../core/email/client.ts', () => ({
  listFolderEntries: listFolderEntriesMock,
  fetchRawMessage: vi.fn(() => Promise.resolve(new TextEncoder().encode('From: a@x\r\n\r\nbody'))),
  fetchMessage: vi.fn(() => Promise.resolve(ORIGINAL)),
  listMessageUids: vi.fn(() => Promise.resolve(['1', '2'])),
  fetchHeaders: vi.fn((_accessor: unknown, _folder: string, uids: string[]) =>
    Promise.resolve(uids.map((uid) => HEADERS[uid])),
  ),
}))

const ORIGINAL = {
  subject: 'Quarterly numbers',
  from: { name: 'Alice', email: 'alice@example.com' },
  reply_to: [],
  to: [{ name: '', email: 'me@example.com' }],
  cc: [{ name: '', email: 'bob@example.com' }],
  date: 'Mon, 02 Feb 2026 10:00:00 +0000',
  body_text: 'the numbers',
  body_html: '',
  snippet: '',
  message_id: '<m1@example.com>',
  in_reply_to: null,
  references: ['<m0@example.com>'],
  has_attachments: false,
  attachments: [],
  uid: '7',
  flags: [],
  internalDate: '2026-02-02T10:00:00.000Z',
}

const HEADERS: Record<string, unknown> = {
  '1': { ...ORIGINAL, uid: '1', subject: 'beta', date: 'Mon, 02 Feb 2026 10:00:00 +0000' },
  '2': { ...ORIGINAL, uid: '2', subject: 'alpha', date: 'Tue, 03 Feb 2026 10:00:00 +0000' },
}

const CONFIG: EmailConfig = {
  imapHost: 'h',
  imapPort: 993,
  smtpHost: 'h',
  smtpPort: 587,
  username: 'me@example.com',
  password: 'p',
  useSsl: false,
  maxMessages: 200,
  saveCopy: true,
  sentFolder: null,
}

// Every --send path now also files the sender's copy over IMAP, so the
// account's IMAP side is stubbed for the whole file: without this the
// verbs would reach for a real connection to host 'h'.
beforeEach(() => {
  listFolderEntriesMock.mockResolvedValue([
    { name: 'INBOX', specialUse: null },
    { name: 'Sent', specialUse: '\\Sent' },
  ])
  appendMock.mockReset()
  appendMock.mockResolvedValue({ destination: 'Sent' })
  vi.spyOn(EmailAccessor.prototype, 'getImap').mockResolvedValue({
    append: appendMock,
  } as unknown as Awaited<ReturnType<EmailAccessor['getImap']>>)
  vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
})

function leaf(...path: string[]) {
  let node = HIMALAYA
  for (const name of path) {
    const child = node.subcommands.find((c) => c.name === name)
    if (child === undefined) throw new Error(`no subcommand ${name}`)
    node = child
  }
  return node
}

function decode(out: Uint8Array): string {
  return new TextDecoder().decode(out)
}

describe('himalaya tree', () => {
  it('matches the himalaya vocabulary', () => {
    expect(HIMALAYA.name).toBe('himalaya')
    expect(HIMALAYA.subcommands.map((g) => g.name)).toEqual(['envelope', 'message'])
    expect(leaf('envelope').subcommands.map((v) => v.name)).toEqual(['list', 'search'])
    expect(leaf('message').subcommands.map((v) => v.name)).toEqual([
      'read',
      'compose',
      'send',
      'reply',
      'forward',
    ])
  })

  it('carries the upstream aliases', () => {
    expect(leaf('envelope', 'list').aliases).toEqual(['ls'])
    expect(leaf('envelope', 'search').aliases).toEqual(['sr'])
    expect(leaf('message', 'compose').aliases).toEqual(['write', 'new'])
    expect(leaf('message', 'forward').aliases).toEqual(['fwd'])
  })

  it('takes the message id as an operand and the mailbox as -m', () => {
    for (const verb of ['read', 'reply', 'forward']) {
      const node = leaf('message', verb)
      expect(node.rest).not.toBeNull()
      expect(node.options.find((o) => o.long === '--mailbox')?.short).toBe('-m')
    }
    expect(leaf('message', 'compose').rest).toBeNull()
  })

  it('classifies writes and requires no composer flag', () => {
    expect(leaf('envelope', 'list').write).toBe(false)
    expect(leaf('envelope', 'search').write).toBe(false)
    expect(leaf('message', 'read').write).toBe(false)
    for (const verb of ['compose', 'send', 'reply', 'forward']) {
      expect(leaf('message', verb).write).toBe(true)
    }
    expect(leaf('message', 'compose').options.every((o) => !o.required)).toBe(true)
  })

  it('registers itself for YAML resolution at import time', () => {
    expect(cliSpecFor('himalaya')).toBe(HIMALAYA)
  })

  it('applies config defaults through the zod model', () => {
    const model = HIMALAYA.configModel
    if (model === null || typeof model === 'function') throw new Error('expected zod model')
    const parsed = model.parse({ imapHost: 'h', smtpHost: 'h', username: 'u', password: 'p' })
    expect(parsed.imapPort).toBe(993)
    expect(parsed.smtpPort).toBe(587)
    expect(parsed.useSsl).toBe(true)
    expect(parsed.maxMessages).toBe(200)
  })
})

describe('search query DSL', () => {
  it('turns text conditions into quoted IMAP keys', () => {
    expect(parseQuery('from alice').criteria).toBe('FROM "alice"')
    expect(parseQuery('body refund').criteria).toBe('BODY "refund"')
  })

  it('makes and implicit, or prefix, and binds and tighter', () => {
    expect(parseQuery('from a and to b').criteria).toBe('(FROM "a" TO "b")')
    expect(parseQuery('from a or to b').criteria).toBe('OR FROM "a" TO "b"')
    expect(parseQuery('from a and to b or subject c').criteria).toBe(
      'OR (FROM "a" TO "b") SUBJECT "c"',
    )
  })

  it('regroups with parentheses and negates with not', () => {
    expect(parseQuery('from a and (to b or subject c)').criteria).toBe(
      '(FROM "a" OR TO "b" SUBJECT "c")',
    )
    expect(parseQuery('not flag seen').criteria).toBe('NOT SEEN')
  })

  it('asks for the next day since after is strictly greater', () => {
    expect(parseQuery('date 2026-02-03').criteria).toBe('SENTON 03-Feb-2026')
    expect(parseQuery('before 2026-02-03').criteria).toBe('SENTBEFORE 03-Feb-2026')
    expect(parseQuery('after 2026-01-01').criteria).toBe('SENTSINCE 02-Jan-2026')
  })

  it('searches the Date header rather than the received-at timestamp', () => {
    // ON/BEFORE/SINCE would match the mailbox internal date, which is
    // the wrong day for imported or delayed mail.
    for (const source of ['date 2026-02-03', 'before 2026-02-03', 'after 2026-02-03']) {
      expect(parseQuery(source).criteria.startsWith('SENT')).toBe(true)
    }
  })

  it('only fetches the pages asked for under the default order', () => {
    expect(uidBudget(1, 25, [], 200)).toBe(25)
    expect(uidBudget(3, 25, [], 200)).toBe(75)
    expect(uidBudget(0, 25, [], 200)).toBe(25)
  })

  it('caps deep paging and sorted searches at the account window', () => {
    expect(uidBudget(40, 25, [], 200)).toBe(200)
    expect(uidBudget(1, 25, [{ kind: 'subject', descending: false }], 200)).toBe(200)
  })

  it('keeps spaces in a quoted pattern and defuses its keywords', () => {
    expect(parseQuery('subject "and or not"').criteria).toBe('SUBJECT "and or not"')
  })

  it('parses sorters with asc as the default', () => {
    expect(parseQuery('order by subject from desc').sorters).toEqual([
      { kind: 'subject', descending: false },
      { kind: 'from', descending: true },
    ])
  })

  it('refuses what it cannot parse', () => {
    expect(() => parseQuery('sender alice')).toThrow(QueryError)
    expect(() => parseQuery('flag urgent')).toThrow(QueryError)
    expect(() => parseQuery('date 2026-13-40')).toThrow(QueryError)
    expect(() => parseQuery('order by')).toThrow(QueryError)
    expect(() => parseQuery('from alice bogus')).toThrow(QueryError)
    expect(() => parseQuery('subject "open')).toThrow(QueryError)
  })

  it('orders by date descending when no sorter is given', () => {
    const rows = sortHeaders([HEADERS['1'], HEADERS['2']] as Parameters<typeof sortHeaders>[0], [])
    expect(rows.map((r) => r.uid)).toEqual(['2', '1'])
  })

  it('pages counting from one', () => {
    expect(pageSlice([1, 2, 3, 4, 5], 3, 2)).toEqual([5])
    expect(pageSlice([1, 2, 3], 9, 2)).toEqual([])
  })
})

describe('message builder', () => {
  it('flattens repeats and comma lists', () => {
    expect(splitAddresses(['a@x, b@x', ' c@x '])).toEqual(['a@x', 'b@x', 'c@x'])
  })

  it('keeps the colon when testing for an existing prefix', () => {
    expect(hasPrefix('Re: hello', 'Re: ')).toBe(true)
    expect(hasPrefix('Ready to ship', 'Re: ')).toBe(false)
  })

  it('quotes each line once and leaves the headline unquoted', () => {
    expect(quoteText('a\nb', '')).toBe('> a\n> b')
    expect(quoteText('> a\nb', '')).toBe('>> a\n> b')
    expect(quoteText('a', 'Alice wrote:')).toBe('Alice wrote:\n> a')
  })

  it('top posts by default and bottom posts on request', () => {
    expect(composeBody('mine', '> theirs', '', 'top')).toBe('mine\n\n> theirs')
    expect(composeBody('mine', '> theirs', '', 'bottom')).toBe('> theirs\n\nmine')
    expect(composeBody('mine', '', 'sig', 'top')).toBe('mine\n\n-- \nsig')
  })

  it('refuses a message with no recipient', () => {
    expect(() =>
      build({
        sender: 'me@x',
        to: [],
        cc: [],
        bcc: [],
        subject: 'Hi',
        body: 'yo',
        signature: null,
      }),
    ).toThrow('no recipient')
  })

  it.each([
    'evil\nname.txt',
    'evil\rname.txt',
    'tail\n',
    'evil\vname.txt',
    'evil\fname.txt',
    'evil\x1cname.txt',
    'evil\x1dname.txt',
    'evil\x1ename.txt',
  ])('refuses the ASCII attachment filename %j like EmailMessage', (bad) => {
    // Header injection through the quoted-string form; python's
    // EmailMessage raises the same ValueError, trailing terminators
    // included (pinned in test_builder.py).
    const attachment: Attachment = {
      filename: bad,
      contentType: 'text/plain',
      data: new TextEncoder().encode('x'),
    }
    expect(() =>
      build({
        sender: 'a@example.com',
        to: ['b@example.com'],
        cc: [],
        bcc: [],
        subject: null,
        body: 'hi',
        signature: null,
        attachments: [attachment],
      }),
    ).toThrow('Header values may not contain linefeed or carriage return characters')
  })

  it('percent-encodes a line break in a non-ASCII filename', () => {
    // The RFC 2231 path never refuses; byte-exactness with python is
    // pinned by the attachment_nonascii_filename_with_line_break
    // parity case.
    const attachment: Attachment = {
      filename: 'naïve\nname.txt',
      contentType: 'text/plain',
      data: new TextEncoder().encode('x'),
    }
    const raw = build({
      sender: 'a@example.com',
      to: ['b@example.com'],
      cc: [],
      bcc: [],
      subject: null,
      body: 'hi',
      signature: null,
      attachments: [attachment],
    })
    expect(new TextDecoder().decode(raw)).toContain("filename*=utf-8''na%C3%AFve%0Aname.txt")
  })

  it('derives a deterministic content-addressed boundary', () => {
    const attachments: Attachment[] = [
      { filename: 'a.txt', contentType: 'text/plain', data: new TextEncoder().encode('data') },
    ]
    const first = mixedBoundary('body', attachments)
    expect(mixedBoundary('body', attachments)).toBe(first)
    expect(mixedBoundary('other body', attachments)).not.toBe(first)
    expect(first).toHaveLength(32)
  })

  it('promotes the message to multipart/mixed with attachments', () => {
    const attachments: Attachment[] = [
      {
        filename: 'note.txt',
        contentType: 'text/plain',
        data: new TextEncoder().encode('the note\n'),
      },
      {
        filename: 'blob.bin',
        contentType: 'application/octet-stream',
        data: new Uint8Array([0, 1]),
      },
    ]
    const boundary = mixedBoundary('see attached', attachments)
    const text = decode(
      build({
        sender: 'me@x',
        to: ['a@x'],
        cc: [],
        bcc: [],
        subject: 'files',
        body: 'see attached',
        signature: null,
        attachments,
      }),
    )
    expect(text).toContain('MIME-Version: 1.0\r\nContent-Type: multipart/mixed; ')
    expect(text).toContain(`boundary="${boundary}"`)
    expect(text).toContain('Content-Disposition: attachment; filename="note.txt"')
    expect(text).toContain(Buffer.from('the note\n').toString('base64'))
    expect(text.endsWith(`\r\n--${boundary}--\r\n`)).toBe(true)
  })

  it('serializes an empty attachment as zero payload lines', () => {
    const attachments: Attachment[] = [
      { filename: 'void.bin', contentType: 'application/octet-stream', data: new Uint8Array(0) },
    ]
    const text = decode(
      build({
        sender: 'me@x',
        to: ['a@x'],
        cc: [],
        bcc: [],
        subject: 'empty',
        body: 'nothing inside',
        signature: null,
        attachments,
      }),
    )
    expect(text).toContain('MIME-Version: 1.0\r\n\r\n\r\n--')
  })
})

describe('himalaya verbs', () => {
  it('compose writes MIME to stdout without sending', async () => {
    sendRawMock.mockClear()
    const [out, io] = (await import('./compose.ts').then((m) =>
      m.compose({
        config: CONFIG,
        argv: [],
        paths: [],
        texts: [],
        flags: { to: 'a@b.com', subject: 'Hi', body: 'yo' },
        stdin: null,
        env: {},
      }),
    )) as [Uint8Array, IOResult]
    expect(io.exitCode).toBe(0)
    expect(sendRawMock).not.toHaveBeenCalled()
    const text = decode(await materialize(out))
    expect(text).toContain('From: me@example.com')
    expect(text).toContain('To: a@b.com')
    expect(text).toContain('Subject: Hi')
    expect(text.endsWith('yo\r\n')).toBe(true)
  })

  it('compose --send pushes the raw bytes through SMTP', async () => {
    sendRawMock.mockClear()
    sendRawMock.mockResolvedValue({ to: [{ name: '', email: 'a@b.com' }], subject: 'Hi' })
    const [out] = (await import('./compose.ts').then((m) =>
      m.compose({
        config: CONFIG,
        argv: [],
        paths: [],
        texts: [],
        flags: { to: 'a@b.com', subject: 'Hi', body: 'yo', send: true },
        stdin: null,
        env: {},
      }),
    )) as [Uint8Array, IOResult]
    expect(decode(sendRawMock.mock.calls[0]?.[1] as Uint8Array)).toContain('Subject: Hi')
    expect(JSON.parse(decode(await materialize(out)))).toEqual({
      status: 'sent',
      to: 'a@b.com',
      subject: 'Hi',
    })
  })

  it('compose --attach reads through the dispatcher into multipart', async () => {
    sendRawMock.mockClear()
    const files: Record<string, Uint8Array> = {
      '/scratch/note.txt': new TextEncoder().encode('the note body\n'),
    }
    const doors: CLIDoors = {
      dispatch: (op: string, path: PathSpec) => {
        const data = files[path.virtual]
        if (op !== 'read' || data === undefined) return Promise.reject(enoent(path))
        return Promise.resolve([data, { exitCode: 0 }]) as never
      },
    }
    const [out, io] = (await import('./compose.ts').then((m) =>
      m.compose({
        config: CONFIG,
        argv: [],
        paths: [],
        texts: [],
        flags: {
          to: 'a@b.com',
          subject: 'files',
          body: 'see attached',
          attach: ['/scratch/note.txt'],
        },
        stdin: null,
        env: {},
        doors,
      }),
    )) as [Uint8Array, IOResult]
    expect(io.exitCode).toBe(0)
    const text = decode(await materialize(out))
    expect(text).toContain('Content-Type: multipart/mixed; boundary="')
    expect(text).toContain('Content-Disposition: attachment; filename="note.txt"')
    expect(text).toContain(Buffer.from('the note body\n').toString('base64'))
  })

  it('compose --attach of a missing file names the path', async () => {
    const doors: CLIDoors = {
      dispatch: (_op: string, path: PathSpec) => Promise.reject(enoent(path)),
    }
    await expect(
      import('./compose.ts').then((m) =>
        m.compose({
          config: CONFIG,
          argv: [],
          paths: [],
          texts: [],
          flags: { to: 'a@b.com', body: 'yo', attach: ['/scratch/gone.txt'] },
          stdin: null,
          env: {},
          doors,
        }),
      ),
    ).rejects.toThrow('read attachment /scratch/gone.txt: No such file or directory')
  })

  it('compose --attach outside a workspace is refused', async () => {
    await expect(
      import('./compose.ts').then((m) =>
        m.compose({
          config: CONFIG,
          argv: [],
          paths: [],
          texts: [],
          flags: { to: 'a@b.com', body: 'yo', attach: ['/scratch/note.txt'] },
          stdin: null,
          env: {},
        }),
      ),
    ).rejects.toThrow('--attach needs a workspace to read files from')
  })

  it('reply derives subject, recipients and threading', async () => {
    const closeSpy = vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
    const [out] = (await reply({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: ['7'],
      flags: { body: 'thanks' },
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    const text = decode(await materialize(out))
    expect(text).toContain('Subject: Re: Quarterly numbers')
    expect(text).toContain('To: Alice <alice@example.com>')
    expect(text).toContain('In-Reply-To: <m1@example.com>')
    expect(text).toContain('References: <m0@example.com> <m1@example.com>')
    expect(text).toContain('thanks\r\n\r\n> the numbers')
    closeSpy.mockRestore()
  })

  it('forward prefixes Fwd and keeps References but not In-Reply-To', async () => {
    const closeSpy = vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
    const [out] = (await forward({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: ['7'],
      flags: { to: 'carol@example.com' },
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    const text = decode(await materialize(out))
    expect(text).toContain('Subject: Fwd: Quarterly numbers')
    expect(text).not.toContain('In-Reply-To')
    expect(text).toContain('References: <m0@example.com> <m1@example.com>')
    closeSpy.mockRestore()
  })

  // `message read` and `cat` of the .email.json are the same document, so
  // they share one renderer and INTERNALDATE reaches neither.
  it('read serves exactly what the mount renders', async () => {
    const closeSpy = vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
    const [out] = (await read({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: ['7'],
      flags: {},
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    const bytes = await materialize(out)
    expect(bytes).toEqual(messageJsonBytes(ORIGINAL))
    expect(decode(bytes)).not.toContain('internalDate')
    closeSpy.mockRestore()
  })

  it('reply without an id is a usage error', async () => {
    await expect(
      reply({ config: CONFIG, argv: [], paths: [], texts: [], flags: {}, stdin: null, env: {} }),
    ).rejects.toThrow('message id is required')
  })

  it('send reads a raw message from stdin', async () => {
    sendRawMock.mockClear()
    sendRawMock.mockResolvedValue({ to: [{ name: '', email: 'a@b.com' }], subject: 'Hi' })
    const raw = new TextEncoder().encode('From: me@x\nTo: a@b.com\nSubject: Hi\n\nyo')
    const [out] = (await send({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: {},
      stdin: raw,
      env: {},
    })) as [Uint8Array, IOResult]
    expect(sendRawMock.mock.calls[0]?.[1]).toEqual(raw)
    expect(JSON.parse(decode(await materialize(out)))).toEqual({
      status: 'sent',
      to: 'a@b.com',
      subject: 'Hi',
    })
  })

  it('send files the sender copy in the mailbox the server tags \\Sent', async () => {
    sendRawMock.mockClear()
    sendRawMock.mockResolvedValue({ to: [{ name: '', email: 'a@b.com' }], subject: 'Hi' })
    const raw = new TextEncoder().encode('From: me@x\nTo: a@b.com\nSubject: Hi\n\nyo')
    const [, io] = (await send({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: {},
      stdin: raw,
      env: {},
    })) as [Uint8Array, IOResult]
    expect(appendMock).toHaveBeenCalledWith('Sent', Buffer.from(raw), ['\\Seen'])
    expect(io.stderr).toBeNull()
  })

  it('send warns on stderr and still succeeds when the copy fails', async () => {
    sendRawMock.mockClear()
    sendRawMock.mockResolvedValue({ to: [{ name: '', email: 'a@b.com' }], subject: 'Hi' })
    appendMock.mockRejectedValue(new Error('Sent: no such mailbox'))
    const raw = new TextEncoder().encode('From: me@x\nTo: a@b.com\nSubject: Hi\n\nyo')
    const [out, io] = (await send({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: {},
      stdin: raw,
      env: {},
    })) as [Uint8Array, IOResult]
    // The message is already delivered, so the copy's failure is
    // reported rather than raised.
    expect(io.exitCode).toBe(0)
    expect((JSON.parse(decode(await materialize(out))) as { status: string }).status).toBe('sent')
    expect(decode(await materialize(io.stderr))).toBe(
      'himalaya: sent copy not saved: Sent: no such mailbox\n',
    )
  })

  it('compose --send files a copy too, and saveCopy off files none', async () => {
    sendRawMock.mockClear()
    sendRawMock.mockResolvedValue({ to: [{ name: '', email: 'a@b.com' }], subject: 'Hi' })
    const compose = await import('./compose.ts').then((m) => m.compose)
    const invocation = {
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: { to: 'a@b.com', subject: 'Hi', body: 'yo', send: true },
      stdin: null,
      env: {},
    }
    await compose(invocation)
    expect(appendMock).toHaveBeenCalledTimes(1)
    await compose({ ...invocation, config: { ...CONFIG, saveCopy: false } })
    expect(appendMock).toHaveBeenCalledTimes(1)
  })

  it('compose --save without --send files the message and sends nothing', async () => {
    sendRawMock.mockClear()
    const compose = await import('./compose.ts').then((m) => m.compose)
    const [out, io] = (await compose({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: { to: 'a@b.com', subject: 'Draft it', body: 'yo', save: 'Drafts' },
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    expect(sendRawMock).not.toHaveBeenCalled()
    expect(appendMock).toHaveBeenCalledWith('Drafts', expect.anything(), ['\\Seen'])
    expect(JSON.parse(decode(await materialize(out)))).toEqual({
      status: 'saved',
      mailbox: 'Drafts',
      to: 'a@b.com',
      subject: 'Draft it',
    })
    expect(io.exitCode).toBe(0)
  })

  it('a refused --save without --send fails loudly', async () => {
    // Nothing was sent, so there is no delivered message a retry could
    // duplicate: this one is an error, not a warning.
    appendMock.mockRejectedValue(new Error('Drafts: no such mailbox'))
    const compose = await import('./compose.ts').then((m) => m.compose)
    await expect(
      compose({
        config: CONFIG,
        argv: [],
        paths: [],
        texts: [],
        flags: { to: 'a@b.com', body: 'yo', save: 'Drafts' },
        stdin: null,
        env: {},
      }),
    ).rejects.toThrow('Drafts: no such mailbox')
  })

  it('send refuses an empty message before reaching SMTP', async () => {
    sendRawMock.mockClear()
    await expect(
      send({
        config: CONFIG,
        argv: [],
        paths: [],
        texts: [],
        flags: {},
        stdin: new TextEncoder().encode('  \n '),
        env: {},
      }),
    ).rejects.toThrow('no message provided')
    expect(sendRawMock).not.toHaveBeenCalled()
  })

  it('list orders most recent first and closes its accessor', async () => {
    const closeSpy = vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
    const [out] = (await listEnvelopes({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: [],
      flags: {},
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    const rows = JSON.parse(decode(await materialize(out))) as Record<string, unknown>[]
    expect(rows.map((r) => r.uid)).toEqual(['2', '1'])
    // INTERNALDATE only picks the date directory; it is not an envelope
    // field, and every envelope renders through the mount's renderer.
    expect(rows.every((r) => !('internalDate' in r))).toBe(true)
    expect(closeSpy).toHaveBeenCalledTimes(1)
    closeSpy.mockRestore()
  })

  it('search sorts client side from the query clause', async () => {
    const closeSpy = vi.spyOn(EmailAccessor.prototype, 'close').mockResolvedValue()
    const [out] = (await searchEnvelopes({
      config: CONFIG,
      argv: [],
      paths: [],
      texts: ['order', 'by', 'subject'],
      flags: {},
      stdin: null,
      env: {},
    })) as [Uint8Array, IOResult]
    const rows = JSON.parse(decode(await materialize(out))) as { uid: string }[]
    expect(rows.map((r) => r.uid)).toEqual(['2', '1'])
    closeSpy.mockRestore()
  })
})

describe('himalaya dispatch', () => {
  it('runs an installed tree end to end', async () => {
    const ws = new Workspace({})
    ws.registerCli('himalaya', HIMALAYA, {
      imapHost: 'h',
      smtpHost: 'h',
      username: 'me@example.com',
      password: 'p',
    })
    const io = await ws.execute('himalaya message compose --to a@b.com --subject Hi --body yo')
    expect(io.exitCode).toBe(0)
    expect(new TextDecoder().decode(io.stdout)).toContain('To: a@b.com')
    await ws.close()
  })

  // The email resource normalizes snake_case; the CLI install used to
  // validate the raw keys against the camelCase schema and reject the
  // very same config block ("unknown config keys: imap_host, ...").
  it('installs from the same snake_case config block the Python side uses', async () => {
    const ws = new Workspace({})
    ws.registerCli('himalaya', HIMALAYA, {
      imap_host: 'h',
      imap_port: 993,
      smtp_host: 'h',
      smtp_port: 587,
      username: 'me@example.com',
      password: 'p',
      use_ssl: true,
    })
    const io = await ws.execute('himalaya message compose --to a@b.com --subject Hi --body yo')
    expect(io.exitCode).toBe(0)
    expect(new TextDecoder().decode(io.stdout)).toContain('To: a@b.com')
    await ws.close()
  })

  it('reports an upstream verb mirage lacks with git wording', async () => {
    const ws = new Workspace({})
    ws.registerCli('himalaya', HIMALAYA, {
      imapHost: 'h',
      smtpHost: 'h',
      username: 'u',
      password: 'p',
    })
    const io = await ws.execute('himalaya message move 7 --to Archive')
    expect(io.exitCode).toBe(1)
    expect(new TextDecoder().decode(io.stderr)).toBe(
      "himalaya: 'move' is not a himalaya message command. See 'himalaya message --help'.\n",
    )
    await ws.close()
  })
})
