import { FieldValidationError } from './errors.js'
import { escapeFieldPathSegment } from './migrate.js'
import { parseFieldDefinition } from './schema.js'
import { assertSerializableFieldPresentation, sanitizeFieldHtml } from './types.js'
import type {
  AnyFieldDefinition,
  FieldDefinition,
  FieldGroup,
  FieldPath,
  FieldStorageValue,
  FieldType,
  FieldValueNodeRow,
  FlexibleLayout,
} from './schema.js'
import type {
  FieldHeadlessSchema,
  FieldPresentation,
  FieldTypeRegistry,
  FieldsContext,
  ResolvedAnyFieldDefinition,
  ResolvedFieldDefinition,
  RevisionFieldValue,
  RuntimeFieldTypeDescriptor,
  ValueLimits,
} from './types.js'

export interface MediaFieldRef<K extends 'image' | 'file' = 'image' | 'file'> { readonly id: string; readonly kind: K; readonly mimeType?: string }
export interface EntityFieldRef { readonly entityType: string; readonly id: string }
export interface TermFieldRef { readonly taxonomy: string; readonly id: string }
export interface UserFieldRef { readonly id: string }
export interface MapValue { readonly latitude: number; readonly longitude: number; readonly address?: string; readonly zoom?: number; readonly metadata?: Readonly<Record<string,string>> }
export interface LinkValue { readonly url: string; readonly title: string; readonly target?: '_self' | '_blank' }
export type PasswordWrite = { readonly operation: 'set'; readonly value: string } | { readonly operation: 'clear' } | { readonly operation: 'unchanged' }
export interface PasswordRead { readonly configured: boolean }
export type FieldValueMap = Readonly<Record<string, unknown>>
export interface RepeaterRow { readonly rowId: string; readonly values: FieldValueMap }
export interface FlexibleRow { readonly rowId: string; readonly layoutKey: string; readonly values: FieldValueMap }

export interface FieldValueAuthorization {
  assertTarget(ref: EntityFieldRef, action: 'readTarget' | 'writeReverseTarget'): Promise<void> | void
}
export interface FieldMediaMetadata { readonly mimeType: string; readonly bytes: number; readonly width?: number; readonly height?: number }
export interface FieldMediaMetadataResolver { resolve(ref: MediaFieldRef): Promise<FieldMediaMetadata | undefined> }
export interface FieldProjectionResolvers {
  media(ref: MediaFieldRef, projection: 'id' | 'url'): Promise<string>
  entity(ref: EntityFieldRef, projection: 'id' | 'object'): Promise<string | Readonly<Record<string,unknown>>>
  term(ref: TermFieldRef, projection: 'id' | 'object'): Promise<string | Readonly<Record<string,unknown>>>
  user(ref: UserFieldRef, projection: 'id' | 'object'): Promise<string | Readonly<Record<string,unknown>>>
}
export interface CloneDefinitionResolver { resolve(definition: FieldDefinition<'clone'>): readonly AnyFieldDefinition[] }
export interface FieldValueContext extends FieldsContext {
  readonly authorization?: FieldValueAuthorization
  readonly mediaMetadata?: FieldMediaMetadataResolver
  readonly cloneResolver?: CloneDefinitionResolver
}

