import { AsyncLocalStorage } from "node:async_hooks";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs/promises";
import util from "node:util";
import path from "pathe";
import pc from "picocolors";
import { destroy, destroyAll, DestroyStrategy } from "./destroy.js";
import { ResourceFQN, ResourceID, ResourceKind, ResourceScope, ResourceSeq, } from "./resource.js";
import { FileSystemStateStore } from "./state/file-system-state-store.js";
import { createDummyLogger, createLoggerInstance, } from "./util/cli.js";
import { idempotentSpawn, } from "./util/idempotent-spawn.js";
import { logger } from "./util/logger.js";
import { AsyncMutex } from "./util/mutex.js";
import { ALCHEMY_ROOT } from "./util/root-dir.js";
import { createAndSendEvent } from "./util/telemetry.js";
import { validateResourceID } from "./util/validate-resource-id.js";
export class RootScopeStateAttemptError extends Error {
    constructor() {
        super("Root scope cannot contain state");
    }
}
// TODO: support browser
export const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ??
    process.env.USER ??
    process.env.USERNAME ??
    "dev";
const ScopeSymbol = Symbol.for("alchemy::Scope");
export function isScope(value) {
    return value instanceof Scope || value?.[ScopeSymbol] === true;
}
export class Scope {
    [ScopeSymbol] = true;
    static KIND = "alchemy::Scope";
    static storage = (globalThis.__ALCHEMY_STORAGE__ ??=
        new AsyncLocalStorage());
    static getScope() {
        return Scope.storage.getStore();
    }
    static get root() {
        return Scope.current.root;
    }
    static get current() {
        const scope = Scope.getScope();
        if (!scope)
            throw new Error("Not running within an Alchemy Scope");
        return scope;
    }
    resources = new Map();
    children = new Map();
    stage;
    name;
    scopeName;
    parent;
    password;
    state;
    stateStore;
    quiet;
    phase;
    local;
    watch;
    tunnel;
    force;
    adopt;
    eraseSecrets;
    destroyStrategy;
    logger;
    noTrack;
    dataMutex;
    rootDir;
    dotAlchemy;
    isSelected;
    profile;
    startedAt;
    // Provider credentials for scope-level credential overrides
    providerCredentials;
    isErrored = false;
    isSkipped = false;
    finalized = false;
    deferred = [];
    cleanups = [];
    get appName() {
        if (this.parent) {
            return this.parent.appName;
        }
        return this.scopeName;
    }
    constructor(options) {
        // Extract core scope options first
        const { scopeName, parent, stage, password, stateStore, quiet, phase, local, watch, tunnel, force, destroyStrategy, logger, adopt, eraseSecrets, dotAlchemy, rootDir, isSelected, noTrack, profile, startedAt, ...providerCredentials } = options;
        this.scopeName = scopeName;
        this.name = this.scopeName;
        this.parent = parent === null ? undefined : (parent ?? Scope.getScope());
        this.rootDir = rootDir ?? this.parent?.rootDir ?? ALCHEMY_ROOT;
        this.isSelected = isSelected ?? this.parent?.isSelected;
        this.startedAt = startedAt ?? this.parent?.startedAt ?? performance.now();
        this.dotAlchemy =
            dotAlchemy ??
                this.parent?.dotAlchemy ??
                path.resolve(this.rootDir, ".alchemy");
        // Store provider credentials (TypeScript ensures no conflicts with core options)
        this.providerCredentials = providerCredentials;
        this.stage = stage ?? this.parent?.stage ?? DEFAULT_STAGE;
        this.profile = profile ?? this.parent?.profile;
        this.parent?.children.set(this.scopeName, this);
        this.quiet = quiet ?? this.parent?.quiet ?? false;
        if (this.parent) {
            if (!this.scopeName) {
                throw new Error("Scope name is required when creating a child scope");
            }
            validateResourceID(this.scopeName, "Scope");
        }
        this.password = password ?? this.parent?.password;
        this.noTrack = noTrack ?? this.parent?.noTrack ?? false;
        const resolvedPhase = phase ?? this.parent?.phase;
        if (resolvedPhase === undefined) {
            throw new Error("Phase is required");
        }
        this.phase = resolvedPhase;
        this.logger = this.quiet
            ? createDummyLogger()
            : createLoggerInstance({
                phase: this.phase,
                stage: this.stage,
                appName: this.appName ?? "",
            }, logger);
        this.local = local ?? this.parent?.local ?? false;
        this.watch = watch ?? this.parent?.watch ?? false;
        this.tunnel = tunnel ?? this.parent?.tunnel ?? false;
        this.force = force ?? this.parent?.force ?? false;
        this.adopt = adopt ?? this.parent?.adopt ?? false;
        this.eraseSecrets = eraseSecrets ?? this.parent?.eraseSecrets ?? false;
        this.destroyStrategy =
            destroyStrategy ?? this.parent?.destroyStrategy ?? "sequential";
        if (this.local) {
            this.logger.warnOnce("Development mode is in beta. Please report any issues to https://github.com/alchemy-run/alchemy/issues.");
        }
        this.stateStore =
            stateStore ??
                this.parent?.stateStore ??
                ((scope) => new FileSystemStateStore(scope));
        this.state = this.stateStore(this);
        this.dataMutex = new AsyncMutex();
    }
    async has(id, type) {
        const state = await this.state.get(id);
        return state !== undefined && (type === undefined || state.kind === type);
    }
    createPhysicalName(id, delimiter = "-", maxLength) {
        let name = [this.appName, ...this.chain.slice(2), id, this.stage]
            .map((part) => part.replaceAll(/[^a-z0-9_-]/gi, delimiter))
            .join(delimiter);
        while (maxLength && name.length > maxLength) {
            name = name
                .split(delimiter)
                .map((part) => part.slice(0, -1))
                .join(delimiter);
        }
        return name;
    }
    async spawn(
    // TODO(sam): validate uniqueness? Ensure a flat .logs/${id}.log dir? Or nest in scope dirs?
    id, options) {
        const logsDir = path.join(this.dotAlchemy, "logs");
        const pidsDir = path.join(this.dotAlchemy, "pids");
        const result = await idempotentSpawn({
            log: path.join(logsDir, `${id}.log`),
            stateFile: path.join(pidsDir, `${id}.pid.json`),
            ...options,
        });
        this.onCleanup(result.stop);
        return result.extracted;
    }
    async exec(id, command) {
        const logFilePath = path.join(this.dotAlchemy, "logs", `${id}.log`);
        const [cmd, ...args] = command.split(/\s+/);
        const child = spawn(cmd, args, { stdio: "pipe" });
        await once(child, "spawn"); // throws if command not found
        await fs.mkdir(path.dirname(logFilePath), { recursive: true });
        const logFile = await fs.open(logFilePath, "w");
        let stdout = "";
        let stderr = "";
        child.stdout.on("data", (chunk) => {
            stdout += chunk.toString();
            logFile.write(chunk);
        });
        child.stderr.on("data", (chunk) => {
            stderr += chunk.toString();
            logFile.write(chunk);
        });
        const [exitCode] = (await once(child, "exit"));
        return { exitCode, stdout, stderr };
    }
    /**
     * @internal
     */
    clear() {
        for (const child of this.children.values()) {
            child.clear();
        }
        this.resources.clear();
        this.children.clear();
    }
    get root() {
        let root = this;
        while (root.parent) {
            root = root.parent;
        }
        return root;
    }
    async deleteResource(resourceID) {
        await this.state.delete(resourceID);
        this.resources.delete(resourceID);
    }
    _seq = 0;
    seq() {
        return this._seq++;
    }
    get chain() {
        // Since the root scope name is the same as the app name, this ensures
        // the root scope chain is "<app-name>" instead of "<app-name>/<app-name>".
        if (!this.parent &&
            this.appName &&
            this.scopeName &&
            this.appName === this.scopeName) {
            return [this.appName];
        }
        const thisScope = this.scopeName ? [this.scopeName] : [];
        if (this.parent) {
            return [...this.parent.chain, ...thisScope];
        }
        const app = this.appName ? [this.appName] : [];
        return [...app, ...thisScope];
    }
    fail() {
        this.logger.error("Scope failed", this.chain.join("/"));
        this.isErrored = true;
    }
    skip() {
        this.isSkipped = true;
    }
    async init() {
        await Promise.all([this.state.init?.()]);
    }
    async deinit() {
        await this.parent?.state.delete(this.scopeName);
        await this.state.deinit?.();
    }
    fqn(resourceID) {
        return [...this.chain, resourceID].join("/");
    }
    /**
     * Centralizes the "lock → locate the right scope → hand the caller a live
     * ScopeState instance and a persist() helper".
     *
     * @param fn   Your operation on the scope state.
     *             • `state` is already resolved and, if we're at the root, created.
     *             • `persist` will write the (possibly-mutated) state back.
     */
    async withScopeState(fn) {
        return this.dataMutex.lock(async () => {
            // 1. We must know where to look.
            if (!this.parent || !this.scopeName) {
                throw new RootScopeStateAttemptError();
            }
            // 2. Pull (or lazily create) the state bucket we care about.
            const isRoot = this.parent.scopeName === this.root.scopeName;
            const state = (await this.parent.state.get(this.scopeName)) ??
                (isRoot
                    ? {
                        //todo(michael): should this have a different type cause its root?
                        kind: "alchemy::Scope",
                        id: this.scopeName,
                        fqn: this.root.fqn(this.scopeName),
                        seq: this.seq(),
                        status: "created",
                        data: {},
                        output: {
                            [ResourceID]: this.scopeName,
                            [ResourceFQN]: this.root.fqn(this.scopeName),
                            [ResourceKind]: "alchemy::Scope",
                            [ResourceScope]: this,
                            [ResourceSeq]: this.seq(),
                            [DestroyStrategy]: this.destroyStrategy,
                        },
                        props: {},
                    }
                    : undefined);
            if (!state)
                throw new RootScopeStateAttemptError();
            return fn(state, (updated) => this.parent.state.set(this.scopeName, updated));
        });
    }
    async set(key, value) {
        await this.withScopeState(async (state, persist) => {
            state.data[key] = value;
            await persist(state); // only one line to save!
        });
    }
    get(key) {
        return this.withScopeState(async (state) => state.data[key]);
    }
    async delete(key) {
        return this.withScopeState(async (state, persist) => {
            delete state.data[key];
            await persist(state);
        });
    }
    async run(fn) {
        return Scope.storage.run(this, () => fn(this));
    }
    [util.inspect.custom]() {
        return `Scope(${this.chain.join("/")})`;
    }
    [Symbol.asyncDispose]() {
        return this.finalize();
    }
    async finalize(options) {
        const shouldForce = options?.force ||
            this.parent === undefined ||
            this?.parent?.scopeName === this.root.scopeName;
        if (this.phase === "read") {
            if (this.parent == null) {
                await createAndSendEvent({
                    event: this.isErrored ? "alchemy.error" : "alchemy.success",
                    duration: performance.now() - this.startedAt,
                }, this.isErrored ? new Error("Scope failed") : undefined);
            }
            return;
        }
        if (this.finalized && !shouldForce) {
            return;
        }
        this.finalized = true;
        // trigger and await all deferred promises
        await Promise.all(this.deferred.map((fn) => fn()));
        if (!this.isErrored && !this.isSkipped) {
            // TODO: need to detect if it is in error
            const resourceIds = await this.state.list();
            const aliveIds = new Set(this.resources.keys());
            const orphanIds = Array.from(resourceIds.filter((id) => !aliveIds.has(id)));
            if (shouldForce) {
                await this.destroyPendingDeletions();
                await Promise.all(Array.from(this.children.values()).map((child) => child.finalize({
                    force: shouldForce,
                    noop: options?.noop,
                })));
            }
            const orphans = await Promise.all(orphanIds.map(async (id) => (await this.state.get(id)).output)).then((orphans) => orphans.filter((orphan) => 
            //we never want to mark the stage scope as an orphan
            !(orphan[ResourceKind] === "alchemy::Scope" &&
                orphan[ResourceFQN] === this.root.fqn(this.stage))));
            await destroyAll(orphans, {
                quiet: this.quiet,
                strategy: this.destroyStrategy,
                force: shouldForce,
                noop: options?.noop,
            });
        }
        else if (this.isErrored) {
            this.logger.warn("Scope is in error, skipping finalize");
        }
        if (this.parent == null) {
            await createAndSendEvent({
                event: this.isErrored ? "alchemy.error" : "alchemy.success",
                duration: performance.now() - this.startedAt,
            }, this.isErrored ? new Error("Scope failed") : undefined);
        }
        if (!this.parent && process.env.ALCHEMY_TEST_KILL_ON_FINALIZE) {
            await this.cleanup();
            process.exit(0);
        }
    }
    async destroyPendingDeletions() {
        const pendingDeletions = (await this.get("pendingDeletions").catch((e) => {
            if (e instanceof RootScopeStateAttemptError) {
                return [];
            }
            throw e;
        })) ?? [];
        //todo(michael): remove once we deprecate doss; see: https://github.com/alchemy-run/alchemy/issues/585
        let hasCorruptedResources = false;
        if (pendingDeletions) {
            for (const { resource, oldProps } of pendingDeletions) {
                //todo(michael): ugly hack due to the way scope is serialized
                const realResource = this.resources.get(resource[ResourceID]);
                resource[ResourceScope] = realResource?.[ResourceScope] ?? this;
                if (realResource == null && resource[ResourceID] == null) {
                    logger.warn("A replaced resource pending deletion is corrupted and will NOT be deleted. This is likely a bug with the state store.");
                    hasCorruptedResources = true;
                    continue;
                }
                await destroy(resource, {
                    quiet: this.quiet,
                    strategy: "sequential",
                    replace: {
                        props: oldProps,
                        output: resource,
                    },
                });
            }
        }
        if (hasCorruptedResources) {
            const newPendingDeletions = (await this.get("pendingDeletions").catch(() => [])) ?? [];
            await this.set("pendingDeletions", newPendingDeletions.filter((d) => d.resource[ResourceID] != null));
        }
    }
    /**
     * Defers execution of a function until the Alchemy application finalizes.
     */
    defer(fn) {
        let _resolve;
        let _reject;
        const promise = new Promise((resolve, reject) => {
            _resolve = resolve;
            _reject = reject;
        });
        this.deferred.push(() => {
            if (!this.finalized) {
                throw new Error("Attempted to await a deferred Promise before finalization");
            }
            // lazily trigger the worker on first await
            return this.run(() => fn()).then(_resolve, _reject);
        });
        return promise;
    }
    /**
     * Run all cleanup functions registered with `onCleanup`.
     * This should only be called on the root scope.
     */
    async cleanup() {
        if (this.parent || this.cleanups.length === 0)
            return;
        this.logger.log(pc.gray("Exiting..."));
        await Promise.allSettled(this.cleanups.map((cleanup) => cleanup()));
    }
    /**
     * Register a cleanup function that will be called when the process exits.
     * This should only be called on the root scope.
     */
    onCleanup(fn) {
        if (this.parent) {
            this.root.onCleanup(fn);
            return;
        }
        this.cleanups.push(fn);
    }
    /**
     * Returns a string representation of the scope.
     */
    toString() {
        return `Scope(
  chain=${this.chain.join("/")},
  resources=[${Array.from(this.resources.values())
            .map((r) => r[ResourceID])
            .join(",\n  ")}]
)`;
    }
}
globalThis.__ALCHEMY_SCOPE__ = Scope;
//# sourceMappingURL=scope.js.map