/**
 * ToolkitCoordinator - Centralized Assessment Toolkit Service Management
 *
 * Orchestrates all toolkit services (TTS, tools, accessibility, state management) from a single entry point.
 * Provides centralized configuration for tool availability and settings.
 *
 * Key features:
 * - Owns all toolkit services (TTSService, ToolCoordinator, HighlightCoordinator, ElementToolStateStore, AccessibilityCatalogResolver)
 * - Single configuration point for tool availability and settings
 * - Sensible defaults for standalone usage
 * - Direct access for settings widgets (no player dependency)
 * - Clean separation: toolkit concerns only (NOT assessment state like navigation, timing, progress)
 *
 * Part of PIE Assessment Toolkit.
 */
import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
import { normalizeAndValidateToolsConfig, normalizeToolConfigStrictness, } from "./tool-config-validation.js";
import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
import { ElementToolStateStore } from "./ElementToolStateStore.js";
import { frameworkErrorFromCoordinatorContext, } from "./framework-error.js";
import { FrameworkErrorBus, } from "./framework-error-bus.js";
import { HighlightCoordinator } from "./HighlightCoordinator.js";
import { ToolCoordinator } from "./ToolCoordinator.js";
import { TTSService } from "./TTSService.js";
import { BrowserTTSProvider } from "./tts/browser-provider.js";
import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
import { ToolProviderRegistry } from "./tool-providers/index.js";
import { createPackagedToolRegistry } from "./createDefaultToolRegistry.js";
import { ToolPolicyEngine, } from "../policy/engine.js";
import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
const mergeToolConfigUpdate = (toolId, current, updates) => {
    const next = { ...current, ...updates };
    if (toolId === "textToSpeech" &&
        isPlainRecord(current.settings) &&
        isPlainRecord(updates.settings)) {
        next.settings = { ...current.settings, ...updates.settings };
        if (isPlainRecord(current.settings.mathSpeech) &&
            isPlainRecord(updates.settings.mathSpeech)) {
            next.settings.mathSpeech = {
                ...current.settings.mathSpeech,
                ...updates.settings.mathSpeech,
            };
        }
    }
    return next;
};
const SECTION_ITEM_EVENT_TYPES = [
    "item-selected",
    "item-session-data-changed",
    "item-session-meta-changed",
    "item-complete-changed",
    "content-loaded",
    "item-player-error",
];
const SECTION_SCOPED_EVENT_TYPES = [
    "section-navigation-change",
    "section-session-applied",
    "section-loading-complete",
    "section-items-complete-changed",
    "section-error",
];
/**
 * ToolkitCoordinator - Orchestrates all assessment toolkit services
 *
 * @example
 * ```typescript
 * // Create coordinator with configuration
 * const coordinator = new ToolkitCoordinator({
 *   assessmentId: 'demo-three-questions',
 *   tools: {
 *     providers: {
 *       textToSpeech: { enabled: true, defaultVoice: 'en-US' },
 *       answerEliminator: { enabled: true, strategy: 'strikethrough' }
 *     },
 *     placement: {
 *       item: ['textToSpeech', 'answerEliminator']
 *     }
 *   }
 * });
 *
 * // Pass to section player
 * player.toolkitCoordinator = coordinator;
 *
 * // Access services directly
 * const ttsService = coordinator.ttsService;
 * const toolState = coordinator.elementToolStateStore.getAllState();
 * ```
 */