const DEFAULT_LIMITS: ValueLimits = Object.freeze({ maxBytes: 1024*1024, maxDepth: 12, maxNodes: 1_000 })
const PRESENTATION_TYPES = new Set<FieldType>(['message','accordion','tab'])
function fail(field:string, detail:string):never { throw new FieldValidationError(field,detail) }
function obj(value:unknown, field:string):Record<string,unknown>{ if(typeof value!=='object'||value===null||Array.isArray(value)||Object.getPrototypeOf(value)!==Object.prototype)fail(field,'must be a plain object');return value as Record<string,unknown> }
function str(value:unknown,field:string,max=8192):string{if(typeof value!=='string'||value.length>max)fail(field,`must be a string up to ${max} characters`);return value}
function nonempty(value:unknown,field:string,max=4096):string{const s=str(value,field,max);if(!s.length)fail(field,'must not be empty');return s}
function finite(value:unknown,field:string):number{if(typeof value!=='number'||!Number.isFinite(value))fail(field,'must be a finite number');return value}
function safeInteger(value:unknown,field:string):number{const n=finite(value,field);if(!Number.isSafeInteger(n))fail(field,'must be a safe integer');return n}
function serializable(value:unknown, limits=DEFAULT_LIMITS, field='value', depth=0, budget={nodes:0,bytes:0}):FieldStorageValue{
  if(depth>limits.maxDepth)fail(field,`exceeds maximum depth ${limits.maxDepth}`)
  if(value===null||typeof value==='boolean')return value
  if(typeof value==='number'){if(!Number.isFinite(value))fail(field,'contains non-finite number');budget.bytes+=8;return value}
  if(typeof value==='string'){budget.bytes+=new TextEncoder().encode(value).byteLength;if(budget.bytes>limits.maxBytes)fail(field,`exceeds maximum size ${limits.maxBytes}`);return value}
  if(++budget.nodes>limits.maxNodes)fail(field,`exceeds maximum node count ${limits.maxNodes}`)
  if(Array.isArray(value))return Object.freeze(value.map((item,i)=>serializable(item,limits,`${field}.${i}`,depth+1,budget)))
  const source=obj(value,field),out:Record<string,FieldStorageValue>={}
  for(const key of Object.keys(source).sort()){if(['__proto__','prototype','constructor'].includes(key))fail(`${field}.${key}`,'is forbidden');out[key]=serializable(source[key],limits,`${field}.${key}`,depth+1,budget)}
  return Object.freeze(out)
}
function settings(value:unknown):Record<string,unknown>{return obj(value??{},'settings')}
function pattern(value:string, setting:unknown, field:string):void{if(setting===undefined)return;const p=obj(setting,'settings.pattern'),source=nonempty(p.source,'settings.pattern.source',512),flags=p.flags===undefined?'':str(p.flags,'settings.pattern.flags',16);let rx:RegExp;try{rx=new RegExp(source,flags)}catch{fail('settings.pattern','is invalid')}if(!rx.test(value))fail(field,'does not match the required pattern')}
function parseUrl(value:unknown, s:Record<string,unknown>, field='value'):string{const raw=nonempty(value,field,8192);if(s.allowRelative===true&&/^(?:\/|\.\/|\.\.\/)/.test(raw))return raw;let url:URL;try{url=new URL(raw)}catch{fail(field,'must be a valid URL')}const schemes=Array.isArray(s.schemes)?s.schemes.map(String):[];if(schemes.length&&!schemes.includes(url.protocol.replace(/:$/,'')))fail(field,'uses a disallowed URL scheme');return url.toString()}
function choice(value:unknown, field:string):string|number{if(typeof value!=='string'&&typeof value!=='number')fail(field,'must be string or number');if(typeof value==='number'&&!Number.isFinite(value))fail(field,'must be finite');return value}
function assertChoiceAllowed(value:string|number,s:Record<string,unknown>,field:string):void{const choices=obj(s.choices??{},'settings.choices');if(s.allowCustom!==true&&!Object.prototype.hasOwnProperty.call(choices,String(value)))fail(field,'is not an allowed choice')}
function ref(value:unknown,field:string):Readonly<Record<string,string>>{const v=obj(value,field),allowed=new Set(['entityType','id','taxonomy','kind','mimeType']),out:Record<string,string>={};for(const key of Object.keys(v)){if(!allowed.has(key))fail(`${field}.${key}`,'is not allowed');out[key]=nonempty(v[key],`${field}.${key}`,256)}if(!out.id)fail(`${field}.id`,'is required');return Object.freeze(out)}
function bounds(length:number,s:Record<string,unknown>,field:string,defaultMax=100):void{const min=typeof s.min==='number'?s.min:0,max=typeof s.max==='number'?Math.min(s.max,defaultMax):defaultMax;if(length<min||length>max)fail(field,`must contain ${min}..${max} items`)}

function parseMap(fields:readonly AnyFieldDefinition[], input:unknown, limits:ValueLimits, write:boolean, depth:number):FieldValueMap{
  if(depth>limits.maxDepth)fail('value',`exceeds maximum depth ${limits.maxDepth}`)
  const value=obj(input,'value'),byKey=new Map(fields.map((field)=>[field.key,field])),out:Record<string,unknown>={}
  for(const key of Object.keys(value))if(!byKey.has(key))fail(`value.${key}`,'is not defined')
  for(const field of fields){if(PRESENTATION_TYPES.has(field.type))continue;const present=Object.prototype.hasOwnProperty.call(value,field.key);if(!present){if(field.required)fail(`value.${field.key}`,'is required');continue}out[field.key]=parseBuiltin(field.type,value[field.key],field.settings,limits,write,depth+1)}
  return Object.freeze(out)
}

