import { afterEach, describe, expect, it } from 'vitest'
import { sql } from 'drizzle-orm'
import { makePgHarness } from './pg-harness.js'
import { fieldsMigrationSql, fieldsRecursiveEavForwardMigrationSql } from './migrate.js'
import { fieldsRuntimeMigrationSql } from './revisions.js'
import { canonicalFieldGroupHash, type FieldGroup } from './schema.js'
import type { EntityRef } from './model.js'
import type { MediaFieldRef, TermFieldRef } from './values.js'
import { createFieldTypeRegistry, type FieldsAuthorization, type FieldsContext, type FieldsPrincipal } from './types.js'
import { fieldsRequestHash, type CodeFieldsRegistrySnapshot } from './definition-lifecycle.js'
import {
  parseFieldsImport,
  planFieldsImport,
  planPublishedFieldsImportReverse,
  publishFieldsImport,
  resumeFieldsImport,
  reversePublishedFieldsImport,
  rollbackFieldsImport,
  startFieldsImport,
  type FieldsExportManifest,
  type FieldsImportCommand,
  type FieldsImportLimits,
  type FieldsImportRemappers,
} from './import.js'

const group: FieldGroup = Object.freeze({
  key: 'profile', title: 'Profile', active: true,
  location: Object.freeze([Object.freeze([{ parameter: 'entityType', operator: 'eq' as const, value: 'content' }])]),
  fields: Object.freeze([Object.freeze({ key: 'title', name: 'title', label: 'Title', type: 'text', settings: Object.freeze({ maxLength: 100 }) })]),
})
const limits: FieldsImportLimits = Object.freeze({ maxBytes: 1024 * 1024, maxDefinitions: 100, maxEntities: 100, maxRevisions: 100, maxBlockNodes: 1000, maxDepth: 24 })
let teardown: (() => Promise<void>) | undefined
afterEach(async()=>{await teardown?.();teardown=undefined})

function context(): FieldsContext {
  const principal: FieldsPrincipal = Object.freeze({ id: 'admin-1', capabilities: new Set(['fields.manage']) })
  const authorization: FieldsAuthorization = Object.freeze({ assert: () => undefined, assertDefinitionMigration: () => ({ policyVersion: 'policy-1' }) })
  return Object.freeze({ principal, authorization })
}
async function fixture(){const harness=await makePgHarness();teardown=harness.teardown;for(const statement of fieldsMigrationSql().split(';').map((item)=>item.trim()).filter(Boolean))await harness.db.execute(sql.raw(statement));for(const statement of fieldsRecursiveEavForwardMigrationSql('postgres'))await harness.db.execute(sql.raw(statement));for(const statement of fieldsRuntimeMigrationSql('postgres'))await harness.db.execute(sql.raw(statement));return harness}
async function emptyCodeRegistry(): Promise<CodeFieldsRegistrySnapshot> { const version='code-v1',groups=Object.freeze([]),optionsPages=Object.freeze([]),blocks=Object.freeze([]),canonicalHash=await fieldsRequestHash({version,groups,optionsPages,blocks});return Object.freeze({version,canonicalHash,groups,optionsPages,blocks}) }
const remappers: FieldsImportRemappers = Object.freeze({ entity: async (value:EntityRef) => value, user: async (id:string) => id, media: async (value:MediaFieldRef) => value, term: async (value:TermFieldRef) => value, reusableBlock: async (id:string) => id })
function command(operationId:string,plan:{planId:string;manifestHash:string;scope:{tenantKey:string;entityTypes:readonly string[]}},journal?:{version:string;nextSection:string|null;nextOffset:number}):FieldsImportCommand{return Object.freeze({operationId,expectedPlanId:plan.planId,expectedManifestHash:plan.manifestHash,scope:plan.scope,expectedJournalVersion:journal?.version??null,expectedSection:(journal?.nextSection??null) as FieldsImportCommand['expectedSection'],expectedOffset:journal?.nextOffset??null})}
async function manifest():Promise<FieldsExportManifest>{const canonicalHash=await canonicalFieldGroupHash(group);return Object.freeze({version:1,definitions:Object.freeze({groups:Object.freeze([group]),versions:Object.freeze([Object.freeze({groupKey:'profile',revision:1,canonicalHash,definition:group,createdAt:'2026-08-22T00:00:00.000Z',origin:'db' as const})]),optionsPages:Object.freeze([]),blocks:Object.freeze([]),blockVersions:Object.freeze([])}),entities:Object.freeze([Object.freeze({sourceRef:Object.freeze({entityType:'content',entityId:'entry-1'}),values:Object.freeze({profile:Object.freeze({title:'Imported title'})}),definitionVersions:Object.freeze({profile:1})})]),omissions:Object.freeze([])})}

