/**
 * Blueprint composition proof — community (preset: registry.json presets.community).
 *
 * This is NOT a re-test of each module's surface (search.test.ts, uploads-authz.test.ts, audit.test.ts
 * already do that). It proves the ONE thing a blueprint exists to prove: that the preset's modules
 * COMPOSE into a real flow and the SEAMS BETWEEN THEM hold. The flow: a member publishes a post with
 * an image attachment —
 *
 *   auth (may this caller post?) → uploads (is this attachment really an image?) → gate
 *     → search-index + audit-log + notifications fan-out (email channel composes mail) + realtime envelope
 *
 * The security property under test is FAIL-CLOSED composition across TWO boundaries (one more than
 * saas-admin's single gate): the auth capability gate AND the upload trust boundary. A caller without
 * the publish capability — or a forged attachment (a script-bearing SVG posing as an image) — produces
 * ZERO downstream side effects: nothing indexed, audited, or announced. realtime is type-only (its
 * `/server` delivery is host-owned), so the flow builds the typed envelope but does not deliver it.
 */
import { beforeEach, describe, expect, it } from 'vitest'
import {
  PermissionDeniedError,
  getSession,
  requirePermission,
  type AuthEngine,
  type Principal,
} from '@platform-modules/auth'
import { listAudit, logAudit } from '@platform-modules/audit'
import { bearer, createFakeAuthEngine } from '../src/blueprints/community/wiring/auth'
import {
  RejectedAttachmentError,
  validateAttachment,
  type Attachment,
} from '../src/blueprints/community/wiring/uploads'
import { createPostIndex, type PostIndex } from '../src/blueprints/community/wiring/search'
import { createCaptureMail, type CaptureMail } from '../src/blueprints/community/wiring/mail'
import {
  createFollowerNotifications,
  type FollowerNotifications,
} from '../src/blueprints/community/wiring/notifications'
import { createAuditDb, type AuditDb } from '../src/blueprints/community/wiring/audit'
import { postPublishedEvent, type PostPublishedEvent } from '../src/blueprints/community/wiring/realtime'

const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024

type Follower = { userId: string; email: string }

type PublishDeps = {
  authEngine: AuthEngine
  index: PostIndex
  mail: CaptureMail
  notifications: FollowerNotifications
  auditDb: AuditDb
  followers: Follower[]
}

type PublishInput = {
  callerHeaders: Headers
  postId: string
  title: string
  body: string
  attachment: Attachment
  // host-supplied envelope identity (kept out of the realtime seam — see wiring/realtime.ts)
  eventId: string
  timestamp: string
}

/**
 * The host-owned glue a real app writes (the blueprint NAMES it; here it lives in the test as the
 * composition under proof). The ORDER is the property: BOTH gates (auth, then upload) run before any
 * side effect — that ordering is the fail-closed behavior the negative cases assert.
 */
async function runMemberPublish(
  deps: PublishDeps,
  input: PublishInput,
): Promise<{ postId: string; mime: string; realtime: PostPublishedEvent }> {
  // 1. AUTH GATE — resolve the caller, require the publish capability (throws if absent).
  const principal: Principal | null = await getSession(input.callerHeaders, deps.authEngine)
  const authed = requirePermission('post:create')(principal)

  // 2. UPLOAD TRUST BOUNDARY — sniff the real content-type + enforce size (never the client's claim).
  const mime = validateAttachment(input.attachment, MAX_ATTACHMENT_BYTES)

  // ---- past BOTH gates only ----
  // 3. SEARCH — the post becomes findable through the same searchEntities the host federates over.
  deps.index.add({ id: input.postId, title: input.title, body: input.body })

  // 4. AUDIT — record the published fact (decoupled post-commit form; logAudit never throws).
  await logAudit(deps.auditDb, {
    actorId: authed.userId,
    actorType: 'user',
    action: 'post.publish',
    entityType: 'post',
    entityId: input.postId,
    metadata: { mime, title: input.title },
  })

  // 5. NOTIFICATIONS — fan out to followers (per-recipient; 'email' channel composes mail).
  for (const follower of deps.followers) {
    await deps.notifications.fanOut({
      id: `${input.postId}:${follower.userId}`,
      type: 'post.published',
      userId: follower.userId,
      template: { subject: 'New post', body: '{{author}} posted: {{title}}' },
      data: { author: authed.userId, title: input.title },
      recipients: { inapp: follower.userId, email: follower.email },
    })
  }

  // 6. REALTIME — build the typed envelope the host publishes to live connections (delivery host-owned).
  const realtime = postPublishedEvent({
    id: input.eventId,
    scope: `feed:${authed.userId}`,
    timestamp: input.timestamp,
    payload: { postId: input.postId, authorId: authed.userId, title: input.title },
  })

  return { postId: input.postId, mime, realtime }
}