function parseBuiltin(type:FieldType,input:unknown,settingsValue:unknown,limits:ValueLimits,write:boolean,depth=0):unknown{
  const s=settings(settingsValue)
  if(PRESENTATION_TYPES.has(type))fail('value',`${type} is presentation-only`)
  if(type==='password'){
    const v=obj(input,'value')
    if(!write){if(typeof v.configured!=='boolean'||Object.keys(v).length!==1)fail('value','password reads expose only configured:boolean');return Object.freeze({configured:v.configured})}
    if(v.operation==='clear'||v.operation==='unchanged'){if(Object.keys(v).length!==1)fail('value','password clear/unchanged contain no value');return Object.freeze({operation:v.operation})}
    if(v.operation!=='set')fail('value.operation','must be set, clear, or unchanged');const raw=nonempty(v.value,'value.value',typeof s.maxLength==='number'?s.maxLength:4096);if(typeof s.minLength==='number'&&raw.length<s.minLength)fail('value.value',`must contain at least ${s.minLength} characters`);return Object.freeze({operation:'set',value:raw})
  }
  if(type==='text'||type==='textarea'||type==='richText'){const value=str(input,'value',typeof s.maxLength==='number'?s.maxLength:1_000_000);pattern(value,s.pattern,'value');return value}
  if(type==='email'){const value=nonempty(input,'value',320);if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))fail('value','must be an email address');return value.toLowerCase()}
  if(type==='url')return parseUrl(input,s)
  if(type==='number'||type==='range'){const value=finite(input,'value');if(s.integer===true&&!Number.isSafeInteger(value))fail('value','must be a safe integer');if(typeof s.min==='number'&&value<s.min)fail('value',`must be >= ${s.min}`);if(typeof s.max==='number'&&value>s.max)fail('value',`must be <= ${s.max}`);if(typeof s.step==='number'&&s.step>0){const origin=typeof s.min==='number'?s.min:0,ratio=(value-origin)/s.step;if(Math.abs(ratio-Math.round(ratio))>1e-9)fail('value',`must align to step ${s.step}`)}return value}
  if(type==='boolean'){if(typeof input!=='boolean')fail('value','must be boolean');return input}
  if(type==='select'||type==='radio'||type==='buttonGroup'){
    if(input===null&&s.allowNull===true)return null
    if(s.multiple===true){if(!Array.isArray(input))fail('value','must be an array');const values=[...new Set(input.map((v,i)=>choice(v,`value.${i}`)))];values.forEach((v)=>assertChoiceAllowed(v,s,'value'));bounds(values.length,s,'value',1000);return Object.freeze(values)}
    const value=choice(input,'value');assertChoiceAllowed(value,s,'value');return value
  }
  if(type==='checkbox'){if(!Array.isArray(input))fail('value','must be an array');const values=[...new Set(input.map((v,i)=>choice(v,`value.${i}`)))];values.forEach((v)=>assertChoiceAllowed(v,s,'value'));bounds(values.length,s,'value',1000);return Object.freeze(values)}
  if(type==='oembed'){const v=obj(input,'value'),url=parseUrl(v.url,{schemes:['http','https']},'value.url'),metadata=v.metadata===undefined?undefined:serializable(v.metadata,limits,'value.metadata');return Object.freeze({url,...(metadata===undefined?{}:{metadata})})}
  if(type==='link'){if(input===null)return null;const v=obj(input,'value'),url=parseUrl(v.url,{schemes:s.schemes??['http','https']},'value.url'),title=str(v.title,'value.title',4096),target=v.target;if(target!==undefined&&target!=='_self'&&target!=='_blank')fail('value.target','must be _self or _blank');return Object.freeze({url,title,...(target===undefined?{}:{target})})}
  if(type==='image'||type==='file'){if(input===null)return null;const r=ref(input,'value');if(r.kind!==type)fail('value.kind',`must be ${type}`);return r}
  if(type==='gallery'){if(!Array.isArray(input))fail('value','must be an array');const refs=input.map((item,i)=>{const r=ref(item,`value.${i}`);if(r.kind!=='image')fail(`value.${i}.kind`,'must be image');return r});bounds(refs.length,s,'value');return Object.freeze(refs)}
  if(type==='entity'||type==='entityLink'){if(input===null)return null;const r=ref(input,'value');if(!r.entityType)fail('value.entityType','is required');const allowed=Array.isArray(s.entityTypes)?s.entityTypes.map(String):[];if(allowed.length&&!allowed.includes(r.entityType))fail('value.entityType','is not allowed');return r}
  if(type==='relationship'){if(!Array.isArray(input))fail('value','must be an array');const refs=input.map((item,i)=>{const r=ref(item,`value.${i}`);if(!r.entityType)fail(`value.${i}.entityType`,'is required');const allowed=Array.isArray(s.entityTypes)?s.entityTypes.map(String):[];if(allowed.length&&!allowed.includes(r.entityType))fail(`value.${i}.entityType`,'is not allowed');return r});const seen=new Set<string>();for(const r of refs){const key=`${r.entityType}:${r.id}`;if(seen.has(key))fail('value','contains duplicate relationship target');seen.add(key)}bounds(refs.length,s,'value');return Object.freeze(refs)}
  if(type==='taxonomy'){if(input===null)return null;const one=(item:unknown,field:string)=>{const r=ref(item,field);if(!r.taxonomy)fail(`${field}.taxonomy`,'is required');const allowed=Array.isArray(s.taxonomies)?s.taxonomies.map(String):[];if(allowed.length&&!allowed.includes(r.taxonomy))fail(`${field}.taxonomy`,'is not allowed');return r};if(s.multiple===true){if(!Array.isArray(input))fail('value','must be an array');return Object.freeze(input.map((item,i)=>one(item,`value.${i}`)))}return one(input,'value')}
  if(type==='user'){if(input===null)return null;const one=(item:unknown,field:string)=>Object.freeze({id:nonempty(obj(item,field).id,`${field}.id`,256)});if(s.multiple===true){if(!Array.isArray(input))fail('value','must be an array');return Object.freeze(input.map((item,i)=>one(item,`value.${i}`)))}return one(input,'value')}
  if(type==='date'){if(input===null)return null;const value=str(input,'value',64);if(!/^\d{4}-\d{2}-\d{2}$/.test(value)||Number.isNaN(Date.parse(`${value}T00:00:00Z`)))fail('value','must be YYYY-MM-DD');return value}
  if(type==='time'){if(input===null)return null;const value=str(input,'value',64);if(!/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?$/.test(value))fail('value','must be canonical local time');return value}
  if(type==='dateTime'){if(input===null)return null;const value=str(input,'value',128),date=new Date(value);if(Number.isNaN(date.getTime()))fail('value','must be valid date-time');return date.toISOString()}
  if(type==='color'){if(input===null)return null;const value=str(input,'value',32).toLowerCase(),rx=s.alpha===true?/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/:/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/;if(!rx.test(value))fail('value','must be canonical hex color');return value}
  if(type==='icon'){if(input===null&&s.allowNull===true)return null;const v=obj(input,'value'),set=nonempty(v.set,'value.set',128),name=nonempty(v.name,'value.name',256),allowed=Array.isArray(s.sets)?s.sets.map(String):[];if(allowed.length&&!allowed.includes(set))fail('value.set','is not allowed');return Object.freeze({set,name})}
  if(type==='map'){if(input===null)return null;const v=obj(input,'value'),latitude=finite(v.latitude,'value.latitude'),longitude=finite(v.longitude,'value.longitude');if(latitude < -90||latitude>90)fail('value.latitude','must be between -90 and 90');if(longitude < -180||longitude>180)fail('value.longitude','must be between -180 and 180');const address=v.address===undefined?undefined:str(v.address,'value.address',4096),zoom=v.zoom===undefined?undefined:finite(v.zoom,'value.zoom'),metadata=v.metadata===undefined?undefined:serializable(v.metadata,limits,'value.metadata');return Object.freeze({latitude,longitude,...(address===undefined?{}:{address}),...(zoom===undefined?{}:{zoom}),...(metadata===undefined?{}:{metadata})})}
  if(type==='group'){return parseMap(Array.isArray(s.fields)?s.fields as AnyFieldDefinition[]:[],input,limits,write,depth+1)}
  if(type==='repeater'){if(!Array.isArray(input))fail('value','must be array');bounds(input.length,s,'value');const fields=Array.isArray(s.fields)?s.fields as AnyFieldDefinition[]:[],ids=new Set<string>();return Object.freeze(input.map((raw,i)=>{const row=obj(raw,`value.${i}`),rowId=nonempty(row.rowId,`value.${i}.rowId`,256);if(ids.has(rowId))fail(`value.${i}.rowId`,'must be unique');ids.add(rowId);return Object.freeze({rowId,values:parseMap(fields,row.values,limits,write,depth+1)})}))}
  if(type==='flexible'){if(!Array.isArray(input))fail('value','must be array');bounds(input.length,s,'value');const layouts=Array.isArray(s.layouts)?s.layouts as Array<Record<string,unknown>>:[],layoutMap=new Map(layouts.map((l)=>[String(l.key),l])),ids=new Set<string>();return Object.freeze(input.map((raw,i)=>{const row=obj(raw,`value.${i}`),rowId=nonempty(row.rowId,`value.${i}.rowId`,256),layoutKey=nonempty(row.layoutKey,`value.${i}.layoutKey`,256);if(ids.has(rowId))fail(`value.${i}.rowId`,'must be unique');ids.add(rowId);const layout=layoutMap.get(layoutKey);if(!layout)fail(`value.${i}.layoutKey`,'is not registered');return Object.freeze({rowId,layoutKey,values:parseMap(Array.isArray(layout.fields)?layout.fields as AnyFieldDefinition[]:[],row.values,limits,write,depth+1)})}))}
  if(type==='clone')return serializable(input,limits)
  fail('type',`unsupported field type ${type}`)
}

