/**
 * The registration boundary between sandboxed mount code and the real runtime:
 * SchemaSpec normalization + validation with teaching errors, the
 * marker-guarded `harness.defineTool` / `harness.registerTool` pair, the
 * SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives in place of the
 * real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
 * return values with.
 *
 * The façade is a WHITELIST, not a pass-through proxy. Mount code needs to do
 * exactly four things — register a tool, listen to an event, provide a service,
 * call an injected service (timers included) — so the façade exposes only those
 * verbs and the injected services, each object-valued service individually
 * wrapped (a primitive provided value passes through as-is — see
 * {@link sandboxContext}). Every framework plumbing member (`root`, `parent`, `scope`, `fiber`, `reflect`, `registry`,
 * `events`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) is
 * DENIED with a teaching error rather than passed through. This closes an
 * entire escape class at once: a pass-through proxy that only special-cased
 * `ctx.tools` still handed back the raw context through `ctx.root`,
 * `ctx.extend()`, or a service instance's `.ctx`, and mount code could then
 * `ctx.root.tools.register({…})` to bypass the marker check and host-realm
 * normalization — a raw vm-realm result then errors a real agent turn at the
 * session-log plainness check. The whitelist has no such hole: there is no
 * context-valued member to reach, and any injected-service method that returns
 * a `Context` is rejected (harness services never do — see {@link denyContext}).
 *
 * Two realm facts drive the tool path. Objects built inside the vm carry the vm
 * realm's `Object.prototype`, and the session log's append-time plainness check
 * (`dsh-session`'s `isJsonValue`, a prototype-identity comparison) rejects
 * foreign-realm data — so every dynamic tool's `execute` return is JSON
 * round-tripped into the host realm and shape-checked against the two
 * `ToolExecuteReturn` forms before it reaches the registry (the registry
 * trusts the shape blindly — it spreads `result.content`, so an unvalidated
 * `{ content: 'ok' }` would enter the session log as `['o','k']` and silently
 * corrupt the next model request), and the schema itself is rebuilt as fresh
 * host-realm objects. And a malformed tool
 * schema must fail at REGISTRATION, not when a later request assembles it — so
 * dynamic tool registration accepts only definitions produced by the sandbox's
 * `harness.defineTool`, which normalizes `parameters` up front.
 *
 * Normalize, don't lecture, where the input has exactly one meaning: models
 * write the JSON-Schema dialect by strong prior (the `{ type: 'object',
 * properties, required: […] }` wrapper, `type: 'integer'`, `required: false`),
 * and each rejection costs a model turn — so those convert to the SchemaSpec
 * DSL silently, and only genuinely meaningless input (an unknown type, a
 * non-boolean `required`) is rejected, with the error enumerating the valid
 * vocabulary.
 *
 * @module @deepseek-ai/dsh-tool-cordis/guard
 */

import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'

const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''

type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }

function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return Object.prototype.toString.call(value) === '[object Object]'
}

/**
 * Normalize a sandbox-provided `parameters` value into a fresh host-realm
 * SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
 * `{ type: 'object', properties, required: […] }` wrapper models write by
 * prior — the wrapper unwraps and its `required` array becomes per-property
 * flags (see the module doc).
 */
function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
  if (!isPlainRecord(value)) {
    throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
  }
  let entries = value
  const requiredNames = new Set<unknown>()
  if (value.type === 'object' && isPlainRecord(value.properties)) {
    if (Array.isArray(value.required)) {
      for (const name of value.required) requiredNames.add(name)
    }
    entries = value.properties
  }
  const spec: Record<string, unknown> = {}
  for (const [key, prop] of Object.entries(entries)) {
    spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
  }
  return spec
}

/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
  if (!isPlainRecord(value)) {
    throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
  }
  const type = value.type === 'integer' ? 'number' : value.type
  if (!SCHEMA_TYPES.has(type)) {
    throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
  }
  // On an object property a JSON-Schema-style `required` ARRAY names required
  // children (handled by the nested unwrap below); everywhere else `required`
  // must be a boolean, and `false` simply reads as optional.
  const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
  if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
    throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
  }
  const prop: Record<string, unknown> = { type }
  if (forceRequired || value.required === true) prop.required = true
  if (typeof value.description === 'string') prop.description = value.description
  if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
  if (value.default !== undefined) prop.default = value.default
  if (value.properties !== undefined) {
    if (type !== 'object') {
      throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
    }
    // Re-wrap so the nested unwrap applies a nested `required` array too.
    prop.properties = normalizeSchemaSpec(
      { type: 'object', properties: value.properties, required: value.required },
      `${path}.properties`,
    )
  }
  if (value.items !== undefined) {
    if (type !== 'array') {
      throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
    }
    prop.items = normalizeSchemaProp(value.items, `${path}.items`)
  }
  return prop
}

