/**
 * Blueprint composition proof — cms (preset: registry.json presets.cms / plugins.cms).
 *
 * This is NOT a re-test of each module's surface (content.test.ts, seo.test.ts already do that).
 * It proves the ONE thing a blueprint exists to prove: that the preset's modules COMPOSE into a real
 * editorial flow and the SEAMS BETWEEN THEM hold. The flow: an editor creates and publishes a post —
 *
 *   auth (who is calling?) → content object-level authz (may they publish?) → put → publish
 *     → seo metadata projection + feed syndication
 *
 * The security property under test is FAIL-CLOSED composition: an unauthenticated caller is rejected
 * BEFORE any content row is written; an authenticated non-publisher hits ContentAuthzError on publish
 * with no published row.
 */
import { beforeEach, describe, expect, it } from 'vitest'
import {
  InvalidSessionError,
  getSession,
  type AuthEngine,
  type Principal,
} from '@platform-modules/auth'
import {
  ContentAuthzError,
  contentEntries,
  getBySlug,
  list,
  publish,
  put as putRaw,
  type Actor,
  type ContentInput,
  type EntityRef,
  type StoreOpts,
} from '@platform-modules/content'
import { toFeed, type FeedMeta } from '@platform-modules/content/feed'
import { bearer, createFakeAuthEngine } from '../src/blueprints/cms/wiring/auth'
import {
  actorFromPrincipal,
  createContentDb,
  type ContentDb,
  type SeededContent,
} from '../src/blueprints/cms/wiring/content'
import { projectEntrySeo, type CmsSiteConfig, type EntrySeoMetadata } from '../src/blueprints/cms/wiring/seo'