const BUILTIN_TYPES: readonly FieldType[] = Object.freeze([
  'text','textarea','number','range','email','url','password','boolean','select','checkbox','radio','buttonGroup','richText','oembed','link','message','image','file','gallery','entity','entityLink','relationship','taxonomy','user','date','dateTime','time','color','icon','map','accordion','tab','group','repeater','flexible','clone',
])
function digest(seed:string):string{let a=0x811c9dc5,b=0x9e3779b9;for(let round=0;round<8;round++){for(let i=0;i<seed.length;i++){a=Math.imul(a^(seed.charCodeAt(i)+round),0x01000193);b=Math.imul(b^((seed.charCodeAt(i)<<(i%8))+round),0x85ebca6b)}seed=`${seed}:${(a>>>0).toString(16)}:${(b>>>0).toString(16)}`}return `${(a>>>0).toString(16).padStart(8,'0')}${(b>>>0).toString(16).padStart(8,'0')}`.repeat(4).slice(0,64)}
function parseDefinitionFor(type:FieldType,input:unknown):ResolvedAnyFieldDefinition{const parsed=parseFieldDefinition(input);if(parsed.type!==type)fail('definition.type',`must be ${type}`);return Object.freeze({key:parsed.key,name:parsed.name,label:parsed.label,type,settings:parsed.settings,...(parsed.required===undefined?{}:{required:parsed.required}),...(parsed.readOnly===undefined?{}:{readOnly:parsed.readOnly})})}
function builtinSchema(type:FieldType,s:unknown):FieldHeadlessSchema{const nullable=['link','image','file','entity','entityLink','taxonomy','user','date','dateTime','time','color','icon','map'].includes(type);return Object.freeze({type,nullable,readOnly:PRESENTATION_TYPES.has(type),constraints:serializable(s??{},DEFAULT_LIMITS,'settings') as Readonly<Record<string,unknown>>})}
async function builtinFormat(type:FieldType,value:unknown,context:FieldsContext):Promise<FieldPresentation<unknown>>{if(type==='richText'){if(typeof value!=='string')fail('value','richText formatter requires a string');return Object.freeze({kind:'sanitizedHtml',value:await sanitizeFieldHtml(value,context)})}if(typeof value==='string'||typeof value==='number'||typeof value==='boolean'||value===null)return Object.freeze({kind:'text',value:value===null?'':String(value)});return Object.freeze({kind:'structured',value:serializable(value)})}
export function createBuiltinFieldTypeDescriptors():readonly RuntimeFieldTypeDescriptor[]{return Object.freeze(BUILTIN_TYPES.map((type)=>Object.freeze({
  type,contractVersion:'1',schemaHash:digest(`schema:${type}:1`),codecHash:digest(`codec:${type}:1`),
  parseDefinition:(input:unknown)=>parseDefinitionFor(type,input),
  parseValue:(input:unknown,s:unknown,limits:ValueLimits)=>parseBuiltin(type,input,s,limits,false),
  parseWrite:(input:unknown,s:unknown,limits:ValueLimits)=>parseBuiltin(type,input,s,limits,true),
  serialize:(value:unknown)=>serializable(value),
  deserialize:(value:FieldStorageValue,s:unknown)=>parseBuiltin(type,value,s,DEFAULT_LIMITS,false),
  format:(value:unknown,_s:unknown,context:FieldsContext)=>builtinFormat(type,value,context),
  schema:(s:unknown)=>builtinSchema(type,s),
  redactForRevision:(value:unknown):RevisionFieldValue|'omit'=>type==='password'?Object.freeze({configured:Boolean((value as {configured?:unknown})?.configured)}):PRESENTATION_TYPES.has(type)?'omit':serializable(value),
})))}

