// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import chalk from "chalk";
import registerDebug from "debug";
import { makeClientIOMessage } from "../context/interactiveIO.js";
import { getDefaultExplainerName } from "agent-cache";
import {
    CommandHandlerContext,
    ensureCommandResult,
    getRequestId,
    requestIdToString,
} from "../context/commandHandlerContext.js";

import {
    CommandDescriptor,
    CommandDescriptors,
    CommandDescriptorTable,
} from "@typeagent/agent-sdk";
import { executeCommand } from "../execute/actionHandlers.js";
import { isCommandDescriptorTable } from "@typeagent/agent-sdk/helpers/command";
import { parseParams } from "./parameters.js";
import { getHandlerTableUsage, getUsage } from "./commandHelp.js";
import {
    CommandResult,
    DispatcherStatus,
    ProcessCommandOptions,
    RequestId,
} from "@typeagent/dispatcher-types";
import { DispatcherName } from "../context/dispatcher/dispatcherUtils.js";
import { getAppAgentName } from "../internal.js";

const debugCommand = registerDebug("typeagent:dispatcher:command");
const debugCommandError = registerDebug("typeagent:dispatcher:command:error");

export type ResolveCommandResult = {
    // the app agent name parsed from the input.
    parsedAppAgentName: string | undefined;

    // The actual app agent name that is resolved.
    actualAppAgentName: string;

    // The resolved commands.
    commands: string[];

    // The resolve arguments
    suffix: string;

    // The table the resolved commands is in.  Undefined if the app agent has no subcommand. (e.g. @<agent> <args>)
    table: CommandDescriptorTable | undefined;

    // The descriptor of the resolved command.  Undefined if the last command index is not a command in the table.
    descriptor: CommandDescriptor | undefined;

    // True if the command is matched to a command descriptor.
    // False if the command is not matched or the we resolved to the default.
    matched: boolean;
};

export function getDefaultSubCommandDescriptor(
    table: CommandDescriptorTable,
): CommandDescriptor | undefined {
    if (typeof table.defaultSubCommand === "string") {
        const defaultSubCommand = table.commands[table.defaultSubCommand];
        if (
            defaultSubCommand !== undefined &&
            isCommandDescriptorTable(defaultSubCommand)
        ) {
            return undefined;
        }
        return defaultSubCommand;
    }
    return table.defaultSubCommand;
}