function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
  Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
  return tool as DynamicToolDefinition
}

function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
  if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
    throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
  }
}

/**
 * Structurally a content block, checked AFTER the JSON round-trip: a plain
 * object carrying a string `type` tag. Deliberately nothing deeper — the
 * ContentBlock union is merge-extensible (an unknown tag must pass), and every
 * downstream consumer dispatches on `type` and falls through unknowns.
 */
function isContentBlockShape(value: unknown): boolean {
  return isPlainRecord(value) && typeof value.type === 'string'
}

/**
 * How much of an invalid execute return the teaching error echoes back — a
 * huge blob would burn the model turn the error is trying to save.
 */
const RETURN_PREVIEW_LIMIT = 120

/**
 * Compact JSON preview of an invalid execute return for the teaching error
 * (`String(…)` for the un-stringifiable undefined case), truncated to
 * {@link RETURN_PREVIEW_LIMIT}.
 */
function describeReturn(value: unknown): string {
  // JSON.stringify is TYPED as always returning string, but it yields
  // undefined for an undefined input (the routed forgot-return case) — the
  // assertion widens the type back to the runtime truth.
  const json = JSON.stringify(value) as string | undefined
  if (json === undefined) return String(value)
  return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
}

/**
 * Validate a round-tripped `execute` return against the two shapes
 * {@link ToolExecuteReturn} allows: an ARRAY of content blocks, or
 * `{ content: blocks, meta? }`. The registry trusts the shape blindly — it
 * spreads `result.content`, so an unvalidated `{ content: 'ok' }` would enter
 * the session log as `['o','k']` and silently corrupt the next model request —
 * so a wrong shape fails THIS call with a teaching error instead.
 */
function assertExecuteReturn(value: unknown): ToolExecuteReturn {
  if (Array.isArray(value) && value.every(isContentBlockShape)) {
    return value as ToolExecuteReturn
  }
  if (isPlainRecord(value) && Array.isArray(value.content) && value.content.every(isContentBlockShape)) {
    return value as ToolExecuteReturn
  }
  throw new Error(
    `execute returned ${describeReturn(value)} — a tool's execute must return an ARRAY of content blocks, never a bare string:\n`
    + '  ✓ return [{ type: \'text\', text: someString }]\n'
    + '  ✓ return { content: [{ type: \'text\', text: someString }], meta: anyJsonValue }',
  )
}

/**
 * The `harness.defineTool` handed into the sandbox: the real DSL, with
 * `parameters` normalized into a fresh host-realm SchemaSpec (JSON-Schema
 * wrapper unwrapped, `integer` mapped, `required: false` dropped) and the
 * tool's `execute` return normalized into the host realm via a JSON round-trip
 * (see the module doc). The round-trip projects the return onto exactly what
 * the log would durably store, and {@link assertExecuteReturn} then vets that
 * projection — so a non-JSON-serializable OR wrong-shape return surfaces as
 * that one call's teaching error instead of poisoning the turn.
 * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
 * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
 */
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
  const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
  const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
  const execute = tool.execute.bind(tool)
  return markDynamicTool({
    ...tool,
    async execute(args, exec) {
      // JSON.stringify yields NO JSON for an undefined (or function/symbol)
      // return despite its string-typed signature — route that into
      // assertExecuteReturn's teaching error rather than letting JSON.parse
      // throw its cryptic '"undefined" is not valid JSON'.
      const json = JSON.stringify(await execute(args, exec)) as string | undefined
      return assertExecuteReturn(json === undefined ? undefined : JSON.parse(json) as unknown)
    },
  })
}

/**
 * The `harness.registerTool` handed into the sandbox: registers a
 * marker-verified dynamic tool on the given context's registry.
 * @param ctx - the (guarded) context whose `tools` service receives the tool.
 * @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
 * @returns the registry disposer for the registration.
 */
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
  assertDynamicTool(tool)
  return ctx.tools.register(tool)
}