// Deterministic fake sanitizer (engine is host-owned; security-primitives spec §3).
function sanitize(raw: string): string {
  return raw
    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

// Inject the host sanitizer so existing call sites read against the OLD arity unchanged.
function put(db: ContentDb, raw: ContentInput, actor: Actor, opts?: StoreOpts) {
  return putRaw(db, raw, actor, sanitize, opts)
}

const EDITOR_PRINCIPAL: Principal = {
  userId: 'editor-1',
  sessionId: 's-editor',
  roles: ['editor'],
  capabilities: ['content:publish', 'content:edit-any'],
}

const AUTHOR_PRINCIPAL: Principal = {
  userId: 'author-2',
  sessionId: 's-author',
  roles: ['author'],
  capabilities: ['content:write'],
}

const SITE: CmsSiteConfig = {
  subdomain: 'cms',
  baseDomain: 'platform.test',
  indexingEnabled: true,
}

const FEED_META: FeedMeta = {
  title: 'CMS Demo',
  siteUrl: 'https://cms.platform.test',
  feedUrl: 'https://cms.platform.test/feed.xml',
  description: 'Editorial feed',
}

type PublishDeps = {
  authEngine: AuthEngine
  db: ContentDb
  site: CmsSiteConfig
  feedMeta: FeedMeta
}

type PublishInput = {
  callerHeaders: Headers
  slug: string
  type: string
  title: string
  body: string
}

type PublishResult = {
  ref: EntityRef
  entry: NonNullable<Awaited<ReturnType<typeof getBySlug>>>
  seo: EntrySeoMetadata
  rss: string
  jsonFeed: string
}

/**
 * Host-owned glue a real app writes (the blueprint NAMES it; here it lives in the test as the
 * composition under proof). ORDER matters: session resolution runs before any content write — that
 * ordering is the fail-closed property the unauthenticated case asserts.
 */
async function runEditorPublish(deps: PublishDeps, input: PublishInput): Promise<PublishResult> {
  // 1. AUTH — resolve the caller's identity; fail-closed before any content row is written.
  const principal: Principal | null = await getSession(input.callerHeaders, deps.authEngine)
  if (!principal) {
    throw new InvalidSessionError('no session', 'missing_principal')
  }
  const actor = actorFromPrincipal(principal)

  // 2. CONTENT — create draft (object-level authz on modify is inside put).
  const draft = await put(
    deps.db,
    { slug: input.slug, type: input.type, title: input.title, body: input.body },
    actor,
  )

  // 3. CONTENT AUTHZ + publish — assertCanPublish runs inside publish (ContentAuthzError if denied).
  const ref = await publish(deps.db, draft.id, actor)

  const entry = await getBySlug(deps.db, ref.type, ref.slug, null)
  if (!entry || entry.status !== 'published') {
    throw new Error('expected published entry after publish()')
  }

  const published = await list(deps.db, { status: 'published' }, null)
  const seo = projectEntrySeo(entry, deps.site)
  const rss = toFeed(published, 'rss', deps.feedMeta)
  const jsonFeed = toFeed(published, 'json', deps.feedMeta)

  return { ref, entry, seo, rss, jsonFeed }
}

describe('blueprint: cms — editorial flow composes auth → content authz → publish → seo/feed', () => {
  let seeded: SeededContent
  let deps: PublishDeps

  beforeEach(async () => {
    seeded = await createContentDb()
    deps = {
      authEngine: createFakeAuthEngine(
        new Map([
          ['tok-editor', EDITOR_PRINCIPAL],
          ['tok-author', AUTHOR_PRINCIPAL],
        ]),
      ),
      db: seeded.db,
      site: SITE,
      feedMeta: FEED_META,
    }
  })

  it('editor with publish capability: creates + publishes → EntityRef matches, feed + seo reflect title/slug', async () => {
    const res = await runEditorPublish(deps, {
      callerHeaders: bearer('tok-editor'),
      slug: 'hello-world',
      type: 'post',
      title: 'Hello World',
      body: '<p>Welcome</p>',
    })

    expect(res.ref).toEqual({ id: res.entry.id, slug: 'hello-world', type: 'post' })
    expect(res.entry.status).toBe('published')
    expect(res.entry.title).toBe('Hello World')

    expect(res.rss).toContain('<title>Hello World</title>')
    expect(res.rss).toContain('/post/hello-world')
    expect(res.jsonFeed).toContain('"title":"Hello World"')
    expect(res.jsonFeed).toContain('/post/hello-world')

    expect(res.seo.robots).toBe('index,follow')
    expect(res.seo.canonicalUrl).toBe('https://cms.platform.test/post/hello-world')
    expect(res.seo.sitemapEntry.loc).toBe('https://cms.platform.test/post/hello-world')
    expect(res.seo.jsonLd).toContain('Hello World')
    expect(res.seo.jsonLd).toContain('/post/hello-world')
  })

  it('unauthenticated caller: InvalidSessionError before any content row is written', async () => {
    await expect(
      runEditorPublish(deps, {
        callerHeaders: new Headers(),
        slug: 'ghost',
        type: 'post',
        title: 'Ghost',
        body: '',
      }),
    ).rejects.toBeInstanceOf(InvalidSessionError)

    const rows = await seeded.db.select().from(contentEntries)
    expect(rows).toHaveLength(0)
  })

  it('authenticated non-publisher: ContentAuthzError on publish, no published row', async () => {
    const principal = await getSession(bearer('tok-author'), deps.authEngine)
    expect(principal).not.toBeNull()
    const actor = actorFromPrincipal(principal!)

    const draft = await put(
      deps.db,
      { slug: 'draft-only', type: 'post', title: 'Draft Only', body: '' },
      actor,
    )
    await expect(publish(deps.db, draft.id, actor)).rejects.toBeInstanceOf(ContentAuthzError)

    const published = await list(deps.db, { status: 'published' }, actor)
    expect(published).toHaveLength(0)

    const stillDraft = await getBySlug(deps.db, 'post', 'draft-only', actor)
    expect(stillDraft?.status).toBe('draft')
  })
})