//
// ── resolveCommand contract ─────────────────────────────────────────────────
//
// Given a normalized input string (output of normalizeCommand — no leading
// "@", no leading whitespace), greedily resolves as many tokens as possible
// into an agent name, then a chain of subcommand names.  Returns the point
// at which resolution stopped and the state accumulated so far.
//
// Resolution is greedy and exact-match only.  Each token is consumed if and
// only if it matches a key in the current command table.  As soon as a
// token doesn't match, resolution stops and that token (plus everything
// after it) becomes the suffix.  If a token matches a leaf descriptor
// (not a sub-table), resolution also stops — the command is fully matched.
//
// When resolution stops at a table (not a leaf), the table's
// defaultSubCommand (if any) is used as the descriptor.  This is the
// "resolved to default" case — matched is false.
//
// The first token is special: it is tested as an agent name first.
//   - If it matches a known, enabled agent → that agent is selected
//     and the token is consumed (not included in suffix).
//   - Otherwise → the "system" agent is selected and the token is
//     NOT consumed (rolled back into the input for subcommand matching).
//
// Return fields (see ResolveCommandResult):
//
//   parsedAppAgentName
//                The agent name parsed from input, or undefined.
//                Set when the first token matched an agent name, OR
//                when no subcommands were consumed (commands.length===0)
//                — in this case the originally-matched agent name (which
//                may be undefined) is returned.
//
//   actualAppAgentName
//                The agent that will handle the command.  Either the
//                parsed agent name or "system" as fallback.
//
//   commands     Ordered list of subcommand names that were successfully
//                consumed.  May be empty.
//
//   suffix       The remaining input after the last consumed token,
//                with leading whitespace trimmed.  This is the portion
//                that was NOT resolved — it may be empty (fully resolved),
//                a partial word (user is still typing), or multiple
//                tokens (unrecognized input).  Callers use it as
//                parameter text (parseCommand) or filter text for
//                completions (getCommandCompletion).
//
//   table        The CommandDescriptorTable at the point where resolution
//                stopped.  Undefined when the agent has no subcommands
//                (flat agent with only a root descriptor).  When defined,
//                table.commands lists the valid subcommands that could
//                have followed.
//
//   descriptor   The resolved CommandDescriptor, or undefined.  It is
//                defined when:
//                  (a) an explicit subcommand matched a leaf, or
//                  (b) we stopped at a table that has a defaultSubCommand.
//                Undefined when the table has no default and the suffix
//                didn't match any subcommand.
//
//   matched      true only when descriptor came from an explicit
//                exact-match of the last consumed token against a leaf
//                in the table.  false when descriptor is the default
//                subcommand or when descriptor is undefined.
//
export async function resolveCommand(
    input: string,
    context: CommandHandlerContext,
): Promise<ResolveCommandResult> {
    let prev: string | undefined = undefined;
    let curr = input;
    const nextToken = () => {
        const result = curr.match(/^\s*\S+/);
        if (result === null || result.length !== 1) {
            return undefined;
        }

        prev = curr;
        curr = curr.substring(result[0].length);
        return result[0].trim();
    };
    const rollbackToken = () => {
        if (prev === undefined) {
            throw new Error("No previous term to revert to.");
        }
        curr = prev;
        prev = undefined;
    };

    const first = nextToken();

    const appAgentName =
        first !== undefined && context.agents.isAppAgentName(first)
            ? first
            : undefined;
    let actualAppAgentName: string;
    const useParsedAppAgentName =
        appAgentName !== undefined &&
        context.agents.isCommandEnabled(appAgentName);
    if (useParsedAppAgentName) {
        actualAppAgentName = appAgentName;
    } else {
        actualAppAgentName = "system";
        if (first !== undefined) {
            rollbackToken();
        }
    }

    const appAgent = context.agents.getAppAgent(actualAppAgentName);
    const sessionContext = context.agents.getSessionContext(actualAppAgentName);
    const descriptors = await appAgent.getCommands?.(sessionContext);
    const commands: string[] = [];
    let matched = false;
    let table: CommandDescriptorTable | undefined;
    let descriptor: CommandDescriptor | undefined;
    if (descriptors === undefined || !isCommandDescriptorTable(descriptors)) {
        descriptor = descriptors;
    } else {
        table = descriptors;
        while (true) {
            const subcommand = nextToken();
            if (subcommand === undefined) {
                descriptor = getDefaultSubCommandDescriptor(table);
                break;
            }

            const current: CommandDescriptors = table.commands[subcommand];
            if (current === undefined) {
                // Unknown command
                descriptor = getDefaultSubCommandDescriptor(table);
                rollbackToken();
                break;
            }
            commands.push(subcommand);
            if (!isCommandDescriptorTable(current)) {
                descriptor = current;
                matched = true;
                break;
            }
            table = current;
        }
    }

    const parsedAppAgentName =
        useParsedAppAgentName || commands.length === 0
            ? appAgentName
            : undefined;
    const result: ResolveCommandResult = {
        parsedAppAgentName,
        actualAppAgentName: parsedAppAgentName ?? actualAppAgentName,
        commands,
        suffix: curr.trimStart(),
        table,
        descriptor,
        matched,
    };

    if (debugCommand.enabled) {
        debugCommand(`Resolved command:`, {
            ...result,
            table: result.table !== undefined,
            descriptor: result.descriptor !== undefined,
        });
    }

    return result;
}

export function normalizeCommand(
    originalInput: string,
    context: CommandHandlerContext,
) {
    const input = originalInput.trimStart();
    if (!input.startsWith("@")) {
        const requestHandlerAgent = context.session.getConfig().request;
        return `${requestHandlerAgent} request ${input}`;
    }
    return input.substring(1);
}