function descriptorFor(definition:ResolvedFieldDefinition,registry:FieldTypeRegistry):RuntimeFieldTypeDescriptor{const descriptor=registry.resolve(definition.type);if(!descriptor)fail('definition.type',`unregistered field type ${definition.type}`);return descriptor}
function fieldSettings(definition:ResolvedFieldDefinition):unknown{return definition.settings}
function refsFromValue(definition:ResolvedFieldDefinition,value:unknown):EntityFieldRef[]{if(definition.type==='entity'||definition.type==='entityLink'){return value===null?[]:[value as EntityFieldRef]}if(definition.type==='relationship')return value as EntityFieldRef[];return []}
async function validateMedia(definition:ResolvedFieldDefinition,value:unknown,resolver:FieldMediaMetadataResolver|undefined):Promise<void>{if(!['image','file','gallery'].includes(definition.type)||value===null)return;if(!resolver)fail('mediaMetadata','is required to validate media values');const s=settings(definition.settings),refs=(definition.type==='gallery'?value as MediaFieldRef[]:[value as MediaFieldRef]);for(const r of refs){const meta=await resolver.resolve(r);if(!meta)fail('mediaMetadata','is unavailable for a referenced item');const mimes=Array.isArray(s.mimeTypes)?s.mimeTypes.map(String):[];if(mimes.length&&!mimes.some((allowed)=>allowed.endsWith('/*')?meta.mimeType.startsWith(allowed.slice(0,-1)):meta.mimeType===allowed))fail('value','uses disallowed media MIME type');if(typeof s.minBytes==='number'&&meta.bytes<s.minBytes)fail('value','media is below minimum byte size');if(typeof s.maxBytes==='number'&&meta.bytes>s.maxBytes)fail('value','media exceeds maximum byte size');if(definition.type!=='file'){for(const [key,actual] of [['minWidth',meta.width],['maxWidth',meta.width],['minHeight',meta.height],['maxHeight',meta.height]] as const){const bound=s[key];if(typeof bound==='number'&&actual===undefined)fail('mediaMetadata',`missing ${key.includes('Width')?'width':'height'}`);if(typeof bound==='number'&&key.startsWith('min')&&actual!<bound)fail('value',`media violates ${key}`);if(typeof bound==='number'&&key.startsWith('max')&&actual!>bound)fail('value',`media violates ${key}`)}}}}

export async function validateFieldValue(definition:ResolvedFieldDefinition,input:unknown,registry:FieldTypeRegistry,context:FieldValueContext={},limits:ValueLimits=DEFAULT_LIMITS):Promise<unknown>{
  let value:unknown
  if(definition.type==='clone'&&context.cloneResolver){const fields=context.cloneResolver.resolve(definition as FieldDefinition<'clone'>);value=parseMap(fields,input,limits,true,0)}else value=descriptorFor(definition,registry).parseWrite(input,fieldSettings(definition),limits)
  if(definition.type==='richText'){
    if(!context.sanitizer)fail('sanitizer','is required to validate richText')
    const sanitized=await context.sanitizer.sanitizeHtml(String(value))
    if(typeof sanitized!=='string')fail('sanitizer','must return a string')
    value=sanitized
  }
  await validateMedia(definition,value,context.mediaMetadata)
  const auth=context.authorization
  if(auth)for(const r of refsFromValue(definition,value))await auth.assertTarget(r,'readTarget')
  return value
}