/**
 * The verbs a mounted plugin may reach through the sandbox `ctx` façade,
 * beyond its injected services. `on`/`once` observe events, `provide` exposes
 * a service to other mounts, and the timer helpers schedule work — each a
 * fiber effect that unwinds on unmount. Everything else on a real cordis `ctx`
 * is framework plumbing and is denied. Forwarded LAZILY: the timer helpers are
 * mixin accessors that throw `without inject` when read on a plugin that did
 * not inject `timer`, so the façade reads `ctx[verb]` only at call time — the
 * plugin that never touches a timer never trips that, and one that does gets
 * cordis's own inject error at the call site.
 */
const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])

/**
 * The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
 * metadata (`schemas`, and `get` returning a schema view, never the live
 * `ToolDefinition`). Exposing the raw definition would hand mount code the
 * tool's `execute` function, letting it call another tool directly and bypass
 * `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
 * accounting) and result normalization. So `get` returns the same
 * name/description/parameters view as `schemas()`, and nothing invocable.
 */
function sandboxTools(ctx: Context): Record<string, unknown> {
  return {
    register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
    schemas: () => ctx.tools.schemas(),
    get: (name: string) => ctx.tools.schemas().find(schema => schema.name === name),
  }
}

/**
 * Reject any injected-service return that is a cordis `Context`. Harness
 * services return data, never a context; a value that is one would be a
 * fresh, unguarded handle back into the runtime — the exact escape the façade
 * exists to close — so it fails loud instead of reaching sandbox code.
 */
function denyContext(value: unknown, service: string): unknown {
  if (value instanceof Context) {
    throw new Error(
      `service "${service}" returned a cordis Context, which the sandbox does not expose. `
      + 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
      + 'and the services you inject — never another context.',
    )
  }
  return value
}

/**
 * Wrap an injected service so its methods forward to the real instance but
 * their return values pass through {@link denyContext}. Non-function members
 * (plain data) pass through as-is; a returned Promise is guarded on resolve.
 */
function guardedService(service: object, name: string): unknown {
  return new Proxy(service, {
    get(target, prop) {
      const value = Reflect.get(target, prop, target) as unknown
      if (typeof value !== 'function') return denyContext(value, name)
      return (...args: unknown[]): unknown => {
        const result = Reflect.apply(value, target, args) as unknown
        if (result instanceof Promise) return result.then(v => denyContext(v, name))
        return denyContext(result, name)
      }
    },
  })
}

/**
 * The service names a plugin declared in `inject`, as a lookup set. Whatever
 * declaration style the plugin used — an `inject: ['bash', 'tools']` array or
 * the `{ required, optional }` object form — cordis resolves it into a single
 * name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
 * so the gate just reads that map's keys. A mount may reach only the services
 * it declared — that is what lets cordis park the mount when a declared
 * provider unmounts.
 */
function declaredInjects(ctx: Context): Set<string> {
  return new Set(Object.keys(ctx.fiber.inject))
}

/**
 * The sandbox context façade handed to a mounted plugin's `apply` in place of
 * the real `ctx`. A whitelist (see the module doc): the registration/eventing
 * verbs, the timer helpers, a guarded `tools`, and injected services resolved
 * through a guarded `get` / property access. A service is reachable only if the
 * plugin DECLARED it in `inject` — an undeclared service is denied even when a
 * global provider exists, so cordis's activation/unload semantics (park the
 * mount when a declared provider goes away) actually bind. Every
 * framework-plumbing member is denied with a teaching error; there is no
 * context-valued member to reach.
 */
