// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
 * Shared artifact loader used by every code path that boots a kernel
 * from an `objectstack build` artifact:
 *
 *   - `FsAppBundleResolver`         — cloud / multi-environment file binding
 *   - `runtime-stack.ts:basePlugins` — single-environment local boot
 *   - `StandaloneStack`              — `objectstack serve --standalone`
 *   - `http-dispatcher.ts`           — in-flight artifact rebind
 *
 * Reads the JSON artifact (from a local path *or* an `http(s)://` URL) and,
 * for **local** artifacts only, if the bundle declares a sibling
 * `runtimeModule` (the ESM produced by `packages/cli/src/utils/build-runtime.ts`),
 * dynamic-imports it and merges its `functions` map onto the bundle so
 * declarative Hooks resolve their handlers at boot.
 *
 * For **remote** (`http(s)://`) artifacts the `runtimeModule` reference is
 * intentionally ignored — Node cannot dynamic-import arbitrary URLs and we
 * refuse to execute remote code by default. Remote artifacts are therefore
 * expected to be fully declarative (Hooks/Flows carry their bodies inline).
 *
 * Mutates the returned bundle in place. Returns `null` on read/parse
 * failure (callers may treat as "no bundle for this project yet").
 * Runtime-module load failures are logged but non-fatal — the bundle
 * is still returned, just without runtime functions.
 */

import { readFile } from 'node:fs/promises';
import { resolve as resolvePath, isAbsolute, dirname } from 'node:path';
import { pathToFileURL } from 'node:url';

export interface LoadArtifactBundleOptions {
    /** Optional log tag for warnings (defaults to `[loadArtifactBundle]`). */
    tag?: string;
    /** When true, an unwrapped `{ schemaVersion, metadata }` envelope is unwrapped. */
    unwrapEnvelope?: boolean;
    /** Optional fetch timeout in ms for `http(s)://` sources (default 15000). */
    fetchTimeoutMs?: number;
}

/** Returns true when `pathOrUrl` looks like an `http://` or `https://` URL. */
export function isHttpUrl(pathOrUrl: string): boolean {
    return /^https?:\/\//i.test(pathOrUrl);
}

/**
 * Read a JSON artifact from either a local file path or an `http(s)://` URL.
 * Returns the raw text body. Throws on network or filesystem failure.
 */
export async function readArtifactSource(
    pathOrUrl: string,
    opts: { fetchTimeoutMs?: number } = {},
): Promise<string> {
    if (isHttpUrl(pathOrUrl)) {
        const timeoutMs = opts.fetchTimeoutMs ?? 15_000;
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const res = await fetch(pathOrUrl, {
                redirect: 'follow',
                signal: controller.signal,
                headers: { Accept: 'application/json, text/plain;q=0.9, */*;q=0.5' },
            });
            if (!res.ok) {
                throw new Error(`HTTP ${res.status} ${res.statusText} for ${pathOrUrl}`);
            }
            return await res.text();
        } finally {
            clearTimeout(timer);
        }
    }
    return readFile(pathOrUrl, 'utf-8');
}

export async function loadArtifactBundle(
    absArtifactPath: string,
    opts: LoadArtifactBundleOptions = {},
): Promise<any | null> {
    const tag = opts.tag ?? '[loadArtifactBundle]';
    const isUrl = isHttpUrl(absArtifactPath);
    let bundle: any;
    try {
        const raw = await readArtifactSource(absArtifactPath, { fetchTimeoutMs: opts.fetchTimeoutMs });
        const parsed = JSON.parse(raw);
        bundle = opts.unwrapEnvelope && parsed?.schemaVersion != null && parsed?.metadata !== undefined
            ? parsed.metadata
            : parsed;
    } catch (err: any) {
        // eslint-disable-next-line no-console
        console.warn(`${tag} artifact read FAILED: path='${absArtifactPath}' error=${err?.message ?? err}`);
        return null;
    }

    if (isUrl) {
        // Remote artifacts cannot dynamic-import a sibling ESM runtime module
        // safely (Node does not allow importing arbitrary URLs and we never
        // want to execute remote code by default). Hooks/flow handlers must
        // be carried in the JSON itself (declarative bodies, sandbox-eval).
        if (typeof bundle?.runtimeModule === 'string' && bundle.runtimeModule.length > 0) {
            // eslint-disable-next-line no-console
            console.warn(
                `${tag} ignoring runtimeModule='${bundle.runtimeModule}' for remote artifact ${absArtifactPath} ` +
                `(remote ESM imports are not supported; embed handlers in the JSON instead)`,
            );
            // Strip the reference so downstream code doesn't try to resolve it
            // as a local path against process.cwd().
            delete bundle.runtimeModule;
        }
        return bundle;
    }

    await mergeRuntimeModule(bundle, absArtifactPath, tag);
    return bundle;
}

export async function mergeRuntimeModule(bundle: any, artifactAbsPath: string, tag = '[loadArtifactBundle]'): Promise<void> {
    const ref = bundle?.runtimeModule;
    if (typeof ref !== 'string' || ref.length === 0) return;
    const moduleAbsPath = isAbsolute(ref) ? ref : resolvePath(dirname(artifactAbsPath), ref);
    try {
        const mod: any = await import(pathToFileURL(moduleAbsPath).href);
        const fns = (mod && (mod.functions ?? mod.default?.functions)) ?? null;
        if (!fns || typeof fns !== 'object') {
            // eslint-disable-next-line no-console
            console.warn(`${tag} runtime module '${moduleAbsPath}' exported no \`functions\` map`);
            return;
        }
        const existing = (bundle.functions && typeof bundle.functions === 'object' && !Array.isArray(bundle.functions))
            ? bundle.functions as Record<string, unknown>
            : {};
        bundle.functions = { ...existing, ...fns };
    } catch (err: any) {
        // eslint-disable-next-line no-console
        console.warn(`${tag} runtime module load FAILED: path='${moduleAbsPath}' error=${err?.message ?? err}`);
    }
}