describe('fields staged import',()=>{
  it('keeps staged rows hidden, publishes exactly, replays, and reverses to the original corpus',async()=>{
    const {db}=await fixture(),registry=createFieldTypeRegistry(),code=await emptyCodeRegistry(),source=await manifest()
    const parsed=await parseFieldsImport(new TextEncoder().encode(JSON.stringify(source)),limits,registry)
    const scope=Object.freeze({tenantKey:'tenant-1',entityTypes:Object.freeze(['content'])})
    const plan=await planFieldsImport(db,scope,parsed,'replaceMapped',remappers,registry,code,context())
    let journal=await db.transaction((tx)=>startFieldsImport(tx,plan,{hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1',visibility:'hiddenUntilPublish'},command('import:start',plan),registry,code,context()))
    expect(journal.importId).toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1')
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_group_definitions`)))[0]).toMatchObject({count:0})

    journal=await db.transaction((tx)=>resumeFieldsImport(tx,journal.importId,10,command('import:resume:definitions',plan,journal),registry,code,context()))
    expect(journal).toMatchObject({state:'applying',nextSection:'entities',nextOffset:0})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_group_definitions`)))[0]).toMatchObject({count:0})
    journal=await db.transaction((tx)=>resumeFieldsImport(tx,journal.importId,10,command('import:resume:entities',plan,journal),registry,code,context()))
    expect(journal).toMatchObject({state:'staged',nextSection:null,nextOffset:0})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_value_nodes`)))[0]).toMatchObject({count:0})

    const publishCommand=command('import:publish',plan,journal)
    const published=await db.transaction((tx)=>publishFieldsImport(tx,journal.importId,publishCommand,registry,code,context()))
    const replay=await db.transaction((tx)=>publishFieldsImport(tx,journal.importId,publishCommand,registry,code,context()))
    expect(replay).toEqual(published)
    expect(published.state).toBe('published')
    expect((await db.transaction((tx)=>tx.execute<{origin:string;currentRevision:number;hostSessionId:string|null}>(sql`SELECT origin,current_revision AS "currentRevision",host_session_id AS "hostSessionId" FROM field_group_definitions WHERE key='profile'`)))[0]).toMatchObject({origin:'import',currentRevision:1,hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1'})
    expect((await db.transaction((tx)=>tx.execute<{valueText:string|null;definitionRevision:number;hostSessionId:string|null}>(sql`SELECT value_text AS "valueText",definition_revision AS "definitionRevision",host_session_id AS "hostSessionId" FROM field_value_nodes WHERE entity_type='content' AND entity_id='entry-1'`)))[0]).toMatchObject({valueText:'Imported title',definitionRevision:1,hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1'})

    const reversePlan=await db.transaction((tx)=>planPublishedFieldsImportReverse(tx,journal.importId,scope,registry,code,context()))
    const reversed=await db.transaction((tx)=>reversePublishedFieldsImport(tx,reversePlan,{...command('import:reverse',plan,published),expectedReversePlanId:reversePlan.reversePlanId,expectedPublishedValueCorpusVersion:reversePlan.publishedValueCorpusVersion},registry,code,context()))
    expect(reversed.state).toBe('reversed')
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_group_definitions`)))[0]).toMatchObject({count:0})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_definition_versions`)))[0]).toMatchObject({count:0})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_value_nodes`)))[0]).toMatchObject({count:0})
  })

  it('rejects reuse of a shared host session under a different start operation with a typed conflict',async()=>{const {db}=await fixture(),registry=createFieldTypeRegistry(),code=await emptyCodeRegistry(),parsed=await parseFieldsImport(new TextEncoder().encode(JSON.stringify(await manifest())),limits,registry),scope=Object.freeze({tenantKey:'tenant-1',entityTypes:Object.freeze(['content'])}),plan=await planFieldsImport(db,scope,parsed,'replaceMapped',remappers,registry,code,context()),staging=Object.freeze({hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb4',visibility:'hiddenUntilPublish' as const});const first=await db.transaction((tx)=>startFieldsImport(tx,plan,staging,command('duplicate:start:first',plan),registry,code,context()));expect(first.importId).toBe(staging.hostSessionId);await expect(db.transaction((tx)=>startFieldsImport(tx,plan,staging,command('duplicate:start:second',plan),registry,code,context()))).rejects.toMatchObject({code:'operation-conflict'});expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM fields_import_journal WHERE import_id=${staging.hostSessionId}`)))[0]).toMatchObject({count:1})})

  it('rolls back staged bytes without exposing canonical state',async()=>{
    const {db}=await fixture(),registry=createFieldTypeRegistry(),code=await emptyCodeRegistry(),parsed=await parseFieldsImport(new TextEncoder().encode(JSON.stringify(await manifest())),limits,registry),scope=Object.freeze({tenantKey:'tenant-1',entityTypes:Object.freeze(['content'])}),plan=await planFieldsImport(db,scope,parsed,'replaceMapped',remappers,registry,code,context())
    let journal=await db.transaction((tx)=>startFieldsImport(tx,plan,{hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2',visibility:'hiddenUntilPublish'},command('rollback:start',plan),registry,code,context()))
    journal=await db.transaction((tx)=>resumeFieldsImport(tx,journal.importId,1,command('rollback:resume',plan,journal),registry,code,context()))
    const rolled=await db.transaction((tx)=>rollbackFieldsImport(tx,journal.importId,command('rollback:apply',plan,journal),registry,code,context()))
    expect(rolled.state).toBe('rolledBack')
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM fields_import_staging`)))[0]).toMatchObject({count:0})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM field_group_definitions`)))[0]).toMatchObject({count:0})
  })


  it('rejects a new start when options values drift after planning',async()=>{
    const {db}=await fixture(),registry=createFieldTypeRegistry(),code=await emptyCodeRegistry(),parsed=await parseFieldsImport(new TextEncoder().encode(JSON.stringify(await manifest())),limits,registry),scope=Object.freeze({tenantKey:'tenant-1',entityTypes:Object.freeze(['content'])}),plan=await planFieldsImport(db,scope,parsed,'replaceMapped',remappers,registry,code,context())
    await db.execute(sql`INSERT INTO field_value_nodes (node_id,entity_type,entity_id,group_key,definition_revision,field_key,path,node_kind,ordinal,is_null,value_text,created_at,updated_at) VALUES (${crypto.randomUUID()},'options','site','unrelated',1,'headline','/f:headline','leaf',0,false,'Changed after plan',NOW(),NOW())`)
    await expect(db.transaction((tx)=>startFieldsImport(tx,plan,{hostSessionId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3',visibility:'hiddenUntilPublish'},command('stale-options:start',plan),registry,code,context()))).rejects.toMatchObject({code:'stale-plan'})
    expect((await db.transaction((tx)=>tx.execute<{count:number}>(sql`SELECT COUNT(*)::int AS count FROM fields_import_journal`)))[0]).toMatchObject({count:0})
  })

  it('rejects password payload bytes at the parser boundary',async()=>{
    const registry=createFieldTypeRegistry(),source=await manifest(),passwordGroup={...group,fields:[{key:'secret',name:'secret',label:'Secret',type:'password',settings:{hasherKey:'argon'}}]},canonicalHash=await canonicalFieldGroupHash(passwordGroup as FieldGroup)
    const hostile={...source,definitions:{...source.definitions,groups:[passwordGroup],versions:[{groupKey:'profile',revision:1,canonicalHash,definition:passwordGroup,createdAt:'2026-08-22T00:00:00.000Z',origin:'db'}]},entities:[{sourceRef:{entityType:'content',entityId:'entry-1'},values:{profile:{secret:'plaintext'}},definitionVersions:{profile:1}}]}
    await expect(parseFieldsImport(new TextEncoder().encode(JSON.stringify(hostile)),limits,registry)).rejects.toMatchObject({code:'validation'})
  })
})
