import { ContentAuthorizationError, type ContentPrincipal } from './authz.js'
import { ContentLifecycleError, type ContentAutosave } from './lifecycle.js'
import { canonicalContentMigrationHash } from './migrations/content-model.js'
import type {
  ContentEntry,
  ContentRevision,
  ContentUpdatePatch,
  ContentWriteInput,
  ProtectedContentRead,
} from './model.js'
import { ContentQueryError, decodeContentListQuery, type ContentListQuery, type ContentPage } from './query.js'
import type { ResolvedContentType } from './registry.js'
import type { ContentSchemaValue } from './schema.js'
import type { ResolvedContentStatus } from './status.js'
import type { EntityRef } from './store.js'

const UUID_RE=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const OPERATION_KEYS=[
  'list','count','get','create','update','transition','trash','restore','delete',
  'listRevisions','getRevision','restoreRevision','createAutosave','getAutosave','restoreAutosave',
] as const

export interface ResolvedTaxonomySchema { key:string; version:string; schema:ContentSchemaValue }
export interface ContentRevisionPage { items:readonly ContentRevision[]; nextCursor:number|null }
export interface ContentOperationContracts {
  list:{input:ContentListQuery;output:ContentPage}
  count:{input:ContentListQuery;output:number}
  get:{input:{id:string;passwordProof?:string};output:ProtectedContentRead}
  create:{input:ContentWriteInput&{operationId:string};output:ContentEntry}
  update:{input:{id:string;patch:ContentUpdatePatch;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
  transition:{input:{id:string;to:string;scheduleAt?:Date;publishedAt?:Date;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
  trash:{input:{id:string;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
  restore:{input:{id:string;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
  delete:{input:{id:string;expectedUpdatedAt:Date;operationId:string};output:EntityRef}
  listRevisions:{input:{id:string;before?:number;limit?:number};output:ContentRevisionPage}
  getRevision:{input:{id:string;revisionId:string};output:ContentRevision}
  restoreRevision:{input:{id:string;revisionId:string;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
  createAutosave:{input:{id:string;snapshot:ContentUpdatePatch;parentRevisionId:string;operationId:string};output:ContentAutosave}
  getAutosave:{input:{id:string;autosaveId:string};output:ContentAutosave}
  restoreAutosave:{input:{id:string;autosaveId:string;expectedUpdatedAt:Date;operationId:string};output:ContentEntry}
}
export type ContentOperationKey=keyof ContentOperationContracts
export type ContentOperationInput<K extends ContentOperationKey>=ContentOperationContracts[K]['input']
export type ContentOperationOutput<K extends ContentOperationKey>=ContentOperationContracts[K]['output']
export type ContentOperationErrorCategory='validation'|'authorization'|'notFound'|'conflict'|'capability'|'integrity'
export interface ContentOperationError {code:string;category:ContentOperationErrorCategory;message:string;fieldPath?:string}
export type ContentOperationResult<K extends ContentOperationKey>={ok:true;value:ContentOperationOutput<K>}|{ok:false;error:ContentOperationError}
export interface ContentHeadlessOperation<K extends ContentOperationKey=ContentOperationKey>{
  typeKey:string;key:K;capability:string;input:ContentSchemaValue;output:ContentSchemaValue;errors:readonly ContentOperationErrorCategory[]
}
export type ContentOperationHandlers=Readonly<Record<string,Partial<{
  [K in ContentOperationKey]:(input:ContentOperationInput<K>,principal:ContentPrincipal)=>Promise<ContentOperationOutput<K>>
}>>>
export interface ContentHeadlessSchema {
  version:string
  types:readonly ContentSchemaValue[]
  statuses:readonly ContentSchemaValue[]
  taxonomies:readonly ResolvedTaxonomySchema[]
  operations:readonly ContentHeadlessOperation[]
}
export interface ContentFieldsSchemaAdapter { schemaForType(typeKey:string):Promise<ContentSchemaValue> }
export interface ContentSchemaSources {
  types:readonly ResolvedContentType[]
  statuses:readonly ResolvedContentStatus[]
  taxonomies:readonly ResolvedTaxonomySchema[]
  fields?:ContentFieldsSchemaAdapter
}
export interface ContentHeadlessRuntime {
  schema:ContentHeadlessSchema
  invoke<K extends ContentOperationKey>(typeKey:string,key:K,untrustedInput:unknown,principal:ContentPrincipal):Promise<ContentOperationResult<K>>
}
export type ContentHeadlessErrorCode='invalid-schema-source'|'handler-mismatch'|'invalid-input'
export class ContentHeadlessError extends Error{
  override readonly name='ContentHeadlessError'
  constructor(readonly code:ContentHeadlessErrorCode,readonly detail:string,readonly field?:string){super(`content headless ${code}: ${detail}${field?` (${field})`:''}`)}
}

const ERRORS=Object.freeze(['validation','authorization','notFound','conflict','capability','integrity'] as const)
function object(value:unknown,field='input'):Record<string,unknown>{if(typeof value!=='object'||value===null||Array.isArray(value))throw new ContentHeadlessError('invalid-input','must be an object',field);return value as Record<string,unknown>}
function exactKeys(value:Record<string,unknown>,allowed:readonly string[],field='input'):void{for(const key of Object.keys(value))if(!allowed.includes(key))throw new ContentHeadlessError('invalid-input','contains an unsupported property',`${field}.${key}`)}
function string(value:unknown,field:string,max=4096):string{if(typeof value!=='string')throw new ContentHeadlessError('invalid-input','must be a string',field);const normalized=value.trim();if(!normalized||normalized.length>max)throw new ContentHeadlessError('invalid-input',`must contain 1..${max} characters`,field);return normalized}
function uuid(value:unknown,field:string):string{const result=string(value,field,128);if(!UUID_RE.test(result))throw new ContentHeadlessError('invalid-input','must be a UUID',field);return result}
function date(value:unknown,field:string):Date{const parsed=value instanceof Date?new Date(value.getTime()):typeof value==='string'?new Date(value):null;if(!parsed||Number.isNaN(parsed.getTime()))throw new ContentHeadlessError('invalid-input','must be an ISO timestamp or Date',field);return parsed}
function integer(value:unknown,field:string,min:number,max:number):number{if(typeof value!=='number'||!Number.isSafeInteger(value)||value<min||value>max)throw new ContentHeadlessError('invalid-input',`must be an integer in ${min}..${max}`,field);return value}
function optionalString(value:unknown,field:string,max=4096):string|undefined{return value===undefined?undefined:string(value,field,max)}
function optionalDate(value:unknown,field:string):Date|undefined{return value===undefined?undefined:date(value,field)}
function schemaValue(value:unknown,path='schema'):ContentSchemaValue{
  if(value===null||typeof value==='string'||typeof value==='boolean')return value
  if(typeof value==='number'){if(!Number.isFinite(value))throw new ContentHeadlessError('invalid-schema-source','contains a non-finite number',path);return value}
  if(value instanceof Date)return value.toISOString()
  if(Array.isArray(value))return Object.freeze(value.map((item,index)=>schemaValue(item,`${path}.${index}`)))
  if(typeof value==='object'){
    const out:Record<string,ContentSchemaValue>={}
    for(const key of Object.keys(value as object).sort()){
      const child=(value as Record<string,unknown>)[key]
      if(child!==undefined)out[key]=schemaValue(child,`${path}.${key}`)
    }
    return Object.freeze(out)
  }
  throw new ContentHeadlessError('invalid-schema-source','contains a non-serializable value',path)
}
function uniqueByKey<T extends {key:string}>(values:readonly T[],field:string):Map<string,T>{const map=new Map<string,T>();for(const value of values){if(!value||typeof value.key!=='string'||!value.key)throw new ContentHeadlessError('invalid-schema-source','contains an invalid key',field);if(map.has(value.key))throw new ContentHeadlessError('invalid-schema-source',`duplicate key ${value.key}`,field);map.set(value.key,value)}return map}

function jsonSchema(type:string,properties?:Record<string,ContentSchemaValue>,required:readonly string[]=[]):ContentSchemaValue{return Object.freeze({type,...(properties?{properties:Object.freeze(properties)}:{}),...(required.length?{required:Object.freeze([...required])}:{})})}
const STRING=jsonSchema('string'),NUMBER=jsonSchema('number'),BOOLEAN=jsonSchema('boolean'),DATE=Object.freeze({type:'string',format:'date-time'}),UUID=Object.freeze({type:'string',format:'uuid'}),ENTRY=Object.freeze({$ref:'ContentEntry'}),REVISION=Object.freeze({$ref:'ContentRevision'}),AUTOSAVE=Object.freeze({$ref:'ContentAutosave'})
function operationSchemas(key:ContentOperationKey):{input:ContentSchemaValue;output:ContentSchemaValue}{
  switch(key){
    case 'list':return {input:Object.freeze({$ref:'ContentListQuery'}),output:Object.freeze({$ref:'ContentPage'})}
    case 'count':return {input:Object.freeze({$ref:'ContentListQuery'}),output:NUMBER}
    case 'get':return {input:jsonSchema('object',{id:UUID,passwordProof:STRING},['id']),output:Object.freeze({$ref:'ProtectedContentRead'})}
    case 'create':return {input:Object.freeze({$ref:'ContentWriteInputWithOperationId'}),output:ENTRY}
    case 'update':return {input:jsonSchema('object',{id:UUID,patch:Object.freeze({$ref:'ContentUpdatePatch'}),expectedUpdatedAt:DATE,operationId:STRING},['id','patch','expectedUpdatedAt','operationId']),output:ENTRY}
    case 'transition':return {input:jsonSchema('object',{id:UUID,to:STRING,scheduleAt:DATE,publishedAt:DATE,expectedUpdatedAt:DATE,operationId:STRING},['id','to','expectedUpdatedAt','operationId']),output:ENTRY}
    case 'trash':case 'restore':return {input:jsonSchema('object',{id:UUID,expectedUpdatedAt:DATE,operationId:STRING},['id','expectedUpdatedAt','operationId']),output:ENTRY}
    case 'delete':return {input:jsonSchema('object',{id:UUID,expectedUpdatedAt:DATE,operationId:STRING},['id','expectedUpdatedAt','operationId']),output:Object.freeze({$ref:'EntityRef'})}
    case 'listRevisions':return {input:jsonSchema('object',{id:UUID,before:NUMBER,limit:NUMBER},['id']),output:Object.freeze({$ref:'ContentRevisionPage'})}
    case 'getRevision':return {input:jsonSchema('object',{id:UUID,revisionId:UUID},['id','revisionId']),output:REVISION}
    case 'restoreRevision':return {input:jsonSchema('object',{id:UUID,revisionId:UUID,expectedUpdatedAt:DATE,operationId:STRING},['id','revisionId','expectedUpdatedAt','operationId']),output:ENTRY}
    case 'createAutosave':return {input:jsonSchema('object',{id:UUID,snapshot:Object.freeze({$ref:'ContentUpdatePatch'}),parentRevisionId:UUID,operationId:STRING},['id','snapshot','parentRevisionId','operationId']),output:AUTOSAVE}
    case 'getAutosave':return {input:jsonSchema('object',{id:UUID,autosaveId:UUID},['id','autosaveId']),output:AUTOSAVE}
    case 'restoreAutosave':return {input:jsonSchema('object',{id:UUID,autosaveId:UUID,expectedUpdatedAt:DATE,operationId:STRING},['id','autosaveId','expectedUpdatedAt','operationId']),output:ENTRY}
  }
}
function enabledKeys(type:ResolvedContentType):readonly ContentOperationKey[]{
  if(type.rest===false)return []
  const keys:ContentOperationKey[]=['list','count','get',...(type.active?['create' as const]:[]),'update','transition','trash','restore','delete']
  if(type.rest.revisions&&type.supports.includes('revisions'))keys.push('listRevisions','getRevision','restoreRevision')
  if(type.rest.autosaves&&type.rest.revisions&&type.supports.includes('revisions'))keys.push('createAutosave','getAutosave','restoreAutosave')
  return keys
}
function capabilityLabel(type:ResolvedContentType,key:ContentOperationKey):string{
  switch(key){
    case 'list':case 'count':case 'get':return type.capabilities.read
    case 'create':return type.capabilities.create
    case 'delete':return `${type.capabilities.deleteOwn}|${type.capabilities.deleteOthers}`
    case 'transition':return `${type.capabilities.editOwn}|${type.capabilities.editOthers};published:${type.capabilities.publish}`
    default:return `${type.capabilities.editOwn}|${type.capabilities.editOthers}`
  }
}
function typeSchema(type:ResolvedContentType,fields?:ContentSchemaValue):ContentSchemaValue{return schemaValue({
  key:type.key,version:type.version,revision:type.revision,canonicalHash:type.canonicalHash,active:type.active,labels:type.labels,description:type.description,
  public:type.public,hierarchical:type.hierarchical,excludeFromSearch:type.excludeFromSearch,publiclyQueryable:type.publiclyQueryable,
  supports:type.supports,taxonomies:type.taxonomies,capabilities:type.capabilities,rest:type.rest,statusKeys:type.statusKeys,
  defaultTemplateKey:type.defaultTemplateKey,template:type.template,templateLock:type.templateLock,...(fields===undefined?{}:{fields}),
})}
function statusSchema(status:ResolvedContentStatus):ContentSchemaValue{return schemaValue({key:status.key,version:status.version,revision:status.revision,canonicalHash:status.canonicalHash,active:status.active,label:status.label,published:status.published,internal:status.internal,excludeFromSearch:status.excludeFromSearch,publiclyQueryable:status.publiclyQueryable,showInAdminAll:status.showInAdminAll,showInAdminStatusFilter:status.showInAdminStatusFilter,dateLabel:status.dateLabel,transitionInput:status.transitionInput})}

export async function generateContentSchema(sources:ContentSchemaSources):Promise<ContentHeadlessSchema>{
  const types=uniqueByKey(sources.types,'types'),statuses=uniqueByKey(sources.statuses,'statuses'),taxonomies=uniqueByKey(sources.taxonomies,'taxonomies')
  for(const taxonomy of taxonomies.values())if(typeof taxonomy.version!=='string'||!taxonomy.version)throw new ContentHeadlessError('invalid-schema-source',`taxonomy ${taxonomy.key} has no version`,'taxonomies')
  const serializedTypes:ContentSchemaValue[]=[]
  const operations:ContentHeadlessOperation[]=[]
  for(const type of [...types.values()].sort((a,b)=>a.key.localeCompare(b.key))){
    if(type.rest!==false){
      for(const statusKey of type.statusKeys??[])if(!statuses.has(statusKey))throw new ContentHeadlessError('invalid-schema-source',`type ${type.key} references missing status ${statusKey}`,'types')
      for(const taxonomyKey of type.taxonomies)if(!taxonomies.has(taxonomyKey))throw new ContentHeadlessError('invalid-schema-source',`type ${type.key} references missing taxonomy schema ${taxonomyKey}`,'taxonomies')
      if(type.supports.includes('customFields')&&!sources.fields)throw new ContentHeadlessError('invalid-schema-source',`type ${type.key} exposes custom fields without a fields schema adapter`,'fields')
    }
    let fields:ContentSchemaValue|undefined
    if(sources.fields&&type.rest!==false)fields=schemaValue(await sources.fields.schemaForType(type.key),`fields.${type.key}`)
    serializedTypes.push(typeSchema(type,fields))
    for(const key of enabledKeys(type)){
      const schemas=operationSchemas(key)
      operations.push(Object.freeze({typeKey:type.key,key,capability:capabilityLabel(type,key),input:schemas.input,output:schemas.output,errors:ERRORS}))
    }
  }
  const serializedStatuses=[...statuses.values()].sort((a,b)=>a.key.localeCompare(b.key)).map(statusSchema)
  const serializedTaxonomies=[...taxonomies.values()].sort((a,b)=>a.key.localeCompare(b.key)).map((taxonomy)=>Object.freeze({key:taxonomy.key,version:taxonomy.version,schema:schemaValue(taxonomy.schema,`taxonomies.${taxonomy.key}`)}))
  const hashInput=schemaValue({types:serializedTypes,statuses:serializedStatuses,taxonomies:serializedTaxonomies,operations})
  const version=await canonicalContentMigrationHash(hashInput)
  return Object.freeze({version,types:Object.freeze(serializedTypes),statuses:Object.freeze(serializedStatuses),taxonomies:Object.freeze(serializedTaxonomies),operations:Object.freeze(operations)})
}

function rawString(value:unknown,field:string,max:number,allowEmpty=true):string{
  if(typeof value!=='string'||value.length>max||(!allowEmpty&&value.trim().length===0))throw new ContentHeadlessError('invalid-input',`must be ${allowEmpty?'a':'a non-empty'} string up to ${max} characters`,field)
  return value
}
function optionalRawString(value:unknown,field:string,max:number):string|undefined{return value===undefined?undefined:rawString(value,field,max)}
function stringArray(value:unknown,field:string,max=500,uuidOnly=false):string[]{
  if(!Array.isArray(value)||value.length>max)throw new ContentHeadlessError('invalid-input',`must be an array with at most ${max} values`,field)
  const out:string[]=[];const seen=new Set<string>()
  for(const item of value){const v=string(item,field,256);if(uuidOnly&&!UUID_RE.test(v))throw new ContentHeadlessError('invalid-input','contains an invalid UUID',field);if(!seen.has(v)){seen.add(v);out.push(v)}}
  return out
}
function mediaRef(value:unknown,field:string):ContentEntry['featuredMedia']{
  if(value===null)return null
  const raw=object(value,field);exactKeys(raw,['id','kind'],field)
  const id=string(raw.id,`${field}.id`,256);const kind=raw.kind===undefined?undefined:string(raw.kind,`${field}.kind`,128)
  return Object.freeze({id,...(kind?{kind}:{})})
}
const WRITE_KEYS=['slug','type','title','body','excerpt','visibility','author','termIds','parentId','menuOrder','templateKey','featuredMedia','commentStatus','pingStatus','sticky','format'] as const
const PATCH_KEYS=WRITE_KEYS.filter((key)=>key!=='type')
function decodeWrite(value:unknown,mode:'create'|'patch'):ContentWriteInput|ContentUpdatePatch{
  const raw=object(value);const allowed=mode==='create'?WRITE_KEYS:PATCH_KEYS;exactKeys(raw,allowed)
  if(mode==='patch'&&Object.keys(raw).length===0)throw new ContentHeadlessError('invalid-input','patch must contain at least one field','patch')
  const out:Record<string,unknown>={}
  if(mode==='create'){
    out.slug=string(raw.slug,'slug',200);out.type=string(raw.type,'type',128);out.title=rawString(raw.title,'title',300);out.body=rawString(raw.body,'body',5_000_000)
  }else{
    if(raw.slug!==undefined)out.slug=string(raw.slug,'patch.slug',200)
    if(raw.title!==undefined)out.title=rawString(raw.title,'patch.title',300)
    if(raw.body!==undefined)out.body=rawString(raw.body,'patch.body',5_000_000)
  }
  if(raw.excerpt!==undefined)out.excerpt=rawString(raw.excerpt,`${mode==='patch'?'patch.':''}excerpt`,100_000)
  if(raw.visibility!==undefined){if(!['public','private','members'].includes(String(raw.visibility)))throw new ContentHeadlessError('invalid-input','must be public, private, or members','visibility');out.visibility=raw.visibility}
  if(raw.author!==undefined)out.author=string(raw.author,'author',256)
  if(raw.termIds!==undefined)out.termIds=stringArray(raw.termIds,'termIds',10_000,true)
  if(raw.parentId!==undefined){if(raw.parentId!==null&&!UUID_RE.test(string(raw.parentId,'parentId',128)))throw new ContentHeadlessError('invalid-input','must be null or a UUID','parentId');out.parentId=raw.parentId}
  if(raw.menuOrder!==undefined)out.menuOrder=integer(raw.menuOrder,'menuOrder',-2_147_483_648,2_147_483_647)
  if(raw.templateKey!==undefined)out.templateKey=raw.templateKey===null?null:string(raw.templateKey,'templateKey',256)
  if(raw.featuredMedia!==undefined)out.featuredMedia=mediaRef(raw.featuredMedia,'featuredMedia')
  if(raw.commentStatus!==undefined){if(raw.commentStatus!=='open'&&raw.commentStatus!=='closed')throw new ContentHeadlessError('invalid-input','must be open or closed','commentStatus');out.commentStatus=raw.commentStatus}
  if(raw.pingStatus!==undefined){if(raw.pingStatus!=='open'&&raw.pingStatus!=='closed')throw new ContentHeadlessError('invalid-input','must be open or closed','pingStatus');out.pingStatus=raw.pingStatus}
  if(raw.sticky!==undefined){if(typeof raw.sticky!=='boolean')throw new ContentHeadlessError('invalid-input','must be a boolean','sticky');out.sticky=raw.sticky}
  if(raw.format!==undefined)out.format=raw.format===null?null:string(raw.format,'format',128)
  return Object.freeze(out) as ContentWriteInput|ContentUpdatePatch
}
function decodeList(value:unknown):ContentListQuery{
  const raw=object(value)
  let dateValue=raw.date
  if(dateValue!==undefined){const d=object(dateValue,'date');exactKeys(d,['after','before','field'],'date');dateValue={...d,...(d.after===undefined?{}:{after:date(d.after,'date.after')}),...(d.before===undefined?{}:{before:date(d.before,'date.before')})}}
  return decodeContentListQuery({...raw,...(dateValue===undefined?{}:{date:dateValue})})
}
function decodedBase(value:unknown,extra:readonly string[]):Record<string,unknown>{const raw=object(value);exactKeys(raw,extra);return raw}
function decodeOperation<K extends ContentOperationKey>(key:K,value:unknown):ContentOperationInput<K>{
  let result:unknown
  switch(key){
    case 'list':case 'count':result=decodeList(value);break
    case 'get':{const raw=decodedBase(value,['id','passwordProof']);result={id:uuid(raw.id,'id'),...(raw.passwordProof===undefined?{}:{passwordProof:string(raw.passwordProof,'passwordProof',4096)})};break}
    case 'create':{const raw=object(value);exactKeys(raw,[...WRITE_KEYS,'operationId']);const {operationId,...write}=raw;result={...(decodeWrite(write,'create') as ContentWriteInput),operationId:string(operationId,'operationId',256)};break}
    case 'update':{const raw=decodedBase(value,['id','patch','expectedUpdatedAt','operationId']);result={id:uuid(raw.id,'id'),patch:decodeWrite(raw.patch,'patch'),expectedUpdatedAt:date(raw.expectedUpdatedAt,'expectedUpdatedAt'),operationId:string(raw.operationId,'operationId',256)};break}
    case 'transition':{const raw=decodedBase(value,['id','to','scheduleAt','publishedAt','expectedUpdatedAt','operationId']);result={id:uuid(raw.id,'id'),to:string(raw.to,'to',128),...(raw.scheduleAt===undefined?{}:{scheduleAt:date(raw.scheduleAt,'scheduleAt')}),...(raw.publishedAt===undefined?{}:{publishedAt:date(raw.publishedAt,'publishedAt')}),expectedUpdatedAt:date(raw.expectedUpdatedAt,'expectedUpdatedAt'),operationId:string(raw.operationId,'operationId',256)};break}
    case 'trash':case 'restore':case 'delete':{const raw=decodedBase(value,['id','expectedUpdatedAt','operationId']);result={id:uuid(raw.id,'id'),expectedUpdatedAt:date(raw.expectedUpdatedAt,'expectedUpdatedAt'),operationId:string(raw.operationId,'operationId',256)};break}
    case 'listRevisions':{const raw=decodedBase(value,['id','before','limit']);result={id:uuid(raw.id,'id'),...(raw.before===undefined?{}:{before:integer(raw.before,'before',1,2_147_483_647)}),...(raw.limit===undefined?{}:{limit:integer(raw.limit,'limit',1,100)})};break}
    case 'getRevision':{const raw=decodedBase(value,['id','revisionId']);result={id:uuid(raw.id,'id'),revisionId:uuid(raw.revisionId,'revisionId')};break}
    case 'restoreRevision':{const raw=decodedBase(value,['id','revisionId','expectedUpdatedAt','operationId']);result={id:uuid(raw.id,'id'),revisionId:uuid(raw.revisionId,'revisionId'),expectedUpdatedAt:date(raw.expectedUpdatedAt,'expectedUpdatedAt'),operationId:string(raw.operationId,'operationId',256)};break}
    case 'createAutosave':{const raw=decodedBase(value,['id','snapshot','parentRevisionId','operationId']);result={id:uuid(raw.id,'id'),snapshot:decodeWrite(raw.snapshot,'patch'),parentRevisionId:uuid(raw.parentRevisionId,'parentRevisionId'),operationId:string(raw.operationId,'operationId',256)};break}
    case 'getAutosave':{const raw=decodedBase(value,['id','autosaveId']);result={id:uuid(raw.id,'id'),autosaveId:uuid(raw.autosaveId,'autosaveId')};break}
    case 'restoreAutosave':{const raw=decodedBase(value,['id','autosaveId','expectedUpdatedAt','operationId']);result={id:uuid(raw.id,'id'),autosaveId:uuid(raw.autosaveId,'autosaveId'),expectedUpdatedAt:date(raw.expectedUpdatedAt,'expectedUpdatedAt'),operationId:string(raw.operationId,'operationId',256)};break}
    default:throw new ContentHeadlessError('invalid-input','unknown operation key')
  }
  return result as ContentOperationInput<K>
}
function hasAny(principal:ContentPrincipal,...capabilities:string[]):boolean{return capabilities.some((capability)=>principal.capabilities.has(capability))}
function coarseAuthorize(type:ResolvedContentType,key:ContentOperationKey,input:unknown,principal:ContentPrincipal,statuses:ReadonlyMap<string,ResolvedContentStatus>):void{
  if(!principal||typeof principal.id!=='string'||!(principal.capabilities instanceof Set)&&typeof principal.capabilities?.has!=='function')throw new ContentAuthorizationError('read','unknown','principal is invalid')
  switch(key){
    case 'list':case 'count':case 'get':if(!principal.capabilities.has(type.capabilities.read))throw new ContentAuthorizationError('read',principal.id,'missing required read capability');return
    case 'create':if(!type.active||!principal.capabilities.has(type.capabilities.create))throw new ContentAuthorizationError('create',principal.id,'missing required create capability or inactive type');return
    case 'trash':case 'delete':if(!hasAny(principal,type.capabilities.deleteOwn,type.capabilities.deleteOthers))throw new ContentAuthorizationError('delete',principal.id,'missing delete capability branch');return
    case 'transition':{
      if(!hasAny(principal,type.capabilities.editOwn,type.capabilities.editOthers))throw new ContentAuthorizationError('edit',principal.id,'missing edit capability branch')
      const target=(input as {to:string}).to;const status=statuses.get(target);if(!status)throw new ContentHeadlessError('invalid-input',`unknown target status ${target}`,'to')
      if(type.statusKeys!==undefined&&!type.statusKeys.includes(target))throw new ContentHeadlessError('invalid-input',`status ${target} is disabled for type ${type.key}`,'to')
      if(status.published&&!principal.capabilities.has(type.capabilities.publish))throw new ContentAuthorizationError('publish',principal.id,'missing publish capability')
      return
    }
    default:if(!hasAny(principal,type.capabilities.editOwn,type.capabilities.editOthers))throw new ContentAuthorizationError('edit',principal.id,'missing edit capability branch')
  }
}
function operationError(error:unknown):ContentOperationError{
  if(error instanceof ContentHeadlessError)return {code:error.code,category:error.code==='invalid-input'?'validation':'integrity',message:error.message,...(error.field?{fieldPath:error.field}:{})}
  if(error instanceof ContentAuthorizationError)return {code:'access-denied',category:'authorization',message:'operation is not authorized'}
  if(error instanceof ContentQueryError){const category:ContentOperationErrorCategory=error.code==='validation'?'validation':error.code==='capability-unavailable'?'capability':'integrity';return {code:error.code,category,message:error.message,...(error.field?{fieldPath:error.field}:{})}}
  if(error instanceof ContentLifecycleError){
    const category:ContentOperationErrorCategory=error.code==='validation'?'validation':error.code==='access-denied'?'authorization':error.code==='not-found'?'notFound':error.code==='capability-unavailable'?'capability':['conflict','stale-version','operation-conflict'].includes(error.code)?'conflict':'integrity'
    return {code:error.code,category,message:category==='authorization'?'operation is not authorized':error.message,...(error.field?{fieldPath:error.field}:{})}
  }
  return {code:'operation-failed',category:'integrity',message:'operation failed'}
}
function pair(typeKey:string,key:string):string{return `${typeKey}\u0000${key}`}

export async function createContentHeadlessRuntime(sources:ContentSchemaSources,handlers:ContentOperationHandlers):Promise<ContentHeadlessRuntime>{
  const schema=await generateContentSchema(sources)
  const advertised=new Set(schema.operations.map((operation)=>pair(operation.typeKey,operation.key)))
  const typeMap=uniqueByKey(sources.types,'types'),statusMap=uniqueByKey(sources.statuses,'statuses')
  for(const operation of schema.operations){const handler=handlers[operation.typeKey]?.[operation.key];if(typeof handler!=='function')throw new ContentHeadlessError('handler-mismatch',`missing handler for ${operation.typeKey}.${operation.key}`)}
  for(const [typeKey,group] of Object.entries(handlers)){
    if(typeof group!=='object'||group===null)throw new ContentHeadlessError('handler-mismatch',`handler group ${typeKey} is invalid`)
    for(const [key,handler] of Object.entries(group)){
      if(handler===undefined)continue
      if(!OPERATION_KEYS.includes(key as ContentOperationKey)||typeof handler!=='function'||!advertised.has(pair(typeKey,key)))throw new ContentHeadlessError('handler-mismatch',`handler ${typeKey}.${key} is not advertised`)
    }
  }
  const runtime:ContentHeadlessRuntime={
    schema,
    async invoke<K extends ContentOperationKey>(typeKey:string,key:K,untrustedInput:unknown,principal:ContentPrincipal):Promise<ContentOperationResult<K>>{
      if(!advertised.has(pair(typeKey,key)))return {ok:false,error:{code:'operation-disabled',category:'capability',message:'operation is not available'}}
      const type=typeMap.get(typeKey)
      if(!type)return {ok:false,error:{code:'operation-disabled',category:'capability',message:'operation is not available'}}
      try{
        const decoded=decodeOperation(key,untrustedInput)
        if(key==='create'&&(decoded as ContentOperationInput<'create'>).type!==typeKey)throw new ContentHeadlessError('invalid-input','create type does not match invoked type','type')
        coarseAuthorize(type,key,decoded,principal,statusMap)
        const handler=handlers[typeKey]?.[key] as ((input:ContentOperationInput<K>,principal:ContentPrincipal)=>Promise<ContentOperationOutput<K>>)|undefined
        if(!handler)return {ok:false,error:{code:'operation-disabled',category:'capability',message:'operation is not available'}}
        return {ok:true,value:await handler(decoded,principal)}
      }catch(error){return {ok:false,error:operationError(error)}}
    },
  }
  return Object.freeze(runtime)
}
