// file: libs/sdk/src/resource/resource.registry.ts

import { getMetadata, tokenName, type Token } from '@frontmcp/di';
import type { ServerCapabilities } from '@frontmcp/protocol';
import { ensureMaxLen, getRuntimeContext, isEntryAvailable, sepFor } from '@frontmcp/utils';

import {
  type AppEntry,
  type EntryLineage,
  type EntryOwnerRef,
  type ResourceEntry,
  type ResourceRecord,
  type ResourceTemplateRecord,
  type ResourceType,
  type ScopeEntry,
} from '../common';
import { logAvailabilityFiltering } from '../common/availability';
import { ResourceTemplateKind } from '../common/records/resource.record';
import { EntryValidationError, NameDisambiguationError } from '../errors';
import type ProviderRegistry from '../provider/provider.registry';
import { RegistryAbstract, type RegistryBuildMapResult } from '../regsitry';
import { ownerKeyOf, qualifiedNameOf } from '../utils/lineage.utils';
import { normalizeOwnerPath, normalizeProviderId, normalizeSegment } from '../utils/naming.utils';
import ReadResourceFlow from './flows/read-resource.flow';
import ResourceTemplatesListFlow from './flows/resource-templates-list.flow';
import ResourcesListFlow from './flows/resources-list.flow';
import SubscribeResourceFlow from './flows/subscribe-resource.flow';
import UnsubscribeResourceFlow from './flows/unsubscribe-resource.flow';
import { ResourceEmitter, type ResourceChangeEvent } from './resource.events';
import { ResourceInstance } from './resource.instance';
import { DEFAULT_RESOURCE_EXPORT_OPTS, type IndexedResource, type ResourceExportOptions } from './resource.types';
import {
  isResourceTemplate,
  normalizeResource,
  normalizeResourceTemplate,
  resourceDiscoveryDeps,
} from './resource.utils';

export default class ResourceRegistry extends RegistryAbstract<
  ResourceInstance, // instances map holds ResourceInstance
  ResourceRecord | ResourceTemplateRecord,
  ResourceType[]