async function parseCommand(
    originalInput: string,
    context: CommandHandlerContext,
) {
    const input = normalizeCommand(originalInput, context);
    const result = await resolveCommand(input, context);
    if (result.descriptor !== undefined) {
        context.logger?.logEvent("command", {
            originalInput,
            appAgentName: result.parsedAppAgentName,
            command: result.commands,
            suffix: result.suffix,
        });
        try {
            if (
                result.suffix !== "" &&
                result.descriptor.parameters === undefined
            ) {
                throw new Error(
                    `Command '@${getParsedCommand(result)}' does not accept parameters.`,
                );
            }
            const params = result.descriptor.parameters
                ? parseParams(result.suffix, result.descriptor.parameters)
                : undefined;
            return {
                appAgentName: result.actualAppAgentName,
                command: result.commands,
                params,
            };
        } catch (e: any) {
            if (
                result.matched ||
                result.suffix.length === 0 ||
                result.suffix.startsWith("-")
            ) {
                // Error with with the subcommand if it is not a default, or suffix started as a "flag".
                const command = getParsedCommand(result);
                throw new Error(
                    `${e.message}\n\n${chalk.reset(getUsage(command, result.descriptor))}`,
                );
            }
            // if we matched only default subcommand fall thru and error assuming that we matched only the table
        }
    }
    const command = getParsedCommand(result);

    if (
        result.parsedAppAgentName !== undefined &&
        !context.agents.isCommandEnabled(result.parsedAppAgentName)
    ) {
        throw new Error(
            `Command for '${result.parsedAppAgentName}' is disabled.`,
        );
    }

    if (result.table === undefined) {
        throw new Error(`Unknown command '${input}'`);
    }
    const message =
        command.length === 0
            ? "Command or agent name required."
            : result.suffix.length === 0
              ? `'@${command}' requires a subcommand.`
              : `'${result.suffix}' is not a subcommand for '@${command}'.`;

    throw new Error(
        `${message}\n\n${chalk.reset(getHandlerTableUsage(result.table, command, context))}`,
    );
}

export function getParsedCommand(result: ResolveCommandResult) {
    const command = result.commands?.join(" ") ?? "";
    const parsedAgentName = result.parsedAppAgentName;
    return parsedAgentName
        ? command.length !== 0
            ? `${parsedAgentName} ${command}`
            : parsedAgentName
        : command;
}

export async function processCommandNoLock(
    originalInput: string,
    context: CommandHandlerContext,
    attachments?: string[],
) {
    try {
        context.currentAbortSignal?.throwIfAborted();
        const result = await parseCommand(originalInput, context);
        context.currentAbortSignal?.throwIfAborted();
        return await executeCommand(
            result.command,
            result.params,
            result.appAgentName,
            context,
            attachments,
        );
    } catch (e: any) {
        if (e.name === "AbortError" || context.currentAbortSignal?.aborted) {
            throw new DOMException("The operation was aborted.", "AbortError");
        }
        context.clientIO.appendDisplay(
            makeClientIOMessage(
                context,
                {
                    type: "text",
                    content: `ERROR: ${e.message}`,
                    kind: "error",
                },
                getRequestId(context),
                DispatcherName,
                undefined,
            ),
            "block",
        );
        debugCommandError(e.stack);

        context?.logger?.logEvent("command:exception", {
            request: originalInput,
            message: e.message,
            stack: e.stack,
        });
    }
}

function beginProcessCommand(
    requestId: RequestId,
    context: CommandHandlerContext,
    options?: ProcessCommandOptions,
    signal?: AbortSignal,
) {
    context.currentRequestId = requestId;
    context.currentAbortSignal = signal;
    context.commandResult = undefined;
    context.noReasoning = options?.noReasoning ?? false;

    context.commandProfiler = context.metricsManager?.beginCommand(
        requestIdToString(requestId),
    );
}

function endProcessCommand(
    requestId: RequestId,
    context: CommandHandlerContext,
) {
    const pendingToggleTransientAgents = context.pendingToggleTransientAgents;
    if (pendingToggleTransientAgents.length !== 0) {
        for (const [agentName, active] of pendingToggleTransientAgents) {
            context.agents.toggleTransient(agentName, active);
        }

        // Changing active schemas, we need to clear the cache.
        context.translatorCache.clear();

        const [agentName, active] = pendingToggleTransientAgents.pop()!;
        if (active) {
            context.lastActionSchemaName = agentName;
        } else if (context.lastActionSchemaName === agentName) {
            context.lastActionSchemaName = getAppAgentName(agentName);
        }
        context.pendingToggleTransientAgents.length = 0; // clear the pending toggle agents.
    }

    context.commandProfiler?.stop();
    context.commandProfiler = undefined;

    const metrics = context.metricsManager?.endCommand(
        requestIdToString(requestId),
    );

    if (metrics) {
        ensureCommandResult(context).metrics = metrics;
    }
    const result = context.commandResult;
    context.commandResult = undefined;
    context.currentRequestId = undefined;
    context.currentAbortSignal = undefined;

    return result;
}