export class ToolkitCoordinator {
    /** Assessment identifier (for scoping tool state) */
    assessmentId;
    /** Configuration */
    config;
    /** Managed services (public for direct access) */
    ttsService;
    toolCoordinator;
    highlightCoordinator;
    elementToolStateStore;
    catalogResolver;
    toolProviderRegistry;
    hooks;
    lazyInit;
    /** Track TTS initialization state */
    ttsInitialized = false;
    ttsInitPromise;
    ttsReconfigurePromise;
    stateLoaded = false;
    stateLoadPromise;
    coordinatorReadyPromise;
    coordinatorReadyNotified = false;
    providerInitPromises = new Map();
    toolRegistry;
    toolContextResolvers = new Map();
    toolContextResolverChangeListeners = new Set();
    sectionControllers = new Map();
    sectionControllerKeys = new Map();
    sectionControllerInitPromises = new Map();
    sectionPersistenceStrategies = new Map();
    sectionControllerLifecycleListeners = new Set();
    /**
     * Phase D: every active section-event subscription, indexed by
     * listener identity. The listener follows the toolkit's active
     * section cohort across transitions; this map is the registry the
     * coordinator iterates on every cohort change to detach the listener
     * from the outgoing controller, attach it to the new one, and replay
     * the snapshot (content-loaded × N then section-loading-complete) in
     * canonical order.
     *
     * Dedup is by listener identity: subscribing the same listener
     * function twice replaces the prior subscription's filter args.
     */
    activeSubscriptions = new Map();
    /**
     * Phase D: map key of the section controller currently treated as
     * the *active cohort*. Set by `getOrCreateSectionController` (both
     * the create-new and resolve-existing paths) and cleared when the
     * matching controller is disposed. `null` while no cohort exists,
     * which is the only state where `subscribeSectionEvents` throws.
     */
    activeCohortMapKey = null;
    /**
     * Latest requested active cohort. Async controller initialization can
     * resolve out of order; only the most recent request may migrate active
     * subscriptions. Older completions still populate the controller cache.
     */
    latestRequestedActiveCohortMapKey = null;
    telemetryListeners = new Set();
    frameworkErrorBus;
    ownsFrameworkErrorBus;
    /**
     * Unified Tool Policy Engine. Owned by the coordinator and lives
     * for the lifetime of the coordinator instance — there is no
     * explicit teardown path today; the engine and its listener set
     * are reclaimed by GC when the coordinator becomes unreachable.
     * Subscribers attached via {@link onPolicyChange} must therefore
     * detach via the unsubscribe function the engine returns; do not
     * rely on a `disposed` event being emitted on coordinator teardown.
     *
     * Hosts read decisions via {@link decideToolPolicy} or subscribe
     * to changes via {@link onPolicyChange}.
     */
    policyEngine;
    /**
     * Host-set override for PNP/profile enforcement. `null` (the default) means
     * "auto" — the coordinator infers the effective mode from the
     * PNP/profile policy inputs the bound `AssessmentEntity` and `AssessmentItemRef`
     * actually carry (see {@link resolveEffectivePnpEnforcement}).
     * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
     * across subsequent assessment / item swaps until the host clears
     * the override by calling `setPnpEnforcement(null)`.
     */
    pnpEnforcementOverride = null;
    /**
     * Last assessment passed to {@link updateAssessment}. Read by
     * {@link resolveEffectivePnpEnforcement} to compute auto-mode.
     * The engine's own copy is the canonical record for decisions;
     * this mirror exists only so the auto-mode helper does not need
     * to round-trip through {@link policyEngine}'s frozen snapshot.
     */
    boundAssessment = null;
    /**
     * Last item reference passed to {@link updateCurrentItemRef}.
     * Mirrored alongside {@link boundAssessment} so
     * {@link resolveEffectivePnpEnforcement} can detect item-level
     * profile policy inputs (`requiredTools` / `restrictedTools` /
     * `toolParameters`) without round-tripping through the engine's
     * frozen snapshot.
     */
    boundCurrentItemRef = null;
    static resolveConfig(config) {
        const strictness = normalizeToolConfigStrictness(config.toolConfigStrictness);
        const toolRegistry = config.toolRegistry ?? createPackagedToolRegistry();
        const normalized = config.deferToolConfigValidation === true
            ? normalizeToolsConfig(config.tools)
            : normalizeAndValidateToolsConfig(config.tools, {
                strictness,
                source: "ToolkitCoordinator.init",
                toolRegistry,
            }).config;
        const defaultProviders = {
            textToSpeech: {
                enabled: true,
                backend: "browser",
            },
            annotationToolbar: {
                enabled: true,
            },
        };
        return {
            ...config,
            toolConfigStrictness: strictness,
            toolRegistry,
            tools: {
                ...normalized,
                providers: {
                    ...defaultProviders,
                    ...normalized.providers,
                },
            },
            accessibility: {
                language: "en-US",
                ...(config.accessibility ?? {}),
                catalogs: config.accessibility?.catalogs ?? [],
            },
        };
    }
    constructor(config) {
        if (!config.assessmentId) {
            throw new Error("ToolkitCoordinator requires assessmentId in config");
        }
        const resolvedConfig = ToolkitCoordinator.resolveConfig(config);
        this.assessmentId = resolvedConfig.assessmentId;
        this.config = resolvedConfig;
        this.toolRegistry =
            resolvedConfig.toolRegistry ?? createPackagedToolRegistry();
        this.installToolContextResolvers(resolvedConfig.toolContextResolvers);
        this.hooks = resolvedConfig.hooks ?? {};
        this.lazyInit = config.lazyInit === true;
        this.pnpEnforcementOverride = this.resolveConfiguredPnpEnforcement();
        // Use the host-provided framework-error bus if one was passed
        // (typical when embedded inside <pie-assessment-toolkit>, so
        // pre-coordinator failures from the CE flow through the same
        // fan-out as coordinator failures). Otherwise own a private one.
        this.frameworkErrorBus =
            config.frameworkErrorBus ?? new FrameworkErrorBus();
        this.ownsFrameworkErrorBus = !config.frameworkErrorBus;
        this.subscribeFrameworkErrorHookAdapters();
        // Initialize all services
        this.toolCoordinator = new ToolCoordinator();
        this.highlightCoordinator = new HighlightCoordinator();
        this.elementToolStateStore = new ElementToolStateStore();
        this.catalogResolver = new AccessibilityCatalogResolver(resolvedConfig.accessibility?.catalogs || [], resolvedConfig.accessibility?.language || "en-US");
        // Initialize tool provider registry
        this.toolProviderRegistry = new ToolProviderRegistry();
        this._registerToolProviders();
        // Initialize TTS service based on config
        this.ttsService = new TTSService();
        this.setupStatePersistenceHooks();
        // M8 PR 2 — construct the unified ToolPolicyEngine seeded with
        // the validated tools config. PNP/profile inputs (`assessment`,
        // `currentItemRef`) start `null`; `pnpEnforcement` is resolved
        // through {@link resolveEffectivePnpEnforcement}, which flips
        // to `"on"` only once the bound assessment or item carries
        // actual profile material. Hosts that only consume the
        // engine for placement/policy gating get the pre-PR-2 behavior
        // bit-for-bit.
        this.policyEngine = new ToolPolicyEngine({
            toolRegistry: this.toolRegistry,
            contextId: `toolkit-coordinator:${this.assessmentId}`,
            inputs: {
                tools: this.config.tools,
                assessment: null,
                currentItemRef: null,
                pnpEnforcement: this.resolveEffectivePnpEnforcement(),
            },
        });
        if (!this.lazyInit) {
            void this.waitUntilReady().catch((err) => {
                console.error("[ToolkitCoordinator] Failed eager initialization:", err);
                this.handleError(err, { phase: "coordinator-ready" });
            });
        }
    }
    /**
     * Subscribe the canonical `onFrameworkError` hook adapter to the
     * framework-error bus.
     *
     * Called once from the constructor. The adapter inspects the live
     * `this.hooks` reference, so a hook added later via {@link setHooks}
     * is picked up automatically without re-subscribing.
     */
    subscribeFrameworkErrorHookAdapters() {
        this.frameworkErrorBus.subscribeFrameworkErrors((model) => {
            const hook = this.hooks.onFrameworkError;
            if (!hook)
                return;
            try {
                hook(model);
            }
            catch (hookError) {
                console.warn("[ToolkitCoordinator] onFrameworkError hook failed:", hookError);
            }
        });
    }
    /**
     * Emit a telemetry event to `onTelemetry` hook + all `subscribeTelemetry`
     * listeners.
     *
     * **Naming convention.** Event names MUST be prefixed at the call site,
     * not auto-decorated here. The convention is:
     *
     * - `pie-toolkit-*` for toolkit lifecycle (state load, providers,
     *   coordinator readiness, section-controller register/dispose, TTS
     *   bring-up, tool-config updates, etc.).
     * - `pie-tool-*` for individual tool events (provider init lifecycle,
     *   provider backend calls, tool-specific telemetry forwarded from
     *   `__pieTelemetry`).
     * - `pie-section-*` is reserved for section-player layout events
     *   surfaced via `attachInstrumentationEventBridge` and is not emitted
     *   from this method directly.
     *
     * Anything that does not fit a documented prefix is a deliberate decision
     * to be raised in code review, not a fallback. There is no "auto prefix"
     * here on purpose: it keeps every emit-site honest about which namespace
     * it owns, and lets `subscribeTelemetry` consumers compare against
     * documented strings without per-consumer normalization.
     */
    async emitTelemetry(eventName, payload) {
        try {
            await this.hooks.onTelemetry?.(eventName, payload);
        }
        catch (err) {
            console.warn("[ToolkitCoordinator] telemetry hook failed:", err);
        }
        const nextPayload = payload ? { ...payload } : undefined;
        for (const listener of this.telemetryListeners) {
            try {
                listener({
                    eventName,
                    payload: nextPayload ? { ...nextPayload } : undefined,
                });
            }
            catch (err) {
                console.warn("[ToolkitCoordinator] telemetry listener failed:", err);
            }
        }
    }
    emitSectionControllerLifecycle(event) {
        for (const listener of this.sectionControllerLifecycleListeners) {
            try {
                listener(event);
            }
            catch (error) {
                console.warn("[ToolkitCoordinator] section controller lifecycle listener failed:", error);
            }
        }
    }
    handleError(error, context) {
        const model = frameworkErrorFromCoordinatorContext({
            error,
            context,
        });
        this.frameworkErrorBus.reportFrameworkError(model);
    }
    /**
     * Subscribe to framework-error events emitted by this coordinator.
     *
     * See `ToolkitCoordinatorApi.subscribeFrameworkErrors` for the
     * contract. The bus is shared with the canonical
     * `onFrameworkError` lifecycle hook, so a listener registered here
     * sees the same fan-out the hook sees.
     */
    subscribeFrameworkErrors(listener) {
        return this.frameworkErrorBus.subscribeFrameworkErrors(listener);
    }
    /**
     * Report a framework-error model directly into this coordinator's bus.
     *
     * Used by embedding hosts (e.g. `<pie-assessment-toolkit>`) that
     * pre-construct their own framework-error model in a path that does
     * not go through `handleError` — for example, `runtime-init` failures
     * raised before any coordinator phase, or `tool-config` failures
     * synthesized from validation diagnostics.
     *
     * Hosts that already share their bus via the constructor's
     * `frameworkErrorBus` config field do not need to call this; their
     * own `bus.reportFrameworkError(model)` is observed here.
     *
     * @internal
     */
    reportFrameworkError(model) {
        this.frameworkErrorBus.reportFrameworkError(model);
    }
    getSectionControllerMapKey(key) {
        return `${key.assessmentId}::${key.sectionId}::${key.attemptId || ""}`;
    }
    createDefaultSectionPersistence() {
        const storage = (() => {
            try {
                if (typeof window === "undefined")
                    return null;
                return window.localStorage;
            }
            catch {
                return null;
            }
        })();
        const getStorageKey = (context) => {
            const { assessmentId, sectionId, attemptId } = context.key;
            return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId || "default"}`;
        };
        return {
            async loadSession(context) {
                if (!storage)
                    return null;
                const value = storage.getItem(getStorageKey(context));
                if (!value)
                    return null;
                try {
                    return JSON.parse(value);
                }
                catch {
                    return null;
                }
            },
            async saveSession(context, session) {
                if (!storage)
                    return;
                storage.setItem(getStorageKey(context), JSON.stringify(session));
            },
            async clearSession(context) {
                if (!storage)
                    return;
                storage.removeItem(getStorageKey(context));
            },
        };
    }
    async resolveSectionPersistence(context) {
        const cacheKey = this.getSectionControllerMapKey(context.key);
        const existing = this.sectionPersistenceStrategies.get(cacheKey);
        if (existing)
            return existing;
        const defaults = {
            createDefaultPersistence: () => this.createDefaultSectionPersistence(),
        };
        const strategy = (await this.hooks.createSectionSessionPersistence?.(context, defaults)) ??
            (await defaults.createDefaultPersistence());
        this.sectionPersistenceStrategies.set(cacheKey, strategy);
        return strategy;
    }
    setupStatePersistenceHooks() {
        this.elementToolStateStore.setOnStateChange((state) => {
            if (!this.hooks.saveToolState)
                return;
            void Promise.resolve(this.hooks.saveToolState(state)).catch((err) => {
                this.handleError(err, { phase: "state-save" });
            });
        });
    }
    async ensureStateLoaded() {
        if (this.stateLoaded)
            return;
        if (this.stateLoadPromise)
            return this.stateLoadPromise;
        this.stateLoadPromise = (async () => {
            const loader = this.hooks.loadToolState;
            if (!loader) {
                return;
            }
            try {
                const state = await loader();
                if (state && typeof state === "object") {
                    this.elementToolStateStore.loadState(state);
                }
                this.stateLoaded = true;
                await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
                    hasState: Boolean(state),
                });
            }
            catch (err) {
                this.handleError(err, { phase: "state-load" });
            }
        })().finally(() => {
            this.stateLoadPromise = undefined;
        });
        return this.stateLoadPromise;
    }
    /**
     * Register tool providers in the registry
     */
    _registerToolProviders() {
        const descriptorTools = this.getProviderDescriptorTools();
        for (const tool of descriptorTools) {
            void this.registerProviderFromTool(tool);
        }
    }
    getProviderDescriptorTools() {
        return this.toolRegistry.getAllTools().filter((tool) => !!tool.provider);
    }
    async registerProviderFromTool(tool) {
        const descriptor = tool.provider;
        if (!descriptor)
            return;
        const toolConfig = this.getToolConfig(tool.toolId) || undefined;
        if (toolConfig?.enabled === false)
            return;
        const providerId = descriptor.getProviderId?.(toolConfig) ??
            toolConfig?.provider?.id ??
            tool.toolId;
        if (this.toolProviderRegistry.has(providerId))
            return;
        const provider = descriptor.createProvider(toolConfig);
        const initConfig = descriptor.getInitConfig?.(toolConfig) ??
            toolConfig?.provider?.init ??
            {};
        const initConfigWithTelemetry = this.addToolTelemetryReporter({
            toolId: tool.toolId,
            providerId,
            initConfig,
        });
        const registryTelemetry = this.createToolTelemetryForwarder({
            toolId: tool.toolId,
            providerId,
        });
        const authFetcher = descriptor.getAuthFetcher?.(toolConfig) ??
            toolConfig?.provider?.runtime?.authFetcher;
        await this.registerProvider(providerId, {
            provider,
            config: initConfigWithTelemetry,
            lazy: descriptor.lazy ?? true,
            authFetcher,
            onTelemetry: registryTelemetry,
        });
    }
    createToolTelemetryForwarder(args) {
        return async (eventName, payload) => {
            await this.emitTelemetry(eventName, {
                ...(payload || {}),
                toolId: args.toolId,
                providerId: args.providerId,
            });
        };
    }
    addToolTelemetryReporter(args) {
        const configObject = args.initConfig && typeof args.initConfig === "object"
            ? { ...args.initConfig }
            : {};
        const existingReporter = typeof configObject.onTelemetry === "function"
            ? configObject.onTelemetry
            : null;
        const forwardTelemetry = this.createToolTelemetryForwarder({
            toolId: args.toolId,
            providerId: args.providerId,
        });
        configObject.onTelemetry = async (eventName, payload) => {
            if (existingReporter) {
                await existingReporter(eventName, payload);
            }
            await forwardTelemetry(eventName, payload);
        };
        return configObject;
    }
    async registerProvider(providerId, config) {
        try {
            this.toolProviderRegistry.register(providerId, config);
            const meta = {
                providerId,
                providerName: config.provider.providerName,
            };
            await this.hooks.onProviderRegistered?.(providerId, meta);
            await this.emitTelemetry("pie-toolkit-provider-registered", {
                providerId,
                providerName: config.provider.providerName,
            });
        }
        catch (err) {
            console.warn(`[ToolkitCoordinator] Failed to register provider "${providerId}":`, err);
            this.handleError(err, { phase: "provider-register", providerId });
        }
    }
    async ensureProviderReady(providerId) {
        const existing = this.providerInitPromises.get(providerId);
        if (existing)
            return existing;
        const promise = (async () => {
            const provider = await this.toolProviderRegistry.getProvider(providerId, false);
            const meta = {
                providerId,
                providerName: provider.providerName,
            };
            try {
                await this.hooks.onProviderInitStart?.(providerId, meta);
                await this.toolProviderRegistry.initialize(providerId);
                await this.hooks.onProviderReady?.(providerId, meta);
                await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
                return provider;
            }
            catch (err) {
                const error = err instanceof Error ? err : new Error(String(err));
                this.handleError(error, { phase: "provider-init", providerId });
                throw error;
            }
        })().finally(() => {
            this.providerInitPromises.delete(providerId);
        });
        this.providerInitPromises.set(providerId, promise);
        return promise;
    }
    setHooks(hooks) {
        Object.assign(this.hooks, hooks);
        this.setupStatePersistenceHooks();
        if (hooks.onCoordinatorReady && this.isReady()) {
            void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
                this.handleError(err, { phase: "coordinator-ready" });
            });
        }
    }
    subscribeTelemetry(listener) {
        this.telemetryListeners.add(listener);
        return () => {
            this.telemetryListeners.delete(listener);
        };
    }
    getSectionController(args) {
        const key = {
            assessmentId: this.assessmentId,
            sectionId: args.sectionId,
            attemptId: args.attemptId,
        };
        return this.sectionControllers.get(this.getSectionControllerMapKey(key));
    }
    subscribeSectionEvents(args) {
        // Phase D: subscriptions follow the toolkit's *active section
        // cohort* automatically. We bind the listener to whichever
        // controller is currently active and re-bind it on every
        // `getOrCreateSectionController` / `disposeSectionController`
        // transition, with snapshot replay on each migration.
        if (this.activeCohortMapKey === null) {
            throw new Error("[ToolkitCoordinator] subscribeSectionEvents requires an active section cohort; call getOrCreateSectionController first.");
        }
        const previous = this.activeSubscriptions.get(args.listener);
        if (previous) {
            previous.unsubscribeCurrent?.();
            previous.unsubscribeCurrent = null;
        }
        const sub = {
            listener: args.listener,
            eventTypes: args.eventTypes,
            itemIds: args.itemIds,
            unsubscribeCurrent: null,
        };
        this.activeSubscriptions.set(args.listener, sub);
        const controller = this.sectionControllers.get(this.activeCohortMapKey);
        if (controller) {
            this.bindSubscriptionToController(sub, controller);
        }
        let detached = false;
        return () => {
            if (detached)
                return;
            detached = true;
            sub.unsubscribeCurrent?.();
            sub.unsubscribeCurrent = null;
            // Only delete if the registry still points at *this* sub. A
            // re-subscribe with the same listener replaces the entry; an
            // old disposer must not stomp the new one.
            if (this.activeSubscriptions.get(args.listener) === sub) {
                this.activeSubscriptions.delete(args.listener);
            }
        };
    }
    /**
     * Bind a Phase D subscription to a section controller. Detaches any
     * prior controller binding the subscription was holding, attaches a
     * fresh `controller.subscribe(...)` callback, and replays the
     * canonical late-subscribe sequence (content-loaded × N then
     * section-loading-complete) so the listener observes the same
     * ordering a fresh subscriber would have seen.
     *
     * Listener throws are caught and `console.warn`-logged; the throw
     * does not interrupt fan-out to the remaining listeners (matches
     * the {@link FrameworkErrorBus} isolation pattern).
     */
    bindSubscriptionToController(sub, controller) {
        sub.unsubscribeCurrent?.();
        sub.unsubscribeCurrent = null;
        const subscribe = controller.subscribe;
        if (typeof subscribe !== "function")
            return;
        const predicate = this.buildSectionEventPredicate(sub);
        sub.unsubscribeCurrent = subscribe.call(controller, (event) => {
            if (!predicate(event))
                return;
            this.deliverSectionEventSafely(sub.listener, event);
        });
        const contentLoadedReplays = this.buildContentLoadedReplayEvents(controller);
        for (const event of contentLoadedReplays) {
            if (predicate(event)) {
                this.deliverSectionEventSafely(sub.listener, event);
            }
        }
        const loadingComplete = this.buildLoadingCompleteReplayEvent(controller);
        if (loadingComplete && predicate(loadingComplete)) {
            this.deliverSectionEventSafely(sub.listener, loadingComplete);
        }
    }
    deliverSectionEventSafely(listener, event) {
        try {
            listener(event);
        }
        catch (error) {
            console.warn("[ToolkitCoordinator] section-event listener failed:", error);
        }
    }
    /**
     * Mark the given map key as the active section cohort. Called from
     * `getOrCreateSectionController` (both create-new and resolve-existing
     * paths) so a cohort transition through either path migrates active
     * subscriptions.
     *
     * Same-cohort re-entry is a no-op so a same-cohort `updateInput`
     * (PnP toggle, prompt edit) doesn't double-replay snapshots to
     * already-bound listeners.
     */
    setActiveCohort(mapKey) {
        if (this.activeCohortMapKey === mapKey)
            return;
        this.activeCohortMapKey = mapKey;
        const controller = this.sectionControllers.get(mapKey);
        if (!controller)
            return;
        // Snapshot the subscription list before iterating: a listener may
        // synchronously call `subscribeSectionEvents(...)` from inside its
        // replay delivery, which inserts a new entry into
        // `activeSubscriptions`. Map iteration yields keys inserted
        // during the loop; without snapshotting, the inner subscribe path
        // would bind the new listener once (correctly, with replay) and
        // the outer loop would then visit it a second time, double-
        // replaying the snapshot. Snapshot keeps "replay once per cohort
        // transition" intact.
        for (const sub of Array.from(this.activeSubscriptions.values())) {
            this.bindSubscriptionToController(sub, controller);
        }
    }
    setActiveCohortIfLatestRequest(mapKey) {
        if (this.latestRequestedActiveCohortMapKey !== mapKey)
            return;
        this.setActiveCohort(mapKey);
    }
    suspendActiveCohortIfSuperseded(mapKey) {
        if (this.activeCohortMapKey === null)
            return;
        if (this.activeCohortMapKey === mapKey)
            return;
        this.activeCohortMapKey = null;
        for (const sub of Array.from(this.activeSubscriptions.values())) {
            sub.unsubscribeCurrent?.();
            sub.unsubscribeCurrent = null;
        }
    }
    /**
     * Clear the active cohort if `mapKey` matches it, detaching every
     * active subscription's current controller binding. Subscriptions
     * stay in the registry — a subsequent `setActiveCohort(...)` will
     * re-bind them to the new controller.
     */
    clearActiveCohortIfMatches(mapKey) {
        if (this.activeCohortMapKey !== mapKey)
            return;
        this.activeCohortMapKey = null;
        // Symmetric snapshot for safety: a disposer fired during detach
        // could mutate `activeSubscriptions`. Detach is silent (no listener
        // fan-out), so re-entrancy exposure is lower than `setActiveCohort`,
        // but the snapshot keeps both paths uniform.
        for (const sub of Array.from(this.activeSubscriptions.values())) {
            sub.unsubscribeCurrent?.();
            sub.unsubscribeCurrent = null;
        }
    }
    buildSectionEventPredicate(args) {
        const eventTypeFilter = args.eventTypes ? new Set(args.eventTypes) : null;
        const itemIdFilter = args.itemIds ? new Set(args.itemIds) : null;
        return (event) => {
            if (eventTypeFilter || itemIdFilter) {
                const eventType = event?.type || null;
                if (eventTypeFilter &&
                    (!eventType || !eventTypeFilter.has(eventType))) {
                    return false;
                }
                if (itemIdFilter) {
                    const hasMatchingItem = Array.from(this.collectEventItemIds(event)).some((itemId) => itemIdFilter.has(itemId));
                    if (!hasMatchingItem) {
                        return false;
                    }
                }
            }
            return true;
        };
    }
    collectEventItemIds(event) {
        const itemIds = new Set();
        if ("itemId" in event && typeof event.itemId === "string") {
            itemIds.add(event.itemId);
        }
        if ("canonicalItemId" in event &&
            typeof event.canonicalItemId === "string") {
            itemIds.add(event.canonicalItemId);
        }
        if ("currentItemId" in event && typeof event.currentItemId === "string") {
            itemIds.add(event.currentItemId);
        }
        if ("previousItemId" in event && typeof event.previousItemId === "string") {
            itemIds.add(event.previousItemId);
        }
        return itemIds;
    }
    /**
     * Build replay `content-loaded` events for renderables the controller has
     * already finished loading.
     *
     * Strict by design: only renderables explicitly reported in
     * `runtimeState.loadedRenderables` are replayed. Synthetic test harnesses
     * that omit the field (or controllers from older revisions that did not
     * populate it) get an empty replay set rather than a fabricated one — that
     * preserves the contract that replays mirror events that actually fired.
     *
     * Order follows registration order from
     * `SectionController.collectLoadedRenderableSnapshot`, which mirrors the
     * order live `content-loaded` events were emitted in.
     */
    buildContentLoadedReplayEvents(controller) {
        const runtimeState = controller.getRuntimeState?.();
        const loadedRenderables = runtimeState?.loadedRenderables;
        if (!Array.isArray(loadedRenderables) || loadedRenderables.length === 0) {
            return [];
        }
        const currentItemIndex = typeof runtimeState?.currentItemIndex === "number" &&
            Number.isFinite(runtimeState.currentItemIndex)
            ? runtimeState.currentItemIndex
            : 0;
        const timestamp = Date.now();
        const events = [];
        for (const renderable of loadedRenderables) {
            if (!renderable)
                continue;
            const itemId = renderable.itemId;
            const canonicalItemId = typeof renderable.canonicalItemId === "string" &&
                renderable.canonicalItemId
                ? renderable.canonicalItemId
                : itemId;
            if (typeof itemId !== "string" || !itemId)
                continue;
            events.push({
                type: "content-loaded",
                contentKind: renderable.contentKind ?? "unknown",
                itemId,
                canonicalItemId,
                currentItemIndex,
                timestamp,
            });
        }
        return events;
    }
    buildLoadingCompleteReplayEvent(controller) {
        const runtimeState = controller.getRuntimeState?.();
        if (runtimeState?.loadingComplete !== true)
            return null;
        const totalRegistered = typeof runtimeState.totalRegistered === "number" &&
            Number.isFinite(runtimeState.totalRegistered)
            ? Math.max(0, runtimeState.totalRegistered)
            : 0;
        const totalLoaded = typeof runtimeState.totalLoaded === "number" &&
            Number.isFinite(runtimeState.totalLoaded)
            ? Math.max(0, runtimeState.totalLoaded)
            : totalRegistered;
        return {
            type: "section-loading-complete",
            totalRegistered,
            totalLoaded,
            currentItemIndex: runtimeState.currentItemIndex,
            timestamp: Date.now(),
        };
    }
    /**
     * Subscribe to item-scoped controller events.
     *
     * Same active-cohort binding contract as
     * {@link subscribeSectionEvents}; defaults `eventTypes` to the
     * item-scoped subset.
     */
    subscribeItemEvents(args) {
        return this.subscribeSectionEvents({
            eventTypes: args.eventTypes || SECTION_ITEM_EVENT_TYPES,
            itemIds: args.itemIds,
            listener: args.listener,
        });
    }
    /**
     * Subscribe to section-scoped controller events.
     *
     * Same active-cohort binding contract as
     * {@link subscribeSectionEvents}; defaults `eventTypes` to the
     * section-scoped subset. Section-scoped events do not carry item
     * identifiers, so this helper intentionally does not expose
     * `itemIds` filtering.
     */
    subscribeSectionLifecycleEvents(args) {
        return this.subscribeSectionEvents({
            eventTypes: args.eventTypes || SECTION_SCOPED_EVENT_TYPES,
            listener: args.listener,
        });
    }
    onSectionControllerLifecycle(listener) {
        this.sectionControllerLifecycleListeners.add(listener);
        return () => {
            this.sectionControllerLifecycleListeners.delete(listener);
        };
    }
    async getOrCreateSectionController(args) {
        const key = {
            assessmentId: this.assessmentId,
            sectionId: args.sectionId,
            attemptId: args.attemptId,
        };
        const mapKey = this.getSectionControllerMapKey(key);
        this.latestRequestedActiveCohortMapKey = mapKey;
        this.suspendActiveCohortIfSuperseded(mapKey);
        const existingController = await this.resolveExistingSectionController({
            mapKey,
            key,
            input: args.input,
            updateExisting: args.updateExisting,
        });
        if (existingController)
            return existingController;
        const existingPromise = this.sectionControllerInitPromises.get(mapKey);
        if (existingPromise)
            return existingPromise;
        const initPromise = this.initializeNewSectionController({
            args,
            key,
            mapKey,
        })
            .catch((err) => {
            this.handleSectionControllerInitError(err, args);
            throw err;
        })
            .finally(() => {
            this.sectionControllerInitPromises.delete(mapKey);
        });
        this.sectionControllerInitPromises.set(mapKey, initPromise);
        return initPromise;
    }
    async resolveExistingSectionController(args) {
        const existingController = this.sectionControllers.get(args.mapKey);
        if (!existingController)
            return undefined;
        this.sectionControllerKeys.set(args.mapKey, args.key);
        if (args.updateExisting !== false) {
            // Existing controllers keep their in-memory session state across
            // input refresh; updateInput should rebuild composition/runtime view
            // without resetting responses.
            await existingController.updateInput?.(args.input);
        }
        // PIE-512 Phase D: a `getOrCreateSectionController` call that
        // resolves to a previously-created controller still represents a
        // cohort transition from the toolkit's perspective (same-cohort
        // re-entry is a no-op inside `setActiveCohort`). Active
        // subscriptions migrate here so a host that subscribed once on
        // `toolkit-ready` keeps receiving events after navigating back
        // to a section it visited earlier.
        this.setActiveCohortIfLatestRequest(args.mapKey);
        return existingController;
    }
    createSectionControllerContext(args) {
        return {
            key: args.key,
            coordinator: this,
            input: args.input,
        };
    }
    async initializeNewSectionController(args) {
        const context = this.createSectionControllerContext({
            key: args.key,
            input: args.args.input,
        });
        const persistence = await this.resolveSectionPersistence(context);
        const defaults = {
            createDefaultController: args.args.createDefaultController,
        };
        const controller = (await this.hooks.createSectionController?.(context, defaults)) ??
            (await defaults.createDefaultController());
        await controller.configureSessionPersistence?.({
            strategy: persistence,
            context,
        });
        await controller.initialize?.(args.args.input);
        await controller.hydrate?.();
        await this.finalizeSectionControllerReady({
            mapKey: args.mapKey,
            key: args.key,
            context,
            controller,
        });
        return controller;
    }
    async finalizeSectionControllerReady(args) {
        this.sectionControllers.set(args.mapKey, args.controller);
        this.sectionControllerKeys.set(args.mapKey, args.key);
        // PIE-512 Phase D: a freshly-resolved controller becomes the
        // active cohort. Active subscriptions migrate to it before the
        // `ready` lifecycle event and `onSectionControllerReady` hook
        // fire so any synchronous post-ready work observes a coherent
        // active-cohort view.
        this.setActiveCohortIfLatestRequest(args.mapKey);
        this.emitSectionControllerLifecycle({
            type: "ready",
            key: args.key,
            controller: args.controller,
        });
        await this.hooks.onSectionControllerReady?.(args.context, args.controller);
        await this.emitTelemetry("pie-toolkit-section-controller-ready", {
            assessmentId: args.key.assessmentId,
            sectionId: args.key.sectionId,
            attemptId: args.key.attemptId,
        });
    }
    handleSectionControllerInitError(err, args) {
        this.handleError(err, {
            phase: "section-controller-init",
            details: {
                sectionId: args.sectionId,
                attemptId: args.attemptId,
            },
        });
    }
    async disposeSectionController(args) {
        const key = {
            assessmentId: this.assessmentId,
            sectionId: args.sectionId,
            attemptId: args.attemptId,
        };
        const mapKey = this.getSectionControllerMapKey(key);
        const controller = this.sectionControllers.get(mapKey);
        if (!controller)
            return;
        // PIE-512 Phase D: if the disposing cohort is the active one,
        // detach all listener-controller bindings before the controller
        // itself disposes. The subscription registry stays intact so a
        // later `getOrCreateSectionController(...)` re-binds the same
        // listeners to the new controller.
        this.clearActiveCohortIfMatches(mapKey);
        const context = this.createSectionControllerContext({
            key,
            input: undefined,
        });
        try {
            await this.runSectionControllerDisposePipeline({
                key,
                context,
                controller,
                persistBeforeDispose: args.persistBeforeDispose,
            });
        }
        catch (err) {
            this.handleError(err, {
                phase: "section-controller-dispose",
                details: {
                    sectionId: args.sectionId,
                    attemptId: args.attemptId,
                },
            });
        }
        finally {
            await this.finalizeSectionControllerDispose({
                mapKey,
                key,
                context,
                clearPersistence: args.clearPersistence,
            });
        }
    }
    async runSectionControllerDisposePipeline(args) {
        if (args.persistBeforeDispose !== false) {
            await args.controller.persist?.();
        }
        await args.controller.dispose?.();
        await this.hooks.onSectionControllerDispose?.(args.context, args.controller);
        await this.emitTelemetry("pie-toolkit-section-controller-disposed", {
            assessmentId: args.key.assessmentId,
            sectionId: args.key.sectionId,
            attemptId: args.key.attemptId,
        });
    }
    async finalizeSectionControllerDispose(args) {
        this.sectionControllers.delete(args.mapKey);
        this.sectionControllerKeys.delete(args.mapKey);
        this.emitSectionControllerLifecycle({
            type: "disposed",
            key: args.key,
        });
        if (args.clearPersistence) {
            const strategy = this.sectionPersistenceStrategies.get(args.mapKey);
            await strategy?.clearSession?.(args.context);
        }
        this.sectionPersistenceStrategies.delete(args.mapKey);
    }
    /**
     * Initialize TTS service with provider
     */
    async ensureTTSReady(config) {
        await this.waitForPendingTTSReconfigure();
        if (this.ttsInitialized)
            return;
        if (this.ttsInitPromise)
            return this.ttsInitPromise;
        this.ttsInitPromise = this._initializeTTS(config).finally(() => {
            this.ttsInitPromise = undefined;
        });
        return this.ttsInitPromise;
    }
    async waitForPendingTTSReconfigure() {
        let pending = this.ttsReconfigurePromise;
        while (pending) {
            await pending;
            pending = this.ttsReconfigurePromise;
        }
    }
    async _initializeTTS(config) {
        if (this.ttsInitialized)
            return;
        const resolvedToolConfig = this.resolveTTSToolConfig(config);
        const runtimeSettings = resolveTTSRuntimeSettings(resolvedToolConfig);
        const resolvedBackend = resolveTTSBackend(runtimeSettings);
        const runtimeTTSConfig = buildRuntimeTTSConfig(runtimeSettings);
        await this.hooks.onBeforeTTSInit?.({
            phase: "tts-init",
            details: {
                backend: resolvedBackend,
            },
        });
        await this.emitTelemetry("pie-toolkit-tts-init-start", {
            backend: resolvedBackend,
        });
        await this.emitTelemetry("pie-tool-init-start", {
            toolId: "textToSpeech",
            operation: "tts-init",
            backend: resolvedBackend,
        });
        // Try to use TTS provider from registry if available
        if (this.toolProviderRegistry.has("tts")) {
            try {
                const ttsProvider = await this.ensureProviderReady("tts");
                const providerInstance = await ttsProvider.createInstance();
                await this.initializeTTSService(providerInstance, runtimeTTSConfig);
                await this.emitTelemetry("pie-toolkit-tts-init-success", {
                    provider: "registry",
                });
                await this.emitTelemetry("pie-tool-init-success", {
                    toolId: "textToSpeech",
                    operation: "tts-init",
                    backend: resolvedBackend,
                    provider: "registry",
                });
                console.log("[ToolkitCoordinator] TTS initialized via ToolProviderRegistry");
                return;
            }
            catch (error) {
                const normalized = error instanceof Error ? error : new Error(String(error));
                await this.emitTelemetry("pie-tool-init-error", {
                    toolId: "textToSpeech",
                    providerId: "tts",
                    operation: "tts-init",
                    backend: resolvedBackend,
                    errorType: "TTSRegistryInitError",
                    message: normalized.message,
                    recovered: true,
                });
                await this.emitTelemetry("pie-tool-init-fallback", {
                    toolId: "textToSpeech",
                    providerId: "tts",
                    operation: "tts-init",
                    backend: resolvedBackend,
                    fromProvider: "registry",
                    toProvider: "browser",
                    reason: normalized.message,
                });
                console.warn("[ToolkitCoordinator] Failed to initialize TTS via registry, falling back to browser provider:", normalized);
            }
        }
        // Fallback to browser provider
        const provider = new BrowserTTSProvider();
        try {
            await this.initializeTTSService(provider, runtimeTTSConfig);
            await this.emitTelemetry("pie-toolkit-tts-init-success", {
                provider: "browser-fallback",
            });
            await this.emitTelemetry("pie-tool-init-success", {
                toolId: "textToSpeech",
                operation: "tts-init",
                backend: resolvedBackend,
                provider: "browser-fallback",
            });
        }
        catch (error) {
            const normalized = error instanceof Error ? error : new Error(String(error));
            this.handleError(normalized, { phase: "tts-init" });
            await this.emitTelemetry("pie-toolkit-tts-init-error", {
                message: normalized.message,
            });
            await this.emitTelemetry("pie-tool-init-error", {
                toolId: "textToSpeech",
                operation: "tts-init",
                backend: resolvedBackend,
                errorType: "TTSInitError",
                message: normalized.message,
            });
            throw normalized;
        }
    }
    resolveTTSToolConfig(config) {
        return config || this.getTTSConfigFromProviders() || {};
    }
    async initializeTTSService(provider, config) {
        const nextProviderOptions = {
            ...((config.providerOptions || {}) || {}),
            __pieTelemetry: async (eventName, payload) => {
                await this.emitTelemetry(eventName, {
                    toolId: "textToSpeech",
                    ...(payload || {}),
                });
            },
        };
        const nextConfig = {
            ...config,
            providerOptions: nextProviderOptions,
        };
        await this.ttsService.initialize(provider, nextConfig);
        await this.ensureBrowserVoicesReady(provider);
        this.ttsService.setCatalogResolver(this.catalogResolver);
        this.ttsInitialized = true;
        await this.hooks.onTTSReady?.();
    }
    async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
        if (provider.providerId !== "browser")
            return;
        if (typeof window === "undefined")
            return;
        if (!("speechSynthesis" in window))
            return;
        const synth = window.speechSynthesis;
        const voices = synth.getVoices();
        if (voices.length > 0)
            return;
        await new Promise((resolve) => {
            let settled = false;
            const canUseEventTarget = typeof synth.addEventListener === "function" &&
                typeof synth.removeEventListener === "function";
            const canUseHandler = !canUseEventTarget && "onvoiceschanged" in synth;
            const previousHandler = canUseHandler ? synth.onvoiceschanged : null;
            let assignedHandler = false;
            const finish = () => {
                if (settled)
                    return;
                settled = true;
                window.clearTimeout(timeoutId);
                if (canUseEventTarget) {
                    synth.removeEventListener("voiceschanged", onVoicesChanged);
                }
                else if (assignedHandler && synth.onvoiceschanged === onVoicesChanged) {
                    synth.onvoiceschanged = previousHandler;
                }
                resolve();
            };
            const onVoicesChanged = (event) => {
                if (!canUseEventTarget && typeof previousHandler === "function" && event) {
                    previousHandler.call(synth, event);
                }
                finish();
            };
            const timeoutId = window.setTimeout(finish, timeoutMs);
            if (canUseEventTarget) {
                synth.addEventListener("voiceschanged", onVoicesChanged, {
                    once: true,
                });
            }
            else if (canUseHandler) {
                synth.onvoiceschanged = onVoicesChanged;
                assignedHandler = true;
            }
        });
        const voicesAfterWait = synth.getVoices();
        await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
            voiceCount: voicesAfterWait.length,
            timedOut: voicesAfterWait.length === 0,
        });
    }
    /**
     * Get all services as a bundle for section player convenience.
     * Section player can extract and pass services to child components.
     */
    getServiceBundle() {
        return {
            ttsService: this.ttsService,
            toolCoordinator: this.toolCoordinator,
            highlightCoordinator: this.highlightCoordinator,
            elementToolStateStore: this.elementToolStateStore,
            catalogResolver: this.catalogResolver,
            toolProviderRegistry: this.toolProviderRegistry,
        };
    }
    /**
     * Get a tool provider from the registry
     *
     * @param providerId Provider identifier
     * @returns Tool provider instance
     */
    async getToolProvider(providerId) {
        return this.ensureProviderReady(providerId);
    }
    async waitUntilReady() {
        if (this.isReady())
            return;
        if (this.coordinatorReadyPromise)
            return this.coordinatorReadyPromise;
        this.coordinatorReadyPromise = (async () => {
            await this.ensureStateLoaded();
            const ttsConfig = this.getTTSConfigFromProviders();
            if (ttsConfig?.enabled !== false) {
                await this.ensureTTSReady(ttsConfig);
            }
            if (!this.coordinatorReadyNotified) {
                this.coordinatorReadyNotified = true;
                await this.hooks.onCoordinatorReady?.(this);
                await this.emitTelemetry("pie-toolkit-coordinator-ready", {
                    assessmentId: this.assessmentId,
                });
            }
        })().finally(() => {
            this.coordinatorReadyPromise = undefined;
        });
        return this.coordinatorReadyPromise;
    }
    isReady() {
        return this.getInitStatus().coordinator;
    }
    getInitStatus() {
        const providers = {};
        for (const providerId of this.toolProviderRegistry.getProviderIds()) {
            providers[providerId] =
                this.toolProviderRegistry.isInitialized(providerId);
        }
        const ttsConfig = this.getTTSConfigFromProviders();
        return {
            tts: this.ttsInitialized,
            stateLoaded: this.stateLoaded || !this.hooks.loadToolState,
            coordinator: (this.stateLoaded || !this.hooks.loadToolState) &&
                (this.ttsInitialized || ttsConfig?.enabled === false),
            providers,
        };
    }
    getTTSConfigFromProviders() {
        const providers = this.config.tools?.providers || {};
        return providers.textToSpeech;
    }
    assertCanonicalToolId(toolId) {
        if (typeof toolId !== "string" || toolId.trim().length === 0) {
            throw new Error("Tool id must be a non-empty string.");
        }
        if (toolId === "tts") {
            throw new Error(`Tool id "tts" is no longer supported. Use "textToSpeech".`);
        }
        if (!this.toolRegistry.get(toolId)) {
            throw new Error(`Unknown tool id "${toolId}".`);
        }
    }
    installToolContextResolvers(resolvers) {
        if (!resolvers)
            return;
        for (const [toolId, resolver] of Object.entries(resolvers)) {
            if (resolver == null)
                continue;
            this.assertCanonicalToolId(toolId);
            if (typeof resolver !== "function") {
                throw new Error(`Invalid tool context resolver for "${toolId}": expected a function.`);
            }
            this.toolContextResolvers.set(toolId, resolver);
        }
    }
    setToolContextResolvers(resolvers) {
        this.toolContextResolvers.clear();
        this.installToolContextResolvers(resolvers ?? undefined);
        this.notifyToolContextResolverChange();
    }
    notifyToolContextResolverChange() {
        for (const listener of this.toolContextResolverChangeListeners) {
            try {
                listener();
            }
            catch (error) {
                console.warn("[ToolkitCoordinator] Tool context resolver listener threw:", error);
            }
        }
    }
    /**
     * Check if a tool is enabled.
     * Tools are enabled by default unless explicitly disabled.
     *
     * @param toolId Tool identifier (e.g., 'textToSpeech', 'answerEliminator')
     * @returns True if tool is enabled
     */
    isToolEnabled(toolId) {
        this.assertCanonicalToolId(toolId);
        const toolConfig = this.config.tools?.providers?.[toolId];
        // Enabled by default unless explicitly set to false
        return toolConfig?.enabled !== false;
    }
    /**
     * Get tool configuration.
     *
     * @param toolId Tool identifier
     * @returns Tool configuration or null if not configured
     */
    getToolConfig(toolId) {
        this.assertCanonicalToolId(toolId);
        return (this.config.tools?.providers?.[toolId] || null);
    }
    updateToolConfig(toolId, updates) {
        // Update config
        this.assertCanonicalToolId(toolId);
        const current = this.getToolConfig(toolId) || {};
        if (!this.config.tools) {
            this.config.tools = normalizeAndValidateToolsConfig(undefined, {
                strictness: this.config.toolConfigStrictness ?? "error",
                source: "ToolkitCoordinator.updateToolConfig",
                toolRegistry: this.toolRegistry,
            }).config;
        }
        if (!this.config.tools.providers) {
            this.config.tools.providers = {};
        }
        const nextProviders = {
            ...(this.config.tools.providers || {}),
            [toolId]: mergeToolConfigUpdate(toolId, current, updates),
        };
        const validated = normalizeAndValidateToolsConfig({
            ...this.config.tools,
            providers: nextProviders,
        }, {
            strictness: this.config.toolConfigStrictness ?? "error",
            source: "ToolkitCoordinator.updateToolConfig",
            toolRegistry: this.toolRegistry,
        });
        this.config.tools = validated.config;
        // M8 PR 2 — keep the policy engine's tools input in lockstep
        // with the validated coordinator config. The engine emits an
        // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
        // re-decide without us managing a parallel pub/sub.
        this.policyEngine.updateInputs({
            tools: this.config.tools,
        });
        void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
        // Apply configuration changes to services
        this._applyToolConfigChange(toolId, updates);
    }
    /**
     * Update the enabled tool list for one placement level.
     *
     * This is the generic placement companion to {@link updateToolConfig}:
     * it validates the next canonical tools config, keeps the policy
     * engine in lockstep, and emits the same policy-change event used by
     * live toolbars/debug panels.
     */
    updateToolPlacement(level, toolIds) {
        this.updateToolsPlacement({ [level]: [...toolIds] });
    }
    /**
     * Patch one or more placement levels in the canonical tools config.
     */
    updateToolsPlacement(partial) {
        if (!this.config.tools) {
            this.config.tools = normalizeAndValidateToolsConfig(undefined, {
                strictness: this.config.toolConfigStrictness ?? "error",
                source: "ToolkitCoordinator.updateToolsPlacement",
                toolRegistry: this.toolRegistry,
            }).config;
        }
        const currentPlacement = this.config.tools?.placement ?? {
            section: [],
            item: [],
            passage: [],
        };
        const nextPlacement = {
            section: [...(partial.section ?? currentPlacement.section ?? [])],
            item: [...(partial.item ?? currentPlacement.item ?? [])],
            passage: [...(partial.passage ?? currentPlacement.passage ?? [])],
        };
        const validated = normalizeAndValidateToolsConfig({
            ...this.config.tools,
            placement: nextPlacement,
        }, {
            strictness: this.config.toolConfigStrictness ?? "error",
            source: "ToolkitCoordinator.updateToolsPlacement",
            toolRegistry: this.toolRegistry,
        });
        validated.config.placement = nextPlacement;
        this.config.tools = validated.config;
        this.policyEngine.updateInputs({
            tools: this.config.tools,
        });
    }
    /**
     * Resolve the visible tool set for a given placement level + scope.
     *
     * Thin shim over the owned tool-policy engine.
     */
    decideToolPolicy(request) {
        return this.policyEngine.decide(request);
    }
    /**
     * Subscribe to policy-engine change events. Fires whenever the
     * coordinator's bound inputs change (`updateToolConfig`,
     * `updateToolPlacement`, `updateAssessment`, `updateCurrentItemRef`,
     * `setPnpEnforcement`) or a custom `PolicySource` is registered /
     * removed via {@link registerPolicySource}.
     *
     * The listener receives a `ToolPolicyChangeEvent` with the event
     * `reason` and a frozen snapshot of the engine inputs. Listeners
     * that want the new visible tool set should call
     * {@link decideToolPolicy} with their level / scope.
     *
     * Note: the engine itself can also emit `reason: "disposed"`, but
     * the coordinator does not dispose its engine on teardown today,
     * so subscribers attached via this method will not observe that
     * reason. Detach via the returned unsubscribe function instead of
     * relying on a `disposed` event.
     */
    onPolicyChange(listener) {
        return this.policyEngine.onPolicyChange(listener);
    }
    /**
     * Bind (or clear) the active assessment for PNP/profile policy decisions.
     *
     * Under auto-mode (no host override via {@link setPnpEnforcement}),
     * the coordinator promotes the engine to `pnpEnforcement: "on"`
     * iff the assessment carries any profile precedence material
     * (`personalNeedsProfile`, `settings.districtPolicy`,
     * `settings.testAdministration`) or the currently-bound item ref
     * carries item-level profile policy inputs. A bare assessment record (just
     * `id` / `name`, no PNP, no settings) keeps `"off"`.
     *
     * The host override set via {@link setPnpEnforcement} is sticky
     * across assessment swaps; calling with `null` clears the binding
     * and re-runs the auto-mode helper.
     */
    updateAssessment(assessment) {
        this.boundAssessment = assessment;
        this.policyEngine.updateInputs({
            assessment,
            pnpEnforcement: this.resolveEffectivePnpEnforcement(),
        });
    }
    /**
     * Bind (or clear) the current item reference for policy decisions.
     *
     * Used by item-level profile gates (item `requiredTools` /
     * `restrictedTools` / `toolParameters`). Item-level profile material
     * also feeds {@link resolveEffectivePnpEnforcement} — navigating
     * to an item with profile settings can flip auto-mode to `"on"` even
     * when the parent assessment carries no profile block of its own.
     */
    updateCurrentItemRef(itemRef) {
        this.boundCurrentItemRef = itemRef;
        this.policyEngine.updateInputs({
            currentItemRef: itemRef,
            pnpEnforcement: this.resolveEffectivePnpEnforcement(),
        });
    }
    /**
     * Override the auto-mode PNP/profile enforcement decision.
     */
    setPnpEnforcement(mode) {
        this.pnpEnforcementOverride = mode;
        this.policyEngine.updateInputs({
            pnpEnforcement: this.resolveEffectivePnpEnforcement(),
        });
    }
    /**
     * Get the policy engine inputs currently driving decisions. Useful
     * for debugging / instrumentation; do not mutate.
     */
    getPolicyInputs() {
        return this.policyEngine.getInputs();
    }
    /**
     * Register a custom {@link PolicySource} with the owned policy
     * engine. The source participates in every subsequent
     * {@link decideToolPolicy} call until disposed (the returned
     * function detaches and emits a `policy-source-removed` event).
     *
     * Delegates verbatim to
     * {@link ToolPolicyEngine.registerPolicySource} — see that
     * method for the full contract (event ordering, idempotency of
     * the returned dispose function, and how registered sources
     * compose with the built-in PNP policy source).
     */
    registerPolicySource(source) {
        return this.policyEngine.registerPolicySource(source);
    }
    /**
     * Register a host-owned resolver for one tool's render context.
     *
     * Resolvers are evaluated only for tools that already survived the
     * framework policy pipeline. They can hide the tool for the current
     * scope or attach render params consumed by the packaged registration;
     * they cannot re-enable tools blocked by placement, provider config,
     * host policy, or PNP/profile rules.
     */
    registerToolContextResolver(toolId, resolver) {
        this.assertCanonicalToolId(toolId);
        if (typeof resolver !== "function") {
            throw new Error(`Invalid tool context resolver for "${toolId}": expected a function.`);
        }
        this.toolContextResolvers.set(toolId, resolver);
        this.notifyToolContextResolverChange();
        return () => {
            if (this.toolContextResolvers.get(toolId) !== resolver)
                return;
            this.toolContextResolvers.delete(toolId);
            this.notifyToolContextResolverChange();
        };
    }
    hasToolContextResolver(toolId) {
        this.assertCanonicalToolId(toolId);
        return this.toolContextResolvers.has(toolId);
    }
    resolveToolContext(context) {
        this.assertCanonicalToolId(context.toolId);
        const resolver = this.toolContextResolvers.get(context.toolId);
        if (!resolver)
            return null;
        let result;
        try {
            result = resolver(context);
        }
        catch (error) {
            console.error(`[ToolkitCoordinator] Tool context resolver for "${context.toolId}" threw:`, error);
            return {
                toolId: context.toolId,
                visible: false,
                params: {},
                reason: "Tool context resolver threw.",
            };
        }
        if (!result) {
            return {
                toolId: context.toolId,
                visible: true,
                params: {},
            };
        }
        const params = result.params &&
            typeof result.params === "object" &&
            !Array.isArray(result.params)
            ? result.params
            : {};
        return {
            toolId: context.toolId,
            visible: result.visible !== false,
            params,
            reason: typeof result.reason === "string" ? result.reason : undefined,
        };
    }
    onToolContextResolverChange(listener) {
        this.toolContextResolverChangeListeners.add(listener);
        return () => {
            this.toolContextResolverChangeListeners.delete(listener);
        };
    }
    /**
     * Compute the effective PNP/profile enforcement mode given the explicit
     * host override and the auto-mode helper.
     *
     * Auto-mode (no override) defers to
     * {@link resolveDefaultPnpEnforcement}, which returns `"on"`
     * exactly when the bound assessment or current item ref carries
     * profile precedence material (PNP, district policy, test
     * administration, item-level required/restricted/parameters), and
     * `"off"` otherwise. Profile gates engage the moment profile material
     * is present.
     */
    resolveEffectivePnpEnforcement() {
        if (this.pnpEnforcementOverride !== null) {
            return this.pnpEnforcementOverride;
        }
        return resolveDefaultPnpEnforcement({
            assessment: this.boundAssessment,
            currentItemRef: this.boundCurrentItemRef,
        });
    }
    resolveConfiguredPnpEnforcement() {
        const tools = this.config.tools;
        return tools?.pnpEnforcement ?? null;
    }
    /**
     * Apply tool configuration changes to underlying services.
     * Called after updateToolConfig().
     */
    _applyToolConfigChange(toolId, _updates) {
        // Apply configuration changes based on tool
        switch (toolId) {
            case "textToSpeech": {
                const reconfigurePromise = this._reconfigureTTSProvider();
                this.ttsReconfigurePromise = reconfigurePromise;
                void reconfigurePromise.finally(() => {
                    if (this.ttsReconfigurePromise === reconfigurePromise) {
                        this.ttsReconfigurePromise = undefined;
                    }
                });
                void reconfigurePromise.then(async () => {
                    const ttsConfig = this.getTTSConfigFromProviders();
                    if (!this.lazyInit && ttsConfig?.enabled !== false) {
                        await this.ensureTTSReady(ttsConfig);
                    }
                });
                break;
            }
            case "answerEliminator":
                // Future: Could notify answer eliminator tools of strategy change
                break;
            // Add cases for other tools as needed
        }
    }
    async _reconfigureTTSProvider() {
        this.ttsInitialized = false;
        this.ttsInitPromise = undefined;
        try {
            this.ttsService.stop();
        }
        catch {
            // noop: stop best effort
        }
        if (this.toolProviderRegistry.has("tts")) {
            await this.toolProviderRegistry.unregister("tts");
        }
        const ttsRegistration = this.getProviderDescriptorTools().find((tool) => tool.toolId === "textToSpeech");
        if (!ttsRegistration)
            return;
        await this.registerProviderFromTool(ttsRegistration);
    }
}
//# sourceMappingURL=ToolkitCoordinator.js.map