function projectMode(definition:ResolvedFieldDefinition):string|undefined{return typeof definition.settings==='object'&&definition.settings!==null?'return' in definition.settings?String((definition.settings as {return?:unknown}).return):undefined:undefined}
async function projectRef(definition:ResolvedFieldDefinition,value:unknown,resolvers:FieldProjectionResolvers):Promise<unknown>{const mode=projectMode(definition);if(mode===undefined||mode==='ref')return value;if(definition.type==='image'||definition.type==='file')return value===null?null:resolvers.media(value as MediaFieldRef,mode as 'id'|'url');if(definition.type==='gallery')return Promise.all((value as MediaFieldRef[]).map((r)=>resolvers.media(r,mode as 'id'|'url')));if(definition.type==='entity'||definition.type==='entityLink')return value===null?null:resolvers.entity(value as EntityFieldRef,mode as 'id'|'object');if(definition.type==='relationship')return Promise.all((value as EntityFieldRef[]).map((r)=>resolvers.entity(r,mode as 'id'|'object')));if(definition.type==='taxonomy'){if(value===null)return null;return Array.isArray(value)?Promise.all(value.map((r)=>resolvers.term(r as TermFieldRef,mode as 'id'|'object'))):resolvers.term(value as TermFieldRef,mode as 'id'|'object')}if(definition.type==='user'){if(value===null)return null;return Array.isArray(value)?Promise.all(value.map((r)=>resolvers.user(r as UserFieldRef,mode as 'id'|'object'))):resolvers.user(value as UserFieldRef,mode as 'id'|'object')}return value}
export async function formatFieldValue(definition:ResolvedFieldDefinition,value:unknown,registry:FieldTypeRegistry,resolvers:FieldProjectionResolvers,context:FieldValueContext={}):Promise<FieldPresentation<unknown>>{
  if(definition.type==='group'||definition.type==='repeater'||definition.type==='flexible'||definition.type==='clone')return Object.freeze({kind:'structured',value:serializable(value)})
  if(context.authorization)for(const target of refsFromValue(definition,value))await context.authorization.assertTarget(target,'readTarget')
  const projected=await projectRef(definition,value,resolvers)
  const presentation=await descriptorFor(definition,registry).format(projected,fieldSettings(definition),context)
  return assertSerializableFieldPresentation(presentation)
}

export function serializeFieldValue(definition:ResolvedFieldDefinition,value:unknown,registry:FieldTypeRegistry):FieldStorageValue{return descriptorFor(definition,registry).serialize(value,fieldSettings(definition))}
export function deserializeFieldValue(definition:ResolvedFieldDefinition,value:FieldStorageValue,registry:FieldTypeRegistry):unknown{return descriptorFor(definition,registry).deserialize(value,fieldSettings(definition))}
export function redactFieldValueForRevision(definition:ResolvedFieldDefinition,value:unknown,registry:FieldTypeRegistry):RevisionFieldValue|'omit'{return descriptorFor(definition,registry).redactForRevision(value,fieldSettings(definition))}

export function* repeaterRows(value:readonly RepeaterRow[]):IterableIterator<RepeaterRow>{for(const row of value)yield row}
export function* flexibleRows(value:readonly FlexibleRow[],layoutKey?:string):IterableIterator<FlexibleRow>{for(const row of value)if(layoutKey===undefined||row.layoutKey===layoutKey)yield row}

function pathField(parent:string,key:string):string{return `${parent}/f:${escapeFieldPathSegment(key)}`}
function pathRow(parent:string,rowId:string):string{return `${parent}/r:${escapeFieldPathSegment(rowId)}`}
function pathLayout(parent:string,layoutKey:string):string{return `${parent}/l:${escapeFieldPathSegment(layoutKey)}`}
function leafLane(value:unknown):Pick<FieldValueNodeRow,'isNull'|'valueText'|'valueNumber'|'valueBoolean'|'valueDateTime'|'valueRef'|'valueJson'>{
  const empty={valueText:null,valueNumber:null,valueBoolean:null,valueDateTime:null,valueRef:null,valueJson:null}
  if(value===null)return {isNull:true,...empty}
  if(typeof value==='string')return {isNull:false,...empty,valueText:value}
  if(typeof value==='number')return {isNull:false,...empty,valueNumber:value}
  if(typeof value==='boolean')return {isNull:false,...empty,valueBoolean:value}
  return {isNull:false,...empty,valueJson:JSON.stringify(serializable(value))}
}