export async function processCommand(
    originalInput: string,
    context: CommandHandlerContext,
    requestId: RequestId,
    attachments?: string[],
    options?: ProcessCommandOptions,
): Promise<CommandResult | undefined> {
    // Create the AbortController *before* acquiring the lock so that a
    // cancelCommandByClientId() call that arrives while we are queued can
    // already abort the controller that will drive this command.
    const abortController = new AbortController();
    if (requestId.clientRequestId !== undefined) {
        context.activeRequestsByClientId.set(
            requestId.clientRequestId,
            abortController,
        );
    }
    try {
        // Process one command at a time.
        return await context.commandLock(async () => {
            const requestIdStr = requestId.requestId;
            context.activeRequests.set(requestIdStr, abortController);
            beginProcessCommand(
                requestId,
                context,
                options,
                abortController.signal,
            );
            context.clientIO.setUserRequest(requestId, originalInput);
            try {
                await processCommandNoLock(originalInput, context, attachments);
            } catch (e: any) {
                if (e.name === "AbortError") {
                    ensureCommandResult(context).cancelled = true;
                } else {
                    throw e;
                }
            } finally {
                context.activeRequests.delete(requestIdStr);
                return endProcessCommand(requestId, context);
            }
        });
    } finally {
        if (requestId.clientRequestId !== undefined) {
            context.activeRequestsByClientId.delete(requestId.clientRequestId);
        }
    }
}

export const enum unicodeChar {
    wood = "🪵",
    constructionSign = "🚧",
    floppyDisk = "💾",
    stopSign = "🛑",
    convert = "🔄",
}

function getDispatcherStatusDetails(context: CommandHandlerContext) {
    const disabled = [];

    const config = context.session.getConfig();
    if (!config.translation.enabled) {
        disabled.push(unicodeChar.convert);
    }
    const constructionStore = context.agentCache.constructionStore;
    if (!constructionStore.isEnabled()) {
        disabled.push(unicodeChar.constructionSign);
        if (!constructionStore.isAutoSave()) {
            disabled.push(unicodeChar.floppyDisk);
        }
    }

    if (!context.dblogging) {
        disabled.push(unicodeChar.wood);
    }

    const prompt: string[] = [];
    if (disabled.length !== 0) {
        prompt.push(" ", unicodeChar.stopSign, ":[", ...disabled, "]");
    }
    const translationModel = config.translation.model;
    if (translationModel !== "") {
        prompt.push(` (model: ${translationModel})`);
    }
    const explainerModel = config.explainer.model;
    if (context.agentCache.explainerName !== getDefaultExplainerName()) {
        prompt.push(` (explainer: ${context.agentCache.explainerName}`);

        if (explainerModel !== "") {
            prompt.push(` model: ${explainerModel}`);
        }
        prompt.push(")");
    } else if (explainerModel !== "") {
        prompt.push(` (explainer model: ${explainerModel})`);
    }

    return prompt.join("");
}
export function getDispatcherStatus(
    context: CommandHandlerContext,
): DispatcherStatus {
    const names = new Set<string>();
    const agents = context.agents.getActionConfigs().map((config) => {
        const appAgentName = getAppAgentName(config.schemaName);
        names.add(config.schemaName);
        return {
            emoji: config.emojiChar,
            name: config.schemaName,
            lastUsed: context.lastActionSchemaName === config.schemaName,
            priority:
                context.activityContext?.appAgentName === config.schemaName,
            request: context.session.getConfig().request === config.schemaName,
            active:
                context.agents.isActionActive(config.schemaName) ||
                context.agents.isCommandEnabled(appAgentName),
        };
    });

    // Make sure we include app agents that do not have actions.
    for (const agentName of context.agents.getAppAgentNames()) {
        if (!names.has(agentName)) {
            agents.push({
                emoji: context.agents.getAppAgentEmoji(agentName),
                name: agentName,
                lastUsed: false,
                priority: false,
                request: false,
                active: context.agents.isCommandEnabled(agentName),
            });
        }
    }

    return {
        agents,
        details: getDispatcherStatusDetails(context),
    };
}
