/**
 * React renderer for declarative slots. Per-entry bindings enforce child
 * authorization, and entry boundaries contain registrant failures.
 */
import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
  SlotOwnershipError, StaleAuthorizationError,
  type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
  type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
  type SlotScope, type StoredEntry, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
  HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
  observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
} from './session-provider.tsx'

type InjectedProps = Record<string, unknown>

type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode

type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode

/**
 * Per-entry renderSlot bindings. The binding is identity-stable per entry
 * (memoized components must not resubscribe on unrelated re-renders) and dies
 * with the entry: a retained closure calling after the entry's disposal hits
 * the in-ledger check and throws.
 */
const renderSlotCache = new WeakMap<StoredEntry, RenderSlotBinding>()

function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlotBinding {
  let binding = renderSlotCache.get(entry)
  if (!binding) {
    binding = (key, owner, opts) => {
      if (!host.isLive(entry)) {
        throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`)
      }
      // Plain-JS backstop; typed callers are narrowed to the declared keys.
      const declared = entry.children?.[key]
      if (declared === undefined) {
        throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
      }
      if (declared.kind === 'chain') {
        throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
      }
      return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
    }
    renderSlotCache.set(entry, binding)
  }
  return binding
}

/**
 * Per-entry renderSlotChain bindings: identity-stable per entry (same cache
 * axis as renderSlot — a per-frame dispatch must not rebuild the binding) and
 * dead with the entry. The chain-kind check is the plain-JS backstop twin of
 * the declaration check; typed callers are narrowed to chain keys.
 */
const renderSlotChainCache = new WeakMap<StoredEntry, RenderSlotChainBinding>()

function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): RenderSlotChainBinding {
  let binding = renderSlotChainCache.get(entry)
  if (!binding) {
    binding = (key, owner, opts) => {
      if (!host.isLive(entry)) {
        throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed registration`)
      }
      const declared = entry.children?.[key]
      if (declared === undefined) {
        throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
      }
      if (declared.kind !== 'chain') {
        throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`)
      }
      return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
    }
    renderSlotChainCache.set(entry, binding)
  }
  return binding
}

/**
 * Inject results cache: root entries per entry, session entries per
 * (entry x provide bundle). WeakMap keys are entry/info objects (both
 * identity-stable per registration/session scope), so cache lifetime rides
 * the same axes as the values it memoizes.
 */
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionProvideInfo, InjectedProps>>()
const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()

function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps {
  const inject = entry.inject
  if (!inject) return {}
  // Declaration-derived positional arguments: sessionId for session scope,
  // baked actions when a store is declared.
  const args: unknown[] = []
  if (info !== undefined) args.push(info.sessionId)
  if (actions !== undefined) args.push(actions)
  return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
}

/**
 * Bind an inject face's reserved `hooks` compartment (bare observable
 * sources, see HooksSources) into `use<Name>` selector hooks — the
 * registrant-private twin of the provide-bundle binding in standardKit.
 * Runs once per cached inject result; hook identity rides observableHook's
 * per-source cache.
 */
function bindInjectHooks(face: InjectedProps): InjectedProps {
  const sources = face['hooks']
  if (sources === undefined) return face
  const { hooks: _hooks, ...rest } = face
  const bound: InjectedProps = rest
  for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
    bound[hookName] = observableHook(source)
  }
  return bound
}

function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
  let props = rootInjectCache.get(entry)
  if (!props) {
    props = runInject(entry, undefined, actions)
    rootInjectCache.set(entry, props)
  }
  return props
}

function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps {
  let perInfo = sessionInjectCache.get(entry)
  if (!perInfo) {
    perInfo = new WeakMap()
    sessionInjectCache.set(entry, perInfo)
  }
  let props = perInfo.get(info)
  if (!props) {
    props = runInject(entry, info, actions)
    perInfo.set(info, props)
  }
  return props
}

function cachedSessionMaybeInject(
  entry: StoredEntry,
  info: SessionMaybeProvideInfo,
  actions: object | undefined,
): InjectedProps {
  let perInfo = sessionMaybeInjectCache.get(entry)
  if (!perInfo) {
    perInfo = new WeakMap()
    sessionMaybeInjectCache.set(entry, perInfo)
  }
  let props = perInfo.get(info)
  if (!props) {
    props = runInject(entry, info, actions)
    perInfo.set(info, props)
  }
  return props
}

/**
 * Locale `t` seat bindings, cached per (face, namespace, revision). The
 * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
 * function reference per namespace, so `React.memo` components taking `t`
 * re-render through ordinary shallow comparison — freshness rides identity,
 * no extra invalidation channel. Within one revision the reference is stable
 * (memoized children do not churn on unrelated re-renders).
 */
const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()

function localeSeat(face: LocaleFace, ns: string): Translate {
  let perNs = localeSeatCache.get(face)
  if (!perNs) {
    perNs = new Map()
    localeSeatCache.set(face, perNs)
  }
  const revision = face.getSnapshot().revision
  const cached = perNs.get(ns)
  if (cached && cached.revision === revision) return cached.t
  const bound = face.bind(ns)
  // Fresh wrapper per revision: bind() itself may return a stable reference.
  const t: Translate = (key, params) => bound(key, params)
  perNs.set(ns, { revision, t })
  return t
}

const noopSubscribe = (): (() => void) => () => {}
const zeroRevision = (): number => 0

/**
 * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
 * face is one global source shared by every outlet, and uSES resubscribes
 * whenever the subscribe reference changes — fresh closures per render would
 * churn one unsubscribe/resubscribe pair per outlet per render.
 */
const localeSubscriptionCache = new WeakMap<LocaleFace, {
  subscribe: (fn: () => void) => () => void
  getRevision: () => number
}>()

function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
  let cached = localeSubscriptionCache.get(face)
  if (!cached) {
    cached = {
      subscribe: fn => face.subscribe(fn),
      getRevision: () => face.getSnapshot().revision,
    }
    localeSubscriptionCache.set(face, cached)
  }
  return cached
}

/**
 * Subscribe an outlet to the installed locale face's revision (0 while none
 * is installed — exactly one uSES call either way, keeping hook order
 * stable). Every outlet re-renders on a locale switch; entry bodies then
 * re-derive their `t` seat at the new revision. The face must be installed
 * before the first render that needs it — a face appearing later has no
 * notification channel to already-mounted outlets.
 */
function useLocaleRevision(face: LocaleFace | undefined): number {
  const subscription = face !== undefined ? localeSubscription(face) : undefined
  return useSyncExternalStore(
    subscription?.subscribe ?? noopSubscribe,
    subscription?.getRevision ?? zeroRevision,
  )
}

/**
 * Entry-identity React keys for chain boundaries. A chain outlet renders ONE
 * elected entry through an error boundary; without a key, a boundary that
 * failed on entry A would survive a re-election and keep a healthy entry B
 * blacked out. Keying by entry identity remounts the boundary fresh whenever
 * the election changes (entries are identity-stable per registration, so the
 * key is stable while the same entry stays elected).
 */
let nextEntryKey = 0
const entryKeys = new WeakMap<StoredEntry, number>()

function entryKeyOf(entry: StoredEntry): number {
  let key = entryKeys.get(entry)
  if (key === undefined) {
    key = nextEntryKey++
    entryKeys.set(entry, key)
  }
  return key
}

/**
 * Per-entry isolation: one registrant crashing (component render or inject
 * factory) must not take down siblings. Assembly errors (missing providers)
 * rethrow — a miswired shell must fail loud, not degrade into fallbacks.
 */
class SlotErrorBoundary extends Component<
  { slotKey: string; children: ReactNode }, { failed: boolean }
> {
  override state = { failed: false }
  static getDerivedStateFromError(error: unknown): { failed: boolean } {
    if (error instanceof SlotAssemblyError) throw error
    return { failed: true }
  }
  override componentDidCatch(error: unknown): void {
    console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
  }
  override render(): ReactNode {
    if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
    return this.props.children
  }
}

/**
 * Standard-kit synthesis shared by both scope branches: the global
 * useSessions/useWorkspaces hooks, the per-session provide bundle (every
 * `hooks` source becomes a `use<Name>` selector hook — useSession is the
 * runtime's own 'session' contribution, no special case — and `props` spread
 * verbatim), the store pair when declared, the renderSlot binding when
 * children are declared, and the SessionProvider seat when the children
 * declare a session-scope slot. Hosts hand out BARE observable sources
 * (hooks never cross the host contract); every hook is bound HERE, cached
 * per source (observableHook), so spreading a fresh kit object per render
 * never churns child subscriptions.
 */
function standardKit(
  host: SlotRendererHost,
  entry: StoredEntry,
  scope: SlotScope,
  info: SessionMaybeProvideInfo | undefined,
): {
  kit: InjectedProps
  actions: object | undefined
} {
  const kit: InjectedProps = {
    useSessions: observableHook(host.sessions.list),
    useWorkspaces: observableHook(host.workspaces.list),
  }
  if (scope !== 'root' && info !== undefined) {
    for (const [name, source] of Object.entries(info.hooks)) {
      const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
      if (scope === 'session-maybe') {
        kit[hookName] = maybeObservableHook(source)
      } else {
        if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`)
        kit[hookName] = observableHook(source)
      }
    }
    Object.assign(kit, info.props)
    kit['sessionId'] = info.sessionId
    // The useProjection seat (fifth framework hook): key-addressed cell
    // reader, bound per provide bundle (cached by info identity).
    kit['useProjection'] = projectionHook(info)
  }
  if (entry.locale !== undefined) {
    const face = host.locale
    // Loud assembly failure: locale is immediately-tier infrastructure; a
    // declared namespace with no installed face is a miswired composition.
    if (face === undefined) {
      throw new SlotAssemblyError(
        `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
    }
    kit['t'] = localeSeat(face, entry.locale)
  }
  const store = scope === 'session-maybe' && info?.sessionId === undefined
    ? undefined
    : host.storeOf(entry, info?.sessionId)
  if (store !== undefined) {
    // The instance IS an observable snapshot source (contract getSnapshot/
    // subscribe); the useStore hook binds here, cached per instance.
    kit['useStore'] = observableHook(store)
    kit['actions'] = store.actions
  }
  if (entry.children !== undefined) {
    kit['renderSlot'] = boundRenderSlot(host, entry)
    // renderSlotChain rides the same declaration source: only entries whose
    // children include a chain-kind slot receive the chain dispatch seat.
    if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
      kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
    }
    // SessionProvider standard seat: entries declaring a session-scope child
    // render the session area, so the framework hands them the self-wired
    // provider (module-level component = stable reference; no value import).
    if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
      kit['SessionProvider'] = SessionProvider
    }
  }
  return { kit, actions: store?.actions }
}

/**
 * One rendered entry: standard kit + cached inject + owner props (owner
 * wins). The kit and injected shares are erased at the render boundary — the
 * register seam already proved the composed contract — so each Entry renders
 * through a props-widened view of the component (the design-budgeted
 * composition point, one per scope branch).
 */
function SessionEntry({ entry, ownerProps, info }: {
  entry: StoredEntry
  ownerProps: object
  info: SessionProvideInfo
}) {
  const host = useHost()
  const Comp = entry.component as FC<InjectedProps>
  const { kit, actions } = standardKit(host, entry, 'session', info)
  const injected = cachedSessionInject(entry, info, actions)
  return <Comp {...kit} {...injected} {...ownerProps} />
}

function SessionMaybeEntryBody({ entry, ownerProps, info }: {
  entry: StoredEntry
  ownerProps: object
  info: SessionMaybeProvideInfo
}) {
  const host = useHost()
  const Comp = entry.component as FC<InjectedProps>
  const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
  const injected = cachedSessionMaybeInject(entry, info, actions)
  return <Comp {...kit} {...injected} {...ownerProps} />
}

/**
 * Session-maybe identity: adoption — the ONLY behavior (there is no
 * hold-identity-forever mode). An incarnation born session-less ADOPTS the
 * first session that arrives: identity holds across that one transition
 * (undefined → first id), so a blank shell's DOM survives the moment a
 * session appears. From then on the entry behaves exactly like a strict
 * session entry: switching to a DIFFERENT session remounts (component-local
 * state must not leak between sessions), and dropping back to no-session
 * remounts into a fresh blank incarnation, which will adopt again.
 * Component-local per-session state therefore clears by construction; state
 * that must SURVIVE a switch belongs in session-bound sources (machine,
 * store, hooks) — the existing layering rule, now load-bearing.
 */
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
  const info = useSessionMaybeProvideInfo()
  // The child key is an incarnation counter, NOT the session id: adoption
  // must keep the key constant across undefined → first id. Bookkeeping
  // lives in this stable (unkeyed) wrapper via the render-phase setState
  // form (React's sanctioned derived-state pattern: setState during render
  // of the same component re-renders once before children mount, and the
  // guard conditions make it convergent — StrictMode-safe).
  const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
  let { adopted, epoch } = state
  if (info.sessionId !== undefined && adopted === undefined) {
    // Adoption: same epoch — no remount.
    adopted = info.sessionId
    setState({ adopted, epoch })
  } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
    // Post-adoption session switch: next incarnation, born already adopted.
    adopted = info.sessionId
    epoch += 1
    setState({ adopted, epoch })
  } else if (adopted !== undefined && info.sessionId === undefined) {
    // Back to no-session: next incarnation, born blank (adopts anew later).
    adopted = undefined
    epoch += 1
    setState({ adopted, epoch })
  }
  return <SessionMaybeEntryBody key={epoch} entry={entry} ownerProps={ownerProps} info={info} />
}

/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
interface MaybeIncarnation {
  /** Session this incarnation adopted; undefined while born blank and unadopted. */
  readonly adopted: string | undefined
  /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
  readonly epoch: number
}

const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }

function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
  const host = useHost()
  const Comp = entry.component as FC<InjectedProps>
  const { kit, actions } = standardKit(host, entry, 'root', undefined)
  const injected = cachedRootInject(entry, actions)
  return <Comp {...kit} {...injected} {...ownerProps} />
}

function StrictSessionEntry({ slotKey, entry, ownerProps }: {
  slotKey: string
  entry: StoredEntry
  ownerProps: object
}) {
  const info = useSessionMaybeProvideInfo()
  if (info.sessionId === undefined) return null
  return (
    <SlotErrorBoundary slotKey={slotKey} key={info.sessionId}>
      <SessionEntry entry={entry} ownerProps={ownerProps} info={info as SessionProvideInfo} />
    </SlotErrorBoundary>
  )
}

function SlotOutlet({ slotKey, ownerProps, opts }: {
  slotKey: string
  ownerProps: object
  opts?: (RenderOpts & ChainRenderOpts) | undefined
}) {
  const host = useHost()
  // Version tick drives entries() re-read; the host batches per microtask.
  useSyncExternalStore(
    fn => host.subscribe(slotKey, fn),
    () => host.getVersion(slotKey),
  )
  // Locale revision tick: a locale switch re-renders every outlet, and entry
  // bodies re-derive their `t` seat at the new revision (fresh identity).
  useLocaleRevision(host.locale)
  const sessionInfo = useSessionMaybeProvideInfo()
  const spec = host.specOf(slotKey)
  // Undeclared (or no-longer-declared) keys render empty: a declaring entry's
  // unload returns the slot to the undeclared state while retained elements
  // may still be mounted — natural empty, not an ownership failure (§9).
  if (!spec) return null
  const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined
  if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) {
    return <>{opts?.fallback ?? null}</>
  }
  // An absent strict overlay chain follows its ordinary empty-election path,
  // preserving the Fragment/fallback-wrapper shape across session arrival.
  const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey)

  // The boundary must wrap the Entry ELEMENT, not live inside it: inject
  // factories and kit synthesis run in the Entry body and must land in the
  // per-entry fallback rather than escaping to the tree above.
  const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => (
    spec.scope === 'session'
      ? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
      : (
        <SlotErrorBoundary slotKey={slotKey} key={key}>
          {spec.scope === 'session-maybe'
            ? <SessionMaybeEntry entry={entry} ownerProps={owner} />
            : <RootEntry entry={entry} ownerProps={owner} />}
        </SlotErrorBoundary>
      )
  )

  if (spec.kind === 'single') {
    const entry = entries[0]
    if (!entry) return <>{opts?.fallback ?? null}</>
    return guarded(entry)
  }
  if (spec.kind === 'keyed') {
    const entry = entries.find(e => e.options.key === opts?.entryKey)
    if (!entry) return <>{opts?.fallback ?? null}</>
    return guarded(entry)
  }
  if (spec.kind === 'chain') {
    // Entries arrive priority-sorted from the ledger (the core orders at
    // register, ties keep registration sequence). Selectors are pure
    // functions of the owner props (register-face contract), so the routing
    // pass runs per render with zero mount side effects: the first non-null
    // election renders, decliners never mount.
    let elected: ReactNode = null
    for (const entry of entries) {
      let matched: unknown
      try {
        // Chain entries always carry select (SlotCore register validation).
        matched = (entry.select as (owner: object) => unknown)(ownerProps)
      } catch (error) {
        // A throwing selector is a registrant contract breach (select MUST be
        // pure and total), but it runs before the entry's SlotErrorBoundary
        // exists — uncontained it would black out the whole owner region. So
        // it degrades to a decline: the chain and the fallback stay intact,
        // and the breach is reported like a crashed entry.
        console.error(
          `chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`,
          error)
        continue
      }
      if (matched !== null) {
        elected = guarded(entry, entryKeyOf(entry), { ...ownerProps, matched })
        break
      }
    }
    if (opts?.overlay) {
      // Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted
      // through elections — hidden via inline display:none (decisive over any
      // author CSS), shown via display:contents so the wrapper never affects
      // the owner's layout. The wrapper's tree position is constant, so React
      // reconciles instead of remounting and fallback state survives takeover.
      return (
        <>
          <div
            data-chain-overlay-fallback={slotKey}
            style={{ display: elected === null ? 'contents' : 'none' }}
          >
            {opts.fallback ?? null}
          </div>
          {elected}
        </>
      )
    }
    return elected ?? <>{opts?.fallback ?? null}</>
  }
  // list: registration order refined by explicit order, optional id filter.
  const withListOptions = entries.map(entry => ({
    entry,
    id: entry.options.id,
    order: entry.options.order ?? 0,
  }))
  let list = [...withListOptions].sort((a, b) => a.order - b.order)
  if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
  if (list.length === 0) return <>{opts?.fallback ?? null}</>
  return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
}

/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */
function RootOutlet({ ownerProps }: { ownerProps: object }) {
  const host = useHost()
  useSyncExternalStore(
    fn => host.subscribe('root', fn),
    () => host.getVersion('root'),
  )
  useLocaleRevision(host.locale)
  const entry = host.entriesOf('root')[0]
  if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
  return (
    <SlotErrorBoundary slotKey="root">
      <RootEntry entry={entry} ownerProps={ownerProps} />
    </SlotErrorBoundary>
  )
}

/**
 * Build the renderer the shell installs into the runtime SlotsService
 * (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
 * install/renderSlot seam and the double-install/not-installed throws).
 * @returns the renderer.
 */
export function createSlotRenderer(): SlotRenderer {
  return {
    renderRoot(host, ownerProps) {
      return (
        <HostContext.Provider value={host}>
          <SessionMaybeProvider>
            <RootOutlet ownerProps={ownerProps} />
          </SessionMaybeProvider>
        </HostContext.Provider>
      )
    },
  }
}
