import type { ContentPrincipal } from './authz.js'
import type {
  ContentEntry,
  ContentTemplateDescriptor,
  ContentTemplateKey,
  ContentTemplateRegistry,
  ProtectedContentRead,
} from './model.js'
import type { ContentTemplateBlock, ResolvedContentType } from './registry.js'

const MAX_ROUTE_SEGMENTS = 128
const MAX_PAGE = 1_000_000
const SEGMENT_RE = /^[^/?#]+$/

export type ContentRouteMatch =
  | { kind:'single'; typeKey:string; pathSegments:readonly string[] }
  | { kind:'archive'|'feed'; typeKey:string; page:number }
export type ContentRouteResolution =
  | { kind:'notFound' }
  | { kind:'redirect'; status:308; location:string }
  | { kind:'single'; match:Extract<ContentRouteMatch,{kind:'single'}>; content:ProtectedContentRead }
  | { kind:'archive'|'feed'; match:Extract<ContentRouteMatch,{kind:'archive'|'feed'}> }
export interface CompiledContentRoutes {
  match(url:URL): ContentRouteMatch|null
  canonical(typeKey:string,entryPath?:readonly string[]):{pathname:string;search?:string}
}
export interface ContentPathLookup {
  resolve(typeKey:string,pathSegments:readonly string[],principal:ContentPrincipal,passwordProof?:string):Promise<ProtectedContentRead|null>
  canonicalPath(entryId:string,principal:ContentPrincipal):Promise<readonly string[]|null>
}
export type ContentTemplateResolution = {templateKey:ContentTemplateKey;lockedBlocks?:readonly ContentTemplateBlock[]}
export type ContentRouteErrorCode = 'invalid-route'|'route-conflict'|'unknown-type'|'template-resolution'
export class ContentRouteError extends Error {
  override readonly name='ContentRouteError'
  constructor(readonly code:ContentRouteErrorCode,readonly detail:string,readonly field?:string){super(`content route ${code}: ${detail}${field?` (${field})`:''}`)}
}

interface RouteRecord {
  type:ResolvedContentType
  singleBase:readonly string[]|null
  archiveBase:readonly string[]|null
  queryVariable:string|null
  feeds:boolean
  pages:boolean
}
interface InternalCompiled extends CompiledContentRoutes {
  readonly records:ReadonlyMap<string,RouteRecord>
  pathFor(match:ContentRouteMatch):{pathname:string;search?:string}
}
function cleanSlug(value:string,field:string):readonly string[]{
  if(typeof value!=='string')throw new ContentRouteError('invalid-route','route slug must be a string',field)
  const stripped=value.replace(/^\/+|\/+$/g,'')
  if(!stripped)throw new ContentRouteError('invalid-route','route slug must not be empty',field)
  let segments:string[]
  try{segments=stripped.split('/').map((segment)=>decodeURIComponent(segment))}catch{throw new ContentRouteError('invalid-route','route slug contains malformed percent encoding',field)}
  if(segments.length>MAX_ROUTE_SEGMENTS||segments.some((segment)=>!segment||!SEGMENT_RE.test(segment)||segment==='.'||segment==='..'))throw new ContentRouteError('invalid-route','route slug contains an invalid segment',field)
  return Object.freeze(segments)
}
function pathname(segments:readonly string[]):string{return `/${segments.map((s)=>encodeURIComponent(s)).join('/')}`}
function normalizedPathname(url:URL):string{
  const raw=url.pathname.replace(/\/{2,}/g,'/').replace(/\/$/,'')
  return raw||'/'
}
function decodePath(url:URL):string[]{
  try{return normalizedPathname(url).split('/').filter(Boolean).map(decodeURIComponent)}catch{return []}
}
function startsWith(value:readonly string[],prefix:readonly string[]):boolean{return prefix.every((segment,index)=>value[index]===segment)}
function same(value:readonly string[],other:readonly string[]):boolean{return value.length===other.length&&startsWith(value,other)}
function routeRecord(type:ResolvedContentType):RouteRecord{
  const singleBase=type.rewrite===false?null:cleanSlug(type.rewrite.slug,`${type.key}.rewrite.slug`)
  let archiveBase:readonly string[]|null=null
  if(type.hasArchive===true){
    if(!singleBase)throw new ContentRouteError('invalid-route','hasArchive=true requires rewrite routing',`${type.key}.hasArchive`)
    archiveBase=singleBase
  }else if(typeof type.hasArchive==='string')archiveBase=cleanSlug(type.hasArchive,`${type.key}.hasArchive`)
  const queryVariable=type.queryVariable===false?null:type.queryVariable
  if(queryVariable!==null&&!/^[a-z][a-z0-9_-]{0,63}$/.test(queryVariable))throw new ContentRouteError('invalid-route','query variable must be a stable machine key',`${type.key}.queryVariable`)
  return {type,singleBase,archiveBase,queryVariable,feeds:type.rewrite!==false&&type.rewrite.feeds===true,pages:type.rewrite!==false&&type.rewrite.pages===true}
}
function assertConflicts(records:readonly RouteRecord[]):void{
  const singleRoots=new Map<string,string>(),archives=new Map<string,string>(),queries=new Map<string,string>()
  const staticRoutes=new Map<string,{typeKey:string;kind:'archive'|'feed'}>()
  const claimStatic=(path:string,typeKey:string,kind:'archive'|'feed')=>{
    const previous=staticRoutes.get(path)
    if(previous&&previous.typeKey!==typeKey)throw new ContentRouteError('route-conflict',`${kind} route ${path} for ${typeKey} conflicts with ${previous.kind} route for ${previous.typeKey}`)
    staticRoutes.set(path,{typeKey,kind})
  }
  for(const record of records){
    if(record.singleBase){const key=pathname(record.singleBase);const previous=singleRoots.get(key);if(previous&&previous!==record.type.key)throw new ContentRouteError('route-conflict',`single route ${key} is shared by ${previous} and ${record.type.key}`);singleRoots.set(key,record.type.key)}
    if(record.archiveBase){
      const key=pathname(record.archiveBase);const previous=archives.get(key);if(previous&&previous!==record.type.key)throw new ContentRouteError('route-conflict',`archive route ${key} is shared by ${previous} and ${record.type.key}`);archives.set(key,record.type.key);claimStatic(key,record.type.key,'archive')
      if(record.feeds)claimStatic(pathname([...record.archiveBase,'feed']),record.type.key,'feed')
    }
    if(record.queryVariable){const previous=queries.get(record.queryVariable);if(previous&&previous!==record.type.key)throw new ContentRouteError('route-conflict',`query variable ${record.queryVariable} is shared by ${previous} and ${record.type.key}`);queries.set(record.queryVariable,record.type.key)}
  }
  for(const [path,typeKey] of archives){const other=singleRoots.get(path);if(other&&other!==typeKey)throw new ContentRouteError('route-conflict',`archive ${path} for ${typeKey} conflicts with single route for ${other}`)}
}
function bySpecificBase(field:'singleBase'|'archiveBase'):(left:RouteRecord,right:RouteRecord)=>number{
  return (left,right)=>{
    const a=left[field]!,b=right[field]!
    return b.length-a.length||pathname(a).localeCompare(pathname(b))||left.type.key.localeCompare(right.type.key)
  }
}
function pageNumber(segment:string|undefined):number|null{
  if(segment===undefined||!/^[1-9][0-9]*$/.test(segment))return null
  const value=Number(segment)
  return Number.isSafeInteger(value)&&value>=1&&value<=MAX_PAGE?value:null
}

export function compileContentRoutes(types:readonly ResolvedContentType[]):CompiledContentRoutes{
  const keys=new Set<string>(),records=types.filter((type)=>type.publiclyQueryable).map((type)=>{
    if(keys.has(type.key))throw new ContentRouteError('route-conflict',`duplicate resolved type ${type.key}`)
    keys.add(type.key);return routeRecord(type)
  })
  assertConflicts(records)
  const byKey=new Map(records.map((record)=>[record.type.key,record]))
  const queryRecords=[...records].filter((record)=>record.queryVariable).sort((a,b)=>a.type.key.localeCompare(b.type.key))
  const archiveRecords=[...records].filter((record)=>record.archiveBase).sort(bySpecificBase('archiveBase'))
  const singleRecords=[...records].filter((record)=>record.singleBase).sort(bySpecificBase('singleBase'))
  const compiled:InternalCompiled={
    records:byKey,
    match(url){
      // Explicit query-variable routing is considered before path singles.
      for(const record of queryRecords){
        if(!record.queryVariable)continue
        const raw=url.searchParams.get(record.queryVariable)
        if(raw!==null&&normalizedPathname(url)==='/'){
          let pathSegments:string[]
          try{pathSegments=raw.split('/').filter(Boolean).map((segment)=>decodeURIComponent(segment))}catch{continue}
          if(pathSegments.length>0&&pathSegments.length<=MAX_ROUTE_SEGMENTS&&pathSegments.every((segment)=>SEGMENT_RE.test(segment)))return Object.freeze({kind:'single',typeKey:record.type.key,pathSegments:Object.freeze(pathSegments)})
        }
      }
      const segments=decodePath(url)
      // Static archive/feed/page routes outrank data-backed single paths. A malformed reserved
      // suffix blocks the owning single route, but not a genuinely more-specific registered root.
      let blockedSingleBaseLength=-1
      for(const record of archiveRecords){
        if(!startsWith(segments,record.archiveBase!))continue
        const rest=segments.slice(record.archiveBase!.length)
        if(rest.length===0)return Object.freeze({kind:'archive',typeKey:record.type.key,page:1})
        if(rest[0]==='page'&&record.pages){
          if(rest.length===2){const page=pageNumber(rest[1]);if(page!==null)return Object.freeze({kind:'archive',typeKey:record.type.key,page})}
          blockedSingleBaseLength=Math.max(blockedSingleBaseLength,record.archiveBase!.length)
          continue
        }
        if(rest[0]==='feed'&&record.feeds){
          if(rest.length===1)return Object.freeze({kind:'feed',typeKey:record.type.key,page:1})
          if(record.pages&&rest.length===3&&rest[1]==='page'){const page=pageNumber(rest[2]);if(page!==null)return Object.freeze({kind:'feed',typeKey:record.type.key,page})}
          blockedSingleBaseLength=Math.max(blockedSingleBaseLength,record.archiveBase!.length)
        }
      }
      for(const record of singleRecords){
        if(record.singleBase!.length<=blockedSingleBaseLength||!startsWith(segments,record.singleBase!))continue
        const pathSegments=segments.slice(record.singleBase!.length)
        if(pathSegments.length===0||pathSegments.length>MAX_ROUTE_SEGMENTS)continue
        if(!record.type.hierarchical&&pathSegments.length!==1)continue
        return Object.freeze({kind:'single',typeKey:record.type.key,pathSegments:Object.freeze(pathSegments)})
      }
      return null
    },
    canonical(typeKey,entryPath){
      const record=byKey.get(typeKey);if(!record)throw new ContentRouteError('unknown-type',`unknown routable type ${typeKey}`)
      if(entryPath!==undefined){
        if(entryPath.length===0||entryPath.length>MAX_ROUTE_SEGMENTS||entryPath.some((segment)=>typeof segment!=='string'||!SEGMENT_RE.test(segment)||!segment))throw new ContentRouteError('invalid-route','entry path is invalid','entryPath')
        if(!record.type.hierarchical&&entryPath.length!==1)throw new ContentRouteError('invalid-route','flat types require exactly one entry path segment','entryPath')
        if(record.singleBase)return {pathname:pathname([...record.singleBase,...entryPath])}
        if(record.queryVariable)return {pathname:'/',search:`?${encodeURIComponent(record.queryVariable)}=${encodeURIComponent(entryPath.join('/'))}`}
        throw new ContentRouteError('invalid-route',`type ${typeKey} has no single route`)
      }
      if(!record.archiveBase)throw new ContentRouteError('invalid-route',`type ${typeKey} has no archive route`)
      return {pathname:pathname(record.archiveBase)}
    },
    pathFor(match){
      const record=byKey.get(match.typeKey);if(!record)throw new ContentRouteError('unknown-type',`unknown routable type ${match.typeKey}`)
      if(match.kind==='single')return compiled.canonical(match.typeKey,match.pathSegments)
      if(!record.archiveBase)throw new ContentRouteError('invalid-route',`type ${match.typeKey} has no archive route`)
      const base=[...record.archiveBase]
      if(match.kind==='feed')base.push('feed')
      if(match.page>1)base.push('page',String(match.page))
      return {pathname:pathname(base)}
    },
  }
  return Object.freeze(compiled)
}

export async function resolveContentRoute(compiled:CompiledContentRoutes,lookup:ContentPathLookup,url:URL,principal:ContentPrincipal,passwordProof?:string):Promise<ContentRouteResolution>{
  const match=compiled.match(url);if(!match)return {kind:'notFound'}
  const internal=compiled as InternalCompiled
  const expected=internal.pathFor(match)
  const actualPath=normalizedPathname(url)
  if(match.kind!=='single'){
    if(actualPath!==expected.pathname)return {kind:'redirect',status:308,location:expected.pathname}
    return Object.freeze({kind:match.kind,match})
  }
  const content=await lookup.resolve(match.typeKey,match.pathSegments,principal,passwordProof)
  if(!content)return {kind:'notFound'}
  const entryId=content.entry.id
  const canonicalPath=await lookup.canonicalPath(entryId,principal)
  if(!canonicalPath)return {kind:'notFound'}
  const canonical=compiled.canonical(match.typeKey,canonicalPath)
  const queryCanonical=canonical.search??''
  if(actualPath!==canonical.pathname||(queryCanonical&&url.search!==queryCanonical))return {kind:'redirect',status:308,location:`${canonical.pathname}${queryCanonical}`}
  return Object.freeze({kind:'single',match,content})
}

function templateDescriptor(registry:ContentTemplateRegistry,type:ResolvedContentType,key:string):ContentTemplateDescriptor{
  const descriptor=registry.resolve(type.key,key)
  if(!descriptor)throw new ContentRouteError('template-resolution',`template ${key} is missing for type ${type.key}`,'templateKey')
  if(descriptor.active===false)throw new ContentRouteError('template-resolution',`template ${key} is inactive`,'templateKey')
  if(!descriptor.typeKeys.includes(type.key))throw new ContentRouteError('template-resolution',`template ${key} is not assigned to type ${type.key}`,'templateKey')
  return descriptor
}
export function resolveContentTemplate(registry:ContentTemplateRegistry,type:ResolvedContentType,match:ContentRouteMatch,entry?:ContentEntry):ContentTemplateResolution{
  if(match.typeKey!==type.key)throw new ContentRouteError('template-resolution','route type does not match resolved type','typeKey')
  if(entry&&entry.type!==type.key)throw new ContentRouteError('template-resolution','entry type does not match resolved route type','entry.type')
  if(entry&&(entry.typeDefinitionRevision!==type.revision))throw new ContentRouteError('template-resolution','entry references a stale type definition','entry.typeDefinitionRevision')
  const key=match.kind==='single'&&entry?.templateKey?entry.templateKey:type.defaultTemplateKey
  templateDescriptor(registry,type,key)
  const lockedBlocks=type.templateLock&&type.template?type.template:undefined
  return Object.freeze({templateKey:key,...(lockedBlocks?{lockedBlocks}: {})})
}