/** PNG 8-byte signature + a little filler — enough for detectMimeFromMagicBytes to return image/png. */
function pngBytes(): Uint8Array {
  return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d])
}

/** A script-bearing SVG masquerading as an image — magic-bytes rejects dangerous text headers. */
function svgScriptBytes(): Uint8Array {
  return new TextEncoder().encode(
    '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
  )
}

describe('blueprint: community — member publish composes auth → uploads → search/audit/notifications (+ realtime envelope)', () => {
  let deps: PublishDeps
  let capture: CaptureMail
  let index: PostIndex

  beforeEach(async () => {
    // Community is single-scope: the Principal carries its capabilities directly (no tenancy layer).
    const memberPrincipal: Principal = {
      userId: 'u-member',
      sessionId: 's-member',
      roles: ['member'],
      capabilities: ['post:create', 'post:read'],
    }
    const readerPrincipal: Principal = {
      userId: 'u-reader',
      sessionId: 's-reader',
      roles: ['reader'],
      capabilities: ['post:read'],
    }
    capture = createCaptureMail()
    index = createPostIndex()
    deps = {
      authEngine: createFakeAuthEngine(
        new Map([
          ['tok-member', memberPrincipal],
          ['tok-reader', readerPrincipal],
        ]),
      ),
      index,
      mail: capture,
      notifications: createFollowerNotifications(capture.mail),
      auditDb: await createAuditDb(),
      followers: [
        { userId: 'f1', email: 'f1@community.test' },
        { userId: 'f2', email: 'f2@community.test' },
      ],
    }
  })

  function validInput(overrides?: Partial<PublishInput>): PublishInput {
    return {
      callerHeaders: bearer('tok-member'),
      postId: 'p-1',
      title: 'Launch announcement',
      body: 'We just shipped the community blueprint.',
      attachment: { bytes: pngBytes() },
      eventId: 'evt-1',
      timestamp: '2026-06-16T00:00:00.000Z',
      ...overrides,
    }
  }

  it('member with post:create + a valid image: published → indexed, audited, fanned out (inapp+email), realtime envelope built', async () => {
    const res = await runMemberPublish(deps, validInput())
    expect(res.mime).toBe('image/png')
    expect(res.realtime.type).toBe('post.published')
    expect(res.realtime.payload.postId).toBe('p-1')

    // SEARCH — findable through searchEntities (single 'post' provider here; a host federates more types)
    const found = await index.search('launch')
    expect(found.groups).toHaveLength(1)
    expect(found.groups[0]?.hits[0]?.id).toBe('p-1')

    // AUDIT — exactly one post.publish row, attributed to the actor
    const page = await listAudit(deps.auditDb, { entityType: 'post', action: 'post.publish' })
    expect(page.items).toHaveLength(1)
    expect(page.items[0]?.actorId).toBe('u-member')
    expect(page.items[0]?.entityId).toBe('p-1')

    // NOTIFICATIONS — one inapp + one email PER follower; the email channel actually drove mail
    expect(deps.notifications.inApp).toHaveLength(2)
    expect(capture.sent).toHaveLength(2)
    expect(capture.sent[0]?.subject).toBe('New post')
    expect(capture.sent[0]?.text).toContain('Launch announcement')
  })

  it('forged attachment (script-bearing SVG posing as an image): rejected at the upload trust boundary → ZERO downstream side effects', async () => {
    await expect(
      runMemberPublish(deps, validInput({ attachment: { bytes: svgScriptBytes() } })),
    ).rejects.toBeInstanceOf(RejectedAttachmentError)

    // fail-closed — the post was never indexed, audited, or announced (gate ran before any side effect)
    const found = await index.search('launch')
    expect(found.groups).toHaveLength(0)
    const page = await listAudit(deps.auditDb, { entityType: 'post', action: 'post.publish' })
    expect(page.items).toHaveLength(0)
    expect(deps.notifications.inApp).toHaveLength(0)
    expect(capture.sent).toHaveLength(0)
  })

  it('reader without post:create: rejected at the auth gate (before upload validation) → zero side effects', async () => {
    await expect(
      // a valid image, but the caller lacks the capability — auth gate runs FIRST and short-circuits
      runMemberPublish(deps, validInput({ callerHeaders: bearer('tok-reader') })),
    ).rejects.toBeInstanceOf(PermissionDeniedError)

    const found = await index.search('launch')
    expect(found.groups).toHaveLength(0)
    const page = await listAudit(deps.auditDb, { entityType: 'post', action: 'post.publish' })
    expect(page.items).toHaveLength(0)
    expect(deps.notifications.inApp).toHaveLength(0)
    expect(capture.sent).toHaveLength(0)
  })
})