export interface EncodeFieldNodesOptions { entityType:string; entityId:string; definitionRevision:number; nodeIdForPath(path:string):string; cloneResolver?:CloneDefinitionResolver }
export function encodeFieldValueNodes(group:FieldGroup,values:FieldValueMap,options:EncodeFieldNodesOptions):readonly FieldValueNodeRow[]{
  const nodes:FieldValueNodeRow[]=[]
  const seenPaths=new Set<string>()
  const add=(row:FieldValueNodeRow)=>{if(seenPaths.has(row.path))fail(row.path,'resolves to a duplicate materialized path');seenPaths.add(row.path);nodes.push(Object.freeze(row))}
  const structural=(field:AnyFieldDefinition,path:string,parent:string|null,kind:FieldValueNodeRow['nodeKind'],ordinal:number,rowId:string|null=null,layoutKey:string|null=null)=>add({nodeId:options.nodeIdForPath(path),entityType:options.entityType,entityId:options.entityId,groupKey:group.key,definitionRevision:options.definitionRevision,fieldKey:field.key,path,parentNodeId:parent,rowId,layoutKey,nodeKind:kind,ordinal,isNull:false,valueText:null,valueNumber:null,valueBoolean:null,valueDateTime:null,valueRef:null,valueJson:null})
  const walkFields=(fields:readonly AnyFieldDefinition[],map:FieldValueMap,parentPath:string,parentId:string|null)=>{fields.forEach((field,ordinal)=>{if(PRESENTATION_TYPES.has(field.type))return;const value=map[field.key],path=pathField(parentPath,field.key);if(field.type==='group'){structural(field,path,parentId,'group',ordinal);walkFields((field.settings as {fields:readonly AnyFieldDefinition[]}).fields,(value??{}) as FieldValueMap,path,options.nodeIdForPath(path));return}if(field.type==='repeater'){structural(field,path,parentId,'group',ordinal);const baseId=options.nodeIdForPath(path);for(const [rowOrdinal,row] of ((value??[]) as RepeaterRow[]).entries()){const rp=pathRow(path,row.rowId);structural(field,rp,baseId,'repeaterRow',rowOrdinal,row.rowId);walkFields((field.settings as {fields:readonly AnyFieldDefinition[]}).fields,row.values,rp,options.nodeIdForPath(rp))}return}if(field.type==='flexible'){structural(field,path,parentId,'group',ordinal);const baseId=options.nodeIdForPath(path);for(const [rowOrdinal,row] of ((value??[]) as FlexibleRow[]).entries()){const rp=pathLayout(pathRow(path,row.rowId),row.layoutKey),layout=(field.settings as {layouts:readonly FlexibleLayout[]}).layouts.find((item)=>item.key===row.layoutKey);if(!layout)fail(path,'contains unknown layout');structural(field,rp,baseId,'flexibleRow',rowOrdinal,row.rowId,row.layoutKey);walkFields(layout.fields,row.values,rp,options.nodeIdForPath(rp))}return}if(field.type==='clone'){if(!options.cloneResolver)fail(path,'clone resolver is required');structural(field,path,parentId,'group',ordinal);walkFields(options.cloneResolver.resolve(field),value as FieldValueMap,path,options.nodeIdForPath(path));return}const lane=leafLane(value===undefined?null:value);add({nodeId:options.nodeIdForPath(path),entityType:options.entityType,entityId:options.entityId,groupKey:group.key,definitionRevision:options.definitionRevision,fieldKey:field.key,path,parentNodeId:parentId,rowId:null,layoutKey:null,nodeKind:'leaf',ordinal,...lane})})}
  walkFields(group.fields,values,'',null)
  if(nodes.length>1_000)fail('values','exceeds maximum materialized node count 1000')
  return Object.freeze(nodes)
}