> {
  /** Who owns this registry (used for provenance). */
  owner: EntryOwnerRef;

  /** Resources truly owned/constructed by THIS registry (with lineage applied) */
  private localRows: IndexedResource[] = [];

  /** Adopted resource rows from each child registry (references to the same instances) */
  private adopted = new Map<ResourceRegistry, IndexedResource[]>();

  /** Children registries that we track */
  private children = new Set<ResourceRegistry>();

  // ---- O(1) indexes over EFFECTIVE set (local + adopted) ----
  private byQualifiedId = new Map<string, IndexedResource>(); // qualifiedId -> row
  private byName = new Map<string, IndexedResource[]>(); // baseName -> rows
  private byUri = new Map<string, IndexedResource>(); // uri -> row (static resources)
  private byUriTemplate = new Map<string, IndexedResource>(); // uriTemplate -> row (templates)
  private byOwnerAndName = new Map<string, IndexedResource>(); // "ownerKey:name" -> row
  private byOwner = new Map<string, IndexedResource[]>(); // ownerKey -> rows

  // version + emitter
  private version = 0;
  private emitter = new ResourceEmitter();

  constructor(providers: ProviderRegistry, list: ResourceType[], owner: EntryOwnerRef) {
    // disable auto so subclass fields initialize first
    super('ResourceRegistry', providers, list, false);
    this.owner = owner;

    // now it's safe to run the lifecycle
    this.buildGraph();
    this.ready = this.initialize();
  }

  /* -------------------- Build-time: defs + dep checks -------------------- */

  protected override buildMap(list: ResourceType[]): RegistryBuildMapResult<ResourceRecord | ResourceTemplateRecord> {
    const tokens = new Set<Token>();
    const defs = new Map<Token, ResourceRecord | ResourceTemplateRecord>();
    const graph = new Map<Token, Set<Token>>();

    for (const raw of list) {
      // Determine if it's a template or static resource
      const isTemplate = isResourceTemplate(raw);
      const rec = isTemplate ? normalizeResourceTemplate(raw) : normalizeResource(raw);

      const provide = rec.provide;
      tokens.add(provide);
      defs.set(provide, rec);
      graph.set(provide, new Set());
    }
    return { tokens, defs, graph };
  }

  protected buildGraph() {
    for (const token of this.tokens) {
      const rec = this.defs.get(token)!;
      const deps = resourceDiscoveryDeps(rec);

      for (const d of deps) {
        // Validate against hierarchical providers; throws early if missing
        this.providers.get(d);
        this.graph.get(token)!.add(d);
      }
    }
  }

  /* -------------------- Initialize: create ONE ResourceInstance per local resource -------------------- */

  protected override async initialize(): Promise<void> {
    // Instantiate each local resource once and store in this.instances
    for (const token of this.tokens) {
      const rec = this.defs.get(token)!;

      // Single, authoritative instance per local resource
      const ri = new ResourceInstance(rec, this.providers, this.owner);
      this.instances.set(token as Token<ResourceInstance>, ri);

      const lineage: EntryLineage = this.owner ? [this.owner] : [];
      const row = this.makeRow(token, ri, lineage, this);
      this.localRows.push(row);
    }

    // Adopt resources from child app registries
    const scope = this.providers.getActiveScope();
    const childAppRegistries = this.providers.getRegistries('AppRegistry');
    childAppRegistries.forEach((appRegistry) => {
      const apps = appRegistry.getApps();
      for (const app of apps) {
        if (app.isRemote) {
          // Remote apps: adopt resources directly from the app's resources registry
          this.adoptResourcesFromRemoteApp(app, scope);
        } else {
          // Local apps: adopt from child ResourceRegistry instances
          this.adoptResourcesFromLocalApp(app);
        }
      }
    });

    // Adopt resources from other child resource registries
    const childResourceRegistries = this.providers.getRegistries('ResourceRegistry');
    childResourceRegistries
      .filter((r) => r !== this)
      .forEach((resourceRegistry) => {
        this.adoptFromChild(resourceRegistry as ResourceRegistry, resourceRegistry.owner);
      });

    // Build effective indexes from (locals + already adopted children)
    this.reindex();

    // Log availability filtering at boot (registry-level, not HTTP/flow-level auth)
    logAvailabilityFiltering('ResourceRegistry', this.listAllInstances(), scope.logger);

    this.bump('reset');

    // Register resource flows with the scope (scope already declared above)
    await scope.registryFlows(
      ReadResourceFlow,
      ResourcesListFlow,
      ResourceTemplatesListFlow,
      SubscribeResourceFlow,
      UnsubscribeResourceFlow,
    );
  }

  /* -------------------- Adoption: reference child instances (no cloning) -------------------- */

  /**
   * Adopt resources from a child registry. Parent runs after children are ready.
   * We *reference* the child's resource instances; no duplicates are created.
   */
  adoptFromChild(child: ResourceRegistry, _childOwner: EntryOwnerRef): void {
    if (this.children.has(child)) return;

    const childRows = child.listAllIndexed();
    const prepend: EntryLineage = this.owner ? [this.owner] : [];

    const adoptedRows = childRows.map((r) => this.relineage(r, prepend));

    this.adopted.set(child, adoptedRows);
    this.children.add(child);

    // keep live if child changes
    child.subscribe({ immediate: false }, () => {
      const latest = child.listAllIndexed().map((r) => this.relineage(r, prepend));
      this.adopted.set(child, latest);
      this.reindex();
      this.bump('reset');
    });

    this.reindex();
    this.bump('reset');
  }

  /**
   * Adopt resources directly from a remote app's resources registry.
   * Remote apps expose resources via proxy entries that forward execution to the remote server.
   * This also subscribes to updates from the remote app's registry for lazy-loaded resources.
   */
  private adoptResourcesFromRemoteApp(app: AppEntry, scope: ScopeEntry): void {
    const remoteRegistry = app.resources as ResourceRegistry;

    // Helper to adopt/re-adopt resources from the remote app
    const adoptRemoteResources = () => {
      try {
        // Remove any previously adopted resources from this remote app
        const appPrefix = `resource:${app.id}:`;
        this.localRows = this.localRows.filter((row) => {
          const tokenDesc = typeof row.token === 'symbol' ? row.token.description : undefined;
          return !tokenDesc?.startsWith(appPrefix);
        });

        const remoteResources = app.resources.getResources();
        const remoteTemplates = app.resources.getResourceTemplates();
        const allRemoteResources = [...remoteResources, ...remoteTemplates];
        if (allRemoteResources.length > 0) {
          const prepend: EntryLineage = this.owner ? [this.owner] : [];
          for (const remoteResource of allRemoteResources) {
            if (!remoteResource || typeof remoteResource.name !== 'string') {
              scope.logger.warn(`Invalid remote resource in app ${app.id}: missing name`);
              continue;
            }
            // Use Symbol.for() for stable, deterministic tokens across registry operations
            const stableToken = Symbol.for(`resource:${app.id}:${remoteResource.name}`);
            const row = this.makeRow(stableToken, remoteResource, prepend, this);
            this.localRows.push(row);
          }
        }
        this.reindex();
        this.bump('reset');
      } catch (error) {
        scope.logger.error(`Failed to get resources from remote app ${app.id}: ${(error as Error).message}`);
      }
    };

    // Initial adoption (may be empty if remote app hasn't connected yet)
    adoptRemoteResources();

    // Subscribe to remote app's resource registry for lazy-loaded resources
    if (remoteRegistry && typeof remoteRegistry.subscribe === 'function') {
      remoteRegistry.subscribe({ immediate: false }, () => {
        adoptRemoteResources();
      });
    }
  }

  /**
   * Adopt resources from a local app's child ResourceRegistry instances.
   * Local apps use the hierarchical registry pattern for resource discovery.
   */
  private adoptResourcesFromLocalApp(app: AppEntry): void {
    const appResourceRegistries = app.providers.getRegistries('ResourceRegistry');
    appResourceRegistries
      .filter((r) => r.owner.kind === 'app')
      .forEach((appResourceRegistry) => {
        this.adoptFromChild(appResourceRegistry as ResourceRegistry, appResourceRegistry.owner);
      });
  }

  /* -------------------- Public API -------------------- */

  /**
   * Get all static resources (not templates)
   */
  getResources(includeHidden = false): ResourceEntry[] {
    const ctx = getRuntimeContext();
    const all = this.listAllIndexed();
    return all
      .filter((r) => !r.isTemplate)
      .filter((r) => {
        const meta = r.instance.metadata;
        if (!isEntryAvailable(meta.availableWhen, ctx)) return false;
        const hidden = 'hideFromDiscovery' in meta && meta.hideFromDiscovery === true;
        return !hidden || includeHidden;
      })
      .map((r) => r.instance);
  }

  /**
   * Get all resource templates
   */
  getResourceTemplates(): ResourceEntry[] {
    const ctx = getRuntimeContext();
    const all = this.listAllIndexed();
    return all
      .filter((r) => r.isTemplate)
      .filter((r) => isEntryAvailable(r.instance.metadata.availableWhen, ctx))
      .map((r) => r.instance);
  }

  /**
   * Get inline resources (local only)
   */
  getInlineResources(): ResourceEntry[] {
    return [...this.instances.values()];
  }

  /**
   * Find a resource by exact URI match
   */
  findByUri(uri: string): ResourceEntry | undefined {
    const row = this.byUri.get(uri);
    return row?.instance;
  }

  /**
   * Match a URI against template resources and extract parameters
   */
  matchTemplateByUri(uri: string): { instance: ResourceEntry; params: Record<string, string> } | undefined {
    // Try each template resource
    for (const row of this.listAllIndexed()) {
      if (!row.isTemplate) continue;

      const match = row.instance.matchUri(uri);
      if (match.matches) {
        return { instance: row.instance, params: match.params };
      }
    }
    return undefined;
  }

  /**
   * Find a resource by URI - tries exact match first, then template matching
   */
  findResourceForUri(uri: string): { instance: ResourceEntry; params: Record<string, string> } | undefined {
    // Try exact URI match first
    const exact = this.findByUri(uri);
    if (exact) {
      return { instance: exact, params: {} };
    }

    // Try template matching
    return this.matchTemplateByUri(uri);
  }

  /** Internal snapshot of effective indexed rows (locals + adopted). */
  listAllIndexed(): IndexedResource[] {
    return [...this.localRows, ...[...this.adopted.values()].flat()];
  }

  /** List all instances (locals + adopted). */
  listAllInstances(): readonly ResourceEntry[] {
    return this.listAllIndexed().map((r) => r.instance);
  }

  /** List instances by owner path (e.g. "app:Portal/plugin:Okta") */
  listByOwner(ownerPath: string): readonly ResourceEntry[] {
    return (this.byOwner.get(ownerPath) ?? []).map((r) => r.instance);
  }

  /* -------------------- Indexing & lookups -------------------- */

  private reindex() {
    const effective = this.listAllIndexed();

    this.byQualifiedId.clear();
    this.byName.clear();
    this.byUri.clear();
    this.byUriTemplate.clear();
    this.byOwnerAndName.clear();
    this.byOwner.clear();

    for (const r of effective) {
      this.byQualifiedId.set(r.qualifiedId, r);

      const listByName = this.byName.get(r.baseName) ?? [];
      listByName.push(r);
      this.byName.set(r.baseName, listByName);

      const byOwnerKey = this.byOwner.get(r.ownerKey) ?? [];
      byOwnerKey.push(r);
      this.byOwner.set(r.ownerKey, byOwnerKey);

      const on = `${r.ownerKey}:${r.baseName}`;
      if (!this.byOwnerAndName.has(on)) this.byOwnerAndName.set(on, r);

      // Index by URI or URI template
      if (r.isTemplate && r.uriTemplate) {
        this.byUriTemplate.set(r.uriTemplate, r);
      } else if (r.uri) {
        this.byUri.set(r.uri, r);
      }
    }
  }

  /* -------------------- Conflict-aware exported names -------------------- */

  /**
   * Produce unique, MCP-valid exported names.
   */
  exportResolvedNames(opts?: ResourceExportOptions): Array<{ name: string; instance: ResourceEntry }> {
    const cfg = { ...DEFAULT_RESOURCE_EXPORT_OPTS, ...(opts ?? {}) };

    const rows = this.listAllIndexed().map((r) => {
      const base = normalizeSegment(r.baseName, cfg.case);
      const isLocal = r.source === this;
      const provider = normalizeProviderId(this.providerIdOf(r.instance), cfg.case);
      const ownerPath = normalizeOwnerPath(r.ownerKey, cfg.case);
      return { base, row: r, isLocal, provider, ownerPath };
    });

    // group by standardized base
    const byBase = new Map<string, typeof rows>();
    for (const r of rows) {
      const list = byBase.get(r.base) ?? [];
      list.push(r);
      byBase.set(r.base, list);
    }

    const out = new Map<string, ResourceEntry>();

    for (const [base, group] of byBase.entries()) {
      if (group.length === 1) {
        const g = group[0];
        out.set(ensureMaxLen(base, cfg.maxLen), g.row.instance);
        continue;
      }

      const locals = group.filter((g) => g.isLocal);
      const children = group.filter((g) => !g.isLocal);

      if (cfg.prefixChildrenOnConflict && locals.length > 0) {
        // Locals
        if (locals.length === 1) {
          const localName = ensureMaxLen(base, cfg.maxLen);
          out.set(disambiguate(localName, out, cfg), locals[0].row.instance);
        } else {
          for (const l of locals) {
            const pref = normalizeOwnerPath(l.ownerPath, cfg.case);
            const name = ensureMaxLen(`${pref}${sepFor(cfg.case)}${base}`, cfg.maxLen);
            out.set(disambiguate(name, out, cfg), l.row.instance);
          }
        }

        // Children
        for (const c of children) {
          const pre = cfg.prefixSource === 'provider' ? (c.provider ?? c.ownerPath) : c.ownerPath;
          const name = ensureMaxLen(`${pre}${sepFor(cfg.case)}${base}`, cfg.maxLen);
          out.set(disambiguate(name, out, cfg), c.row.instance);
        }
      } else {
        // Prefix everyone by source
        for (const r of group) {
          const pre = cfg.prefixSource === 'provider' ? (r.provider ?? r.ownerPath) : r.ownerPath;
          const name = ensureMaxLen(`${pre}${sepFor(cfg.case)}${base}`, cfg.maxLen);
          out.set(disambiguate(name, out, cfg), r.row.instance);
        }
      }
    }

    return [...out.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, instance]) => ({ name, instance }));

    function disambiguate(candidate: string, pool: Map<string, any>, cfg: Required<ResourceExportOptions>): string {
      if (!pool.has(candidate)) return candidate;
      const maxAttempts = 10000;
      let n = 2;
      while (n <= maxAttempts) {
        const withN = ensureMaxLen(`${candidate}${sepFor(cfg.case)}${n}`, cfg.maxLen);
        if (!pool.has(withN)) return withN;
        n++;
      }
      throw new NameDisambiguationError(candidate, maxAttempts);
    }
  }

  /** Lookup by the exported (resolved) name. */
  getExported(name: string, opts?: ResourceExportOptions): ResourceEntry | undefined {
    const pairs = this.exportResolvedNames(opts);
    return pairs.find((p) => p.name === name)?.instance;
  }

  /* -------------------- Subscriptions -------------------- */

  subscribe(
    opts: { immediate?: boolean; filter?: (i: ResourceEntry) => boolean },
    cb: (evt: ResourceChangeEvent) => void,
  ): () => void {
    const filter = opts.filter ?? (() => true);
    if (opts.immediate) {
      cb({
        kind: 'reset',
        changeScope: 'global',
        version: this.version,
        snapshot: this.listAllInstances().filter(filter),
      });
    }
    return this.emitter.on((e) => cb({ ...e, snapshot: this.listAllInstances().filter(filter) }));
  }

  /**
   * Signal that a specific resource's content has changed without modifying the resource list.
   * Emits an 'updated' event that NotificationService uses to notify subscribed sessions.
   *
   * @param uri - The URI of the resource whose content changed
   */
  notifyContentChanged(uri: string): void {
    this.emitter.emit({
      kind: 'updated',
      changeScope: 'global',
      version: this.version,
      snapshot: this.listAllInstances(),
      updatedUri: uri,
    });
  }

  private bump(kind: ResourceChangeEvent['kind']) {
    const version = ++this.version;
    this.emitter.emit({ kind, changeScope: 'global', version, snapshot: this.listAllInstances() });
  }

  /* -------------------- Helpers -------------------- */

  /** Build an IndexedResource row */
  private makeRow(
    token: Token,
    instance: ResourceEntry,
    lineage: EntryLineage,
    source: ResourceRegistry,
  ): IndexedResource {
    const ownerKey = ownerKeyOf(lineage);
    const baseName = instance.name;
    const qualifiedName = qualifiedNameOf(lineage, baseName);
    const qualifiedId = `${ownerKey}:${tokenName(token)}`;
    return {
      token,
      instance,
      baseName,
      uri: instance.uri,
      uriTemplate: instance.uriTemplate,
      isTemplate: instance.isTemplate,
      lineage,
      ownerKey,
      qualifiedName,
      qualifiedId,
      source,
    };
  }

  /** Clone a child row and prepend lineage (with adjacent de-dup to avoid double prefixes). */
  private relineage(row: IndexedResource, prepend: EntryLineage): IndexedResource {
    const merged = [...prepend, ...row.lineage];
    const lineage = dedupLineage(merged);

    const ownerKey = ownerKeyOf(lineage);
    const qualifiedName = qualifiedNameOf(lineage, row.baseName);
    const qualifiedId = `${ownerKey}:${tokenName(row.token)}`;
    return {
      token: row.token,
      instance: row.instance, // REFERENCE the same instance
      baseName: row.baseName,
      uri: row.uri,
      uriTemplate: row.uriTemplate,
      isTemplate: row.isTemplate,
      lineage,
      ownerKey,
      qualifiedName,
      qualifiedId,
      source: row.source, // keep original source (who constructed instance)
    };
  }

  /** Best-effort provider id used for prefixing. */
  private providerIdOf(inst: ResourceEntry): string | undefined {
    try {
      // Use metadata property from BaseEntry
      const meta: unknown = inst.metadata;
      if (!meta || typeof meta !== 'object') return undefined;

      const metaObj = meta as Record<string, unknown>;
      const maybe = metaObj['providerId'] ?? metaObj['provider'] ?? metaObj['ownerId'];
      if (typeof maybe === 'string' && maybe.length > 0) return maybe;

      const cls = metaObj['cls'];
      if (cls && typeof cls === 'function') {
        const id = getMetadata('id', cls);
        if (typeof id === 'string' && id.length > 0) return id;
        if ('name' in cls && typeof cls.name === 'string') return cls.name;
      }
    } catch {
      /* ignore */
    }
    return undefined;
  }

  /** True if this registry (or adopted children) has any resources. */
  hasAny(): boolean {
    return this.listAllIndexed().length > 0 || this.tokens.size > 0;
  }

  /**
   * Replace all resources owned by the given owner.
   * Clears local rows, rebuilds from new list, reindexes, and emits 'reset'.
   * Used by adapter polling to hot-swap resources when specs change.
   */
  replaceAll(list: ResourceType[], owner: EntryOwnerRef): void {
    // Clear local rows and instances
    this.localRows = [];
    this.instances.clear();

    // Clear internal maps from base class
    this.tokens.clear();
    this.defs.clear();
    this.graph.clear();

    // Rebuild from new list
    const { tokens, defs, graph } = this.buildMap(list);
    for (const [key, val] of defs) {
      this.defs.set(key, val);
      this.graph.set(key, graph.get(key) ?? new Set());
    }
    for (const t of tokens) {
      this.tokens.add(t);
    }

    // Rebuild graph dependencies
    this.buildGraph();

    // Recreate instances and local rows
    for (const token of this.tokens) {
      const rec = this.defs.get(token)!;
      const ri = new ResourceInstance(rec, this.providers, owner);
      this.instances.set(token as Token<ResourceInstance>, ri);

      const lineage: EntryLineage = owner ? [owner] : [];
      const row = this.makeRow(token, ri, lineage, this);
      this.localRows.push(row);
    }

    // Update owner reference
    this.owner = owner;

    // Rebuild indexes and notify
    this.reindex();
    this.bump('reset');
  }

  /**
   * Get the MCP capabilities for resources.
   * These are reported to clients during initialization.
   *
   * Issue #407: we always advertise the `resources` capability so the MCP SDK
   * accepts the always-registered `resources/list` and `resources/templates/list`
   * handlers (see `createMcpHandlers`). `subscribe` and `listChanged` reflect
   * whether anything is actually registered.
   */
  getCapabilities(): Partial<ServerCapabilities> {
    return {
      resources: {
        // Subscription support per MCP 2025-11-25 spec — only meaningful when
        // there are registered resources.
        subscribe: this.hasAny(),
        listChanged: this.hasAny(),
      },
    };
  }

  /**
   * Register an existing ResourceEntry instance directly (for remote resources).
   * This allows pre-constructed resource instances to be added without going through
   * the standard token-based initialization flow.
   *
   * **IMPORTANT: Scope Binding**
   * The ResourceEntry captures its scope and providers at construction time.
   * The resource will use the scope from its original `providers` argument, NOT this registry's scope.
   *
   * @param resource - The resource instance to register (ResourceInstance or RemoteResourceInstance)
   * @throws Error if resource is not a valid instance
   */
  registerResourceInstance(resource: ResourceEntry): void {
    // Validate that we have a proper instance with required properties
    const instance = resource as ResourceInstance;

    // Check for required properties that make it a valid registerable instance
    if (!instance.record || !instance.record.provide) {
      throw new EntryValidationError('Resource', 'missing required record.provide property');
    }
    if (!instance.record.kind || !instance.record.metadata) {
      throw new EntryValidationError('Resource', 'missing required record.kind or record.metadata');
    }
    if (typeof instance.name !== 'string' || !instance.name) {
      throw new EntryValidationError('Resource', 'missing required name property');
    }

    const token = instance.record.provide as Token;

    // Check for duplicate registration
    if (this.instances.has(token as Token<ResourceInstance>)) {
      return; // Already registered, skip silently
    }

    // Add to instances map
    this.instances.set(token as Token<ResourceInstance>, instance);

    // Create an indexed row for this resource
    const lineage: EntryLineage = this.owner ? [this.owner] : [];
    const row = this.makeRow(token, instance, lineage, this);
    this.localRows.push(row);

    // Rebuild indexes
    this.reindex();
    this.bump('reset');
  }

  /**
   * Dynamically register a resource or resource template at runtime.
   *
   * Used for system resources like the ui:// template that should be
   * registered when tools with UI configs exist.
   *
   * @param resourceDef - Resource class, function, or template to register
   */
  registerDynamicResource(resourceDef: ResourceType | ResourceRecord | ResourceTemplateRecord): void {
    // Pre-built records (e.g. SEP-2640 per-skill FUNCTION records) carry
    // their kind explicitly. We still route them through `normalizeResource`
    // / `normalizeResourceTemplate` so kind-specific shape validation
    // (FUNCTION must be callable, ESM/REMOTE must have a token, etc.)
    // runs at registration time rather than at first read.
    const isPrebuiltRecord =
      resourceDef &&
      typeof resourceDef === 'object' &&
      'kind' in resourceDef &&
      'metadata' in resourceDef &&
      'provide' in resourceDef;

    let rec: ResourceRecord | ResourceTemplateRecord;
    if (isPrebuiltRecord) {
      const def = resourceDef as ResourceRecord | ResourceTemplateRecord;
      const isTemplateRecord =
        def.kind === ResourceTemplateKind.CLASS_TOKEN || def.kind === ResourceTemplateKind.FUNCTION;
      rec = isTemplateRecord ? normalizeResourceTemplate(def) : normalizeResource(def as ResourceRecord);
    } else {
      const def = resourceDef as ResourceType;
      const isTemplate = isResourceTemplate(def);
      rec = isTemplate ? normalizeResourceTemplate(def) : normalizeResource(def);
    }
    const token = rec.provide;

    // Skip if already registered
    if (this.tokens.has(token)) {
      return;
    }

    // Add to registry structures
    this.tokens.add(token);
    this.defs.set(token, rec);

    // Build dependency graph (same pattern as buildGraph)
    const deps = resourceDiscoveryDeps(rec);
    const depSet = new Set<Token>();
    for (const d of deps) {
      // Validate against hierarchical providers; throws early if missing
      this.providers.get(d);
      depSet.add(d);
    }
    this.graph.set(token, depSet);

    // Create instance
    const ri = new ResourceInstance(rec, this.providers, this.owner);
    this.instances.set(token as Token<ResourceInstance>, ri);

    // Add to local rows with lineage
    const lineage: EntryLineage = this.owner ? [this.owner] : [];
    const row = this.makeRow(token, ri, lineage, this);
    this.localRows.push(row);

    // Rebuild indexes and emit change event
    this.reindex();
    this.bump('reset');
  }
}

/* -------------------- lineage utility -------------------- */

/** Remove only adjacent duplicates like [adapter:x, adapter:x] → [adapter:x] */
function dedupLineage(l: EntryLineage): EntryLineage {
  const out: EntryLineage = [];
  for (const o of l) {
    const last = out[out.length - 1];
    if (!last || last.kind !== o.kind || last.id !== o.id) out.push(o);
  }
  return out;
}