function sandboxContext(ctx: Context): Context {
  const tools = sandboxTools(ctx)
  const declared = declaredInjects(ctx)
  // A framework member or an undeclared service — distinguish the two so the
  // error teaches the right fix (declare it in inject vs it is withheld).
  const denyRead = (prop: string): never => {
    if (ctx.get(prop) !== undefined) {
      throw new Error(
        `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
        + 'so cordis parks this mount if the provider is later unmounted.',
      )
    }
    throw new Error(
      `sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
      + 'the timer helpers (ctx.setTimeout, ctx.interval, …) and any service you declared in inject. '
      + 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
    )
  }
  // Read a service for either access path (property or `get`). `tools` is the
  // façade's own surface. An UNDECLARED name is denied with the teaching
  // error; a DECLARED one resolves to the guarded service. A declared inject
  // is required in cordis (the fiber only activates once every declared
  // service is live), so at `apply`/`execute` time `ctx.get(name)` is present
  // for a declared name — no undefined case to handle here. `provide()`
  // accepts ANY value though (cross-mount composition advertises
  // `ctx.provide('name', value)`), so a primitive or null value passes
  // through unwrapped: Proxy throws on a non-object target, and only an
  // object can carry a method that hands back a Context.
  const readService = (name: string): unknown => {
    if (name === 'tools') return tools
    if (!declared.has(name)) return denyRead(name)
    const service = denyContext(ctx.get(name), name)
    if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
    return guardedService(service, name)
  }
  const get = (name: string): unknown => readService(name)
  return new Proxy({}, {
    get(_target, prop) {
      if (prop === 'tools') return tools
      if (prop === 'get') return get
      if (typeof prop !== 'string') return undefined
      // Lazy verb forwarder — reads `ctx[verb]` only when called, so a plugin
      // that never uses a timer never triggers the timer mixin's inject check
      // (cordis raises its own "without inject" error there for undeclared timer use).
      if (CTX_VERBS.has(prop)) {
        return (...args: unknown[]): unknown => {
          const method = ctx[prop as keyof Context]
          return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
        }
      }
      return readService(prop)
    },
    // A façade is not the real ctx; block writes rather than let mount code
    // stash state on a throwaway object and think it persisted.
    set(_target, prop) {
      throw new Error(`sandbox ctx is read-only; cannot assign "${String(prop)}"`)
    },
    // `in` reflects reachability: the façade surface plus DECLARED services
    // (whether or not currently live). Does not resolve/wrap — no throw.
    has: (_target, prop) => prop === 'tools' || prop === 'get'
      || (typeof prop === 'string' && (CTX_VERBS.has(prop) || declared.has(prop))),
  }) as unknown as Context
}

/**
 * Narrow an arbitrary sandbox return value to a mountable cordis plugin: a
 * function, or an object with an `apply` function. (A bare function passes the
 * first arm, so the object arm never sees `Function.prototype.apply`.)
 * @param value - whatever the mount code returned.
 * @returns whether the value is mountable via `ctx.plugin`.
 */
export function isPlugin(value: unknown): value is Plugin {
  if (typeof value === 'function') return true
  return typeof value === 'object' && value !== null
    && typeof (value as { apply?: unknown }).apply === 'function'
}

/**
 * Wrap a plugin so its `apply` receives the sandbox context façade instead of
 * the real `ctx` (see {@link sandboxContext} and the module doc). Both
 * function-form and object-form plugins go through the same wrap; the plugin's
 * own `inject` declaration is preserved (cordis reads it from the plugin
 * object, and pending/active gating happens on the real fiber before `apply`
 * runs), so cross-mount provide/inject works unmodified.
 *
 * `ctx.effect(customCleanup)` is deliberately absent from the façade for now —
 * `on` / `provide` / `tools.register` cover every mount seen so far, and each
 * is already a fiber effect. FIXME(sandbox-effect): expose a guarded `effect`
 * once a real mount needs a bespoke disposer.
 * @param plugin - the plugin the mount code returned.
 * @returns an equivalent plugin whose `apply` sees the sandbox context façade.
 */
export function guardedPlugin(plugin: Plugin): Plugin {
  if (typeof plugin === 'function') {
    const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
    return {
      name: pluginName(plugin),
      apply(ctx: Context, config?: unknown) {
        return functionPlugin(sandboxContext(ctx), config)
      },
    }
  }
  const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
  return {
    ...plugin,
    apply(ctx: Context, config?: unknown) {
      return objectPlugin.apply(sandboxContext(ctx), config)
    },
  }
}

/**
 * Display name for a mounted plugin: its `name` property, else anonymous.
 * @param plugin - the plugin the mount code returned.
 * @returns the human-readable name used in mount results and inspect output.
 */
export function pluginName(plugin: Plugin): string {
  const named = (plugin as { name?: unknown }).name
  if (typeof named === 'string' && named.length > 0) return named
  return '<anonymous>'
}