function decodeLane(node:FieldValueNodeRow):unknown{
  if(node.nodeKind!=='leaf')fail(node.path,'is not a leaf node')
  const lanes=[node.valueText!==null,node.valueNumber!==null,node.valueBoolean!==null,node.valueDateTime!==null,node.valueRef!==null,node.valueJson!==null].filter(Boolean).length
  if(node.isNull){if(lanes!==0)fail(node.path,'null leaf contains a value lane');return null}
  if(lanes!==1)fail(node.path,'leaf must contain exactly one value lane')
  if(node.valueText!==null)return node.valueText
  if(node.valueNumber!==null)return node.valueNumber
  if(node.valueBoolean!==null)return node.valueBoolean
  if(node.valueDateTime!==null)return node.valueDateTime
  if(node.valueRef!==null){try{return JSON.parse(node.valueRef) as unknown}catch{return node.valueRef}}
  try{return JSON.parse(node.valueJson!) as unknown}catch{fail(node.path,'contains invalid JSON lane')}
}
function assertNodeShape(node:FieldValueNodeRow,group:FieldGroup,revision:number):void{if(node.groupKey!==group.key)fail(node.path,'belongs to another field group');if(node.definitionRevision!==revision)fail(node.path,'mixes immutable definition revisions');if(node.ordinal<0||!Number.isSafeInteger(node.ordinal))fail(node.path,'has invalid ordinal');if(node.nodeKind==='flexibleRow'&&(!node.rowId||!node.layoutKey))fail(node.path,'flexible row requires row/layout identity');if(node.nodeKind==='repeaterRow'&&!node.rowId)fail(node.path,'repeater row requires row identity');if((node.nodeKind==='leaf'||node.nodeKind==='group')&&(node.rowId!==null||node.layoutKey!==null))fail(node.path,'non-row node carries row/layout identity')}
export function isCanonicalFieldValuePath(path:string):boolean{return path.length>0&&/^(?:\/(?:f|r|l):(?:[^~/]|~[01])+)+$/.test(path)}
export interface DecodeFieldNodesOptions { definitionRevision:number; cloneResolver?:CloneDefinitionResolver }
export function decodeFieldValueNodes(group:FieldGroup,nodes:readonly FieldValueNodeRow[],options:DecodeFieldNodesOptions):FieldValueMap{
  const byId=new Map<string,FieldValueNodeRow>(),children=new Map<string|null,FieldValueNodeRow[]>(),paths=new Set<string>()
  for(const node of nodes){assertNodeShape(node,group,options.definitionRevision);if(!isCanonicalFieldValuePath(node.path))fail(node.path,'is not a canonical typed field path');if(byId.has(node.nodeId))fail(node.path,'duplicates node id');if(paths.has(node.path))fail(node.path,'duplicates materialized path');byId.set(node.nodeId,node);paths.add(node.path);const list=children.get(node.parentNodeId)??[];list.push(node);children.set(node.parentNodeId,list)}
  for(const list of children.values()){list.sort((a,b)=>a.ordinal-b.ordinal||a.path.localeCompare(b.path));for(let i=1;i<list.length;i++)if(list[i-1]!.ordinal===list[i]!.ordinal)fail(list[i]!.path,'duplicates sibling ordinal')}
  for(const node of nodes)if(node.parentNodeId!==null&&!byId.has(node.parentNodeId))fail(node.path,'references missing parent')
  const fieldsByKey=(fields:readonly AnyFieldDefinition[])=>new Map(fields.map((field)=>[field.key,field]))
  const readFields=(fields:readonly AnyFieldDefinition[],parentId:string|null,parentPath:string):FieldValueMap=>{
    const out:Record<string,unknown>={},defs=fieldsByKey(fields)
    for(const node of children.get(parentId)??[]){const definition=defs.get(node.fieldKey);if(!definition)fail(node.path,'references a field absent from the pinned definition');const expected=pathField(parentPath,definition.key);if(definition.type==='group'){if(node.nodeKind!=='group'||node.path!==expected)fail(node.path,'has invalid group path/kind');out[definition.key]=readFields((definition.settings as {fields:readonly AnyFieldDefinition[]}).fields,node.nodeId,node.path);continue}
      if(definition.type==='repeater'){if(node.nodeKind!=='group'||node.path!==expected)fail(node.path,'has invalid repeater root');const rows:RepeaterRow[]=[];for(const rowNode of children.get(node.nodeId)??[]){if(rowNode.nodeKind!=='repeaterRow'||!rowNode.rowId||rowNode.path!==pathRow(node.path,rowNode.rowId))fail(rowNode.path,'has invalid repeater row path');rows.push(Object.freeze({rowId:rowNode.rowId,values:readFields((definition.settings as {fields:readonly AnyFieldDefinition[]}).fields,rowNode.nodeId,rowNode.path)}))}out[definition.key]=Object.freeze(rows);continue}
      if(definition.type==='flexible'){if(node.nodeKind!=='group'||node.path!==expected)fail(node.path,'has invalid flexible root');const rows:FlexibleRow[]=[];for(const rowNode of children.get(node.nodeId)??[]){if(rowNode.nodeKind!=='flexibleRow'||!rowNode.rowId||!rowNode.layoutKey||rowNode.path!==pathLayout(pathRow(node.path,rowNode.rowId),rowNode.layoutKey))fail(rowNode.path,'has invalid flexible row path');const layout=(definition.settings as {layouts:readonly FlexibleLayout[]}).layouts.find((item)=>item.key===rowNode.layoutKey);if(!layout)fail(rowNode.path,'references unknown flexible layout');rows.push(Object.freeze({rowId:rowNode.rowId,layoutKey:rowNode.layoutKey,values:readFields(layout.fields,rowNode.nodeId,rowNode.path)}))}out[definition.key]=Object.freeze(rows);continue}
      if(definition.type==='clone'){if(node.nodeKind!=='group'||node.path!==expected)fail(node.path,'has invalid clone root');if(!options.cloneResolver)fail(node.path,'clone resolver is required');out[definition.key]=readFields(options.cloneResolver.resolve(definition),node.nodeId,node.path);continue}
      if(node.nodeKind!=='leaf'||node.path!==expected)fail(node.path,'has invalid leaf path/kind');out[definition.key]=decodeLane(node)}
    return Object.freeze(out)
  }
  const result=readFields(group.fields,null,'')
  const consumed=new Set<string>()
  const mark=(parent:string|null)=>{for(const child of children.get(parent)??[]){consumed.add(child.nodeId);mark(child.nodeId)}};mark(null)
  if(consumed.size!==nodes.length)fail('values','contains unreachable nodes')
  return result
}

export async function authorizeRelationshipReplacement(definition:ResolvedFieldDefinition,before:readonly EntityFieldRef[],after:readonly EntityFieldRef[],authorization:FieldValueAuthorization):Promise<void>{
  if(definition.type!=='relationship')return
  const key=(ref:EntityFieldRef)=>`${ref.entityType}:${ref.id}`,beforeMap=new Map(before.map((ref)=>[key(ref),ref])),afterMap=new Map(after.map((ref)=>[key(ref),ref]))
  for(const ref of after)await authorization.assertTarget(ref,'readTarget')
  if(typeof definition.settings==='object'&&definition.settings!==null&&'bidirectional' in definition.settings&&(definition.settings as {bidirectional?:unknown}).bidirectional!==undefined){for(const [id,ref] of beforeMap)if(!afterMap.has(id))await authorization.assertTarget(ref,'writeReverseTarget');for(const [id,ref] of afterMap)if(!beforeMap.has(id))await authorization.assertTarget(ref,'writeReverseTarget')}
}
