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

import { createWebSocketChannelServer } from "websocket-channel-server";
import { createDispatcherRpcServer } from "@typeagent/dispatcher-rpc/dispatcher/server";
import {
    createConversationManager,
    ConversationManager,
} from "./conversationManager.js";
import {
    getInstanceDirAsync,
    getTraceIdAsync,
} from "agent-dispatcher/helpers/data";
import {
    getDefaultAppAgentProviders,
    getIndexingServiceRegistry,
    getDefaultConstructionProvider,
} from "default-agent-provider";
import { getFsStorageProvider } from "dispatcher-node-providers";
import { createClientIORpcClient } from "@typeagent/dispatcher-rpc/clientio/client";
import { createRpc } from "@typeagent/agent-rpc/rpc";
import {
    AgentServerInvokeFunctions,
    AgentServerChannelName,
    AGENT_SERVER_DEFAULT_PORT,
    AGENT_SERVER_DISCOVERY_NAME,
    DiscoveryChannelName,
    createDiscoveryHandlers,
    DispatcherConnectOptions,
    JoinConversationResult,
    UserIdentity,
    getDispatcherChannelName,
    getClientIOChannelName,
} from "@typeagent/agent-server-protocol";
import type { ChannelProvider } from "@typeagent/agent-rpc/channel";
import type { Dispatcher } from "agent-dispatcher";
import { PortRegistrar, SYSTEM_SESSION_CONTEXT_ID } from "agent-dispatcher";
import { loadConfig } from "@typeagent/config";
import {
    writeServerPid,
    removeServerPid,
} from "@typeagent/agent-server-client";
import registerDebug from "debug";
import os from "node:os";
import { DefaultAzureCredential } from "@azure/identity";

// Load config from YAML layers + Key Vault (replacing legacy dotenv).
// vault.shared is auto-discovered from config.local.yaml / config.defaults.yaml.
await loadConfig({ keyVault: {}, strict: false });

const debugStartup = registerDebug("agent-server:startup");

// User identity resolution. Precedence:
//   1. TYPEAGENT_USER_NAME env var (dev override / CI)
//   2. Claims from the Azure AD token DefaultAzureCredential acquires for
//      the Cognitive Services scope — this is the same credential the
//      agent-server uses to talk to Azure OpenAI, so if the server can talk
//      to the model at all, the token's `name`/`upn` claims give us the
//      real user. Works without any extra setup (no git config, no Office
//      SSO).
//   3. OS username as a last resort.
//
// The Azure step is async and involves a network call, so we resolve it
// after startup and overwrite the cached identity when it arrives. The
// first few RPC calls may see the OS-username fallback; subsequent calls
// see the real display name.
function parseJwtClaims(token: string): Record<string, unknown> | undefined {
    const [, payload] = token.split(".");
    if (!payload) return undefined;
    const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
    const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
    try {
        return JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
    } catch {
        return undefined;
    }
}

function identityFromClaims(
    claims: Record<string, unknown>,
    fallbackUsername: string,
): UserIdentity | undefined {
    // Azure AD `name` is typically "First Last" or "Last, First". Prefer
    // just the first name so the chat header stays compact. Split on
    // whitespace or comma and take the first non-empty part.
    const fullName =
        typeof claims.name === "string" && claims.name.trim()
            ? claims.name.trim()
            : undefined;
    if (!fullName) return undefined;
    // "Last, First" → prefer "First" after the comma; otherwise first token.
    const firstName = fullName.includes(",")
        ? (fullName.split(",")[1]?.trim().split(/\s+/)[0] ?? fullName)
        : (fullName.split(/\s+/)[0] ?? fullName);
    const upn =
        (typeof claims.upn === "string" && claims.upn) ||
        (typeof claims.preferred_username === "string" &&
            claims.preferred_username) ||
        (typeof claims.unique_name === "string" && claims.unique_name) ||
        undefined;
    const initial = (firstName[0] ?? "U").toUpperCase();
    return {
        username: upn || fallbackUsername,
        displayName: firstName,
        initial,
    };
}

async function resolveIdentityFromAzureToken(
    fallbackUsername: string,
): Promise<UserIdentity | undefined> {
    try {
        const token = await new DefaultAzureCredential().getToken(
            "https://cognitiveservices.azure.com/.default",
        );
        if (!token?.token) return undefined;
        const claims = parseJwtClaims(token.token);
        if (!claims) return undefined;
        return identityFromClaims(claims, fallbackUsername);
    } catch {
        return undefined;
    }
}

function initialIdentity(): UserIdentity {
    const username = os.userInfo().username || "user";
    const envName = process.env.TYPEAGENT_USER_NAME?.trim();
    const displayName = envName || username;
    const initial = (displayName[0] ?? "U").toUpperCase();
    return { username, displayName, initial };
}

let userIdentity: UserIdentity = initialIdentity();

// Kick off the token-based resolution asynchronously. Env override wins
// if set, so skip the network call in that case.
if (!process.env.TYPEAGENT_USER_NAME?.trim()) {
    const fallbackUsername = userIdentity.username;
    resolveIdentityFromAzureToken(fallbackUsername)
        .then((resolved) => {
            if (resolved) {
                userIdentity = resolved;
                debugStartup(
                    `resolved user identity from Azure token: ${resolved.displayName}`,
                );
            }
        })
        .catch(() => {});
}

async function main() {
    debugStartup(`pid=${process.pid} resolving instance dir + traceId`);
    const [instanceDir, traceId] = await Promise.all([
        getInstanceDirAsync(),
        getTraceIdAsync(),
    ]);
    debugStartup(`instanceDir=${instanceDir}`);

    // did the launch request a specific config? (e.g. "test" to load "config.test.json")
    const configIdx = process.argv.indexOf("--config");
    const configName =
        configIdx !== -1 ? process.argv[configIdx + 1] : undefined;

    debugStartup("creating conversation manager (will lockInstanceDir)");
    // Single PortRegistrar shared across every conversation in this
    // process. Lets external clients (browser extension, VS Code, CLI)
    // discover any agent's port via the discovery channel regardless of
    // which conversation that agent is loaded into. Standalone hosts
    // (shell, CLI dispatcher) skip this and let each dispatcher mint
    // its own — see DispatcherOptions.portRegistrar in agent-dispatcher.
    const portRegistrar = new PortRegistrar();

    const conversationManager: ConversationManager =
        await createConversationManager(
            "agent server",
            {
                appAgentProviders: getDefaultAppAgentProviders(
                    instanceDir,
                    configName,
                ),
                persistSession: true,
                storageProvider: getFsStorageProvider(),
                metrics: true,
                dblogging: true,
                traceId,
                indexingServiceRegistry: await getIndexingServiceRegistry(
                    instanceDir,
                    configName,
                ),
                constructionProvider: getDefaultConstructionProvider(),
                conversationMemorySettings: {
                    requestKnowledgeExtraction: false,
                    actionResultKnowledgeExtraction: false,
                },
                collectCommandResult: true,
                portRegistrar,
            },
            instanceDir,
        );

    debugStartup("conversation manager ready; prewarming default conversation");
    // Pre-initialize the default conversation dispatcher before accepting clients,
    // so the first joinConversation call is fast and concurrent joinConversation calls
    // don't race to initialize the same dispatcher.
    await conversationManager.prewarmMostRecentConversation();
    debugStartup("prewarm complete");

    const portIdx = process.argv.indexOf("--port");
    const port =
        portIdx !== -1
            ? parseInt(process.argv[portIdx + 1], 10)
            : process.env.AGENT_SERVER_PORT
              ? parseInt(process.env.AGENT_SERVER_PORT, 10)
              : AGENT_SERVER_DEFAULT_PORT;

    const idleShutdownIdx = process.argv.indexOf("--idle-timeout");
    const idleShutdownMs =
        idleShutdownIdx !== -1
            ? parseInt(process.argv[idleShutdownIdx + 1], 10) * 1000
            : 0;

    let connectionCount = 0;
    let idleShutdownTimer: ReturnType<typeof setTimeout> | undefined;

    // Shared shutdown logic — used by RPC handler, idle timer, and clientIO intercept.
    // The wss variable is assigned after createWebSocketChannelServer resolves below.
    let wss: Awaited<ReturnType<typeof createWebSocketChannelServer>>;
    async function shutdownServer() {
        console.log("Shutdown requested, stopping agent server...");
        wss.close();
        await conversationManager.close();
        removeServerPid(port);
        process.exit(0);
    }

    function scheduleIdleShutdown() {
        if (idleShutdownMs <= 0 || connectionCount > 0) {
            return;
        }
        // Don't tear the process down while an agent still has a port
        // registered: out-of-process clients (Chrome/VS Code extension)
        // may have cached that port and could try to reconnect at any
        // moment. Once the agent releases (or its session-context
        // backstop fires), the next disconnect will re-arm this timer.
        if (portRegistrar.hasActiveAllocations()) {
            debugStartup(
                "skipping idle shutdown: PortRegistrar still has active allocations",
            );
            return;
        }
        idleShutdownTimer = setTimeout(async () => {
            console.log(
                "No clients connected — idle shutdown after " +
                    idleShutdownMs / 1000 +
                    "s. Stopping agent server...",
            );
            await shutdownServer();
        }, idleShutdownMs);
    }

    wss = await createWebSocketChannelServer(
        { port },
        (channelProvider: ChannelProvider, closeFn: () => void) => {
            connectionCount++;
            if (idleShutdownTimer !== undefined) {
                clearTimeout(idleShutdownTimer);
                idleShutdownTimer = undefined;
            }

            // Track which conversations this WebSocket connection has joined
            // conversationId → { dispatcher, connectionId }
            const joinedConversations = new Map<
                string,
                { dispatcher: Dispatcher; connectionId: string }
            >();

            const invokeFunctions: AgentServerInvokeFunctions = {
                joinConversation: async (
                    options?: DispatcherConnectOptions,
                ) => {
                    // Resolve conversation ID first (may auto-create default)
                    const conversationId =
                        await conversationManager.resolveConversationId(
                            options?.conversationId,
                        );

                    if (joinedConversations.has(conversationId)) {
                        throw new Error(
                            `Already joined conversation '${conversationId}'. Call leaveConversation() before joining again.`,
                        );
                    }

                    // Create conversation-namespaced channels
                    const clientIOChannel = channelProvider.createChannel(
                        getClientIOChannelName(conversationId),
                    );
                    try {
                        const clientIORpcClient =
                            createClientIORpcClient(clientIOChannel);

                        // Intercept shutdown: when the dispatcher calls
                        // clientIO.shutdown(), shut down the server directly
                        // instead of forwarding the request to the client.
                        // Closing the WebSocket server disconnects all clients.
                        const wrappedClientIO = {
                            ...clientIORpcClient,
                            shutdown: () => {
                                shutdownServer();
                            },
                        };

                        const result =
                            await conversationManager.joinConversation(
                                conversationId,
                                wrappedClientIO,
                                () => {
                                    channelProvider.deleteChannel(
                                        getDispatcherChannelName(
                                            conversationId,
                                        ),
                                    );
                                    channelProvider.deleteChannel(
                                        getClientIOChannelName(conversationId),
                                    );
                                    joinedConversations.delete(conversationId);
                                },
                                options,
                            );

                        const dispatcherChannel = channelProvider.createChannel(
                            getDispatcherChannelName(conversationId),
                        );
                        try {
                            createDispatcherRpcServer(
                                result.dispatcher,
                                dispatcherChannel,
                            );
                        } catch (e) {
                            channelProvider.deleteChannel(
                                getDispatcherChannelName(conversationId),
                            );
                            throw e;
                        }

                        joinedConversations.set(conversationId, {
                            dispatcher: result.dispatcher,
                            connectionId: result.connectionId,
                        });

                        const joinResult: JoinConversationResult = {
                            connectionId: result.connectionId,
                            conversationId,
                            name: result.name,
                            pendingInteractions:
                                result.pendingInteractions ?? [],
                        };
                        if (result.queueSnapshot !== undefined) {
                            joinResult.queueSnapshot = result.queueSnapshot;
                        }
                        return joinResult;
                    } catch (e) {
                        channelProvider.deleteChannel(
                            getClientIOChannelName(conversationId),
                        );
                        throw e;
                    }
                },

                leaveConversation: async (conversationId: string) => {
                    const entry = joinedConversations.get(conversationId);
                    if (entry === undefined) {
                        throw new Error(
                            `Not joined to conversation: ${conversationId}`,
                        );
                    }
                    // Channel cleanup runs in the closeFn passed to
                    // sharedDispatcher.join() via dispatcher.close(); don't
                    // double-delete here.
                    await conversationManager.leaveConversation(
                        conversationId,
                        entry.connectionId,
                    );
                },

                createConversation: async (name: string) => {
                    return conversationManager.createConversation(name);
                },

                listConversations: async (name?: string) => {
                    return conversationManager.listConversations(name);
                },

                renameConversation: async (
                    conversationId: string,
                    newName: string,
                ) => {
                    return conversationManager.renameConversation(
                        conversationId,
                        newName,
                    );
                },

                deleteConversation: async (conversationId: string) => {
                    // Channel cleanup for any joined client of this conversation
                    // runs in the closeFn passed to sharedDispatcher.join() via
                    // sharedDispatcher.close() → closeAllClients() →
                    // dispatcher.close(); don't double-delete here.
                    return conversationManager.deleteConversation(
                        conversationId,
                    );
                },
                shutdown: shutdownServer,
                getUserIdentity: async () => userIdentity,
            };

            // Clean up all conversations on WebSocket disconnect
            channelProvider.on("disconnect", () => {
                connectionCount--;
                scheduleIdleShutdown();
                for (const [
                    conversationId,
                    { connectionId },
                ] of joinedConversations.entries()) {
                    conversationManager
                        .leaveConversation(conversationId, connectionId)
                        .catch(() => {
                            // Best effort on disconnect
                        });
                }
                joinedConversations.clear();
            });

            createRpc(
                "agent-server",
                channelProvider.createChannel(AgentServerChannelName),
                invokeFunctions,
            );

            // Discovery channel: read-only port lookup for external
            // clients (browser extension, VS Code extension, CLI). Hosted
            // on the same WS as agent-server so clients only need one
            // connection. Mutations to the registrar are NOT exposed
            // here — only agents themselves can register, via the
            // in-process SessionContext.registerPort. The handler
            // factory is shared with the standalone Electron shell so
            // both hosts speak the same protocol byte-for-byte.
            createRpc(
                "agent-server:discovery",
                channelProvider.createChannel(DiscoveryChannelName),
                createDiscoveryHandlers((agentName, role) =>
                    portRegistrar.lookup(agentName, role),
                ),
            );
        },
    );

    // Register the agent-server's own listen port as a regular
    // allocation under the well-known AGENT_SERVER_DISCOVERY_NAME with
    // the synthetic SYSTEM_SESSION_CONTEXT_ID. This gives discovery
    // clients a uniform lookup path (no special-case in the discovery
    // handler) and lets the registrar's collision guard flag agents
    // that try to bind the same port via the same code path it uses
    // for any other allocation. The system sessionContextId protects
    // the entry from releaseAllForSession when real conversation
    // sessions close — it lives for the lifetime of the process.
    portRegistrar.register(
        AGENT_SERVER_DISCOVERY_NAME,
        "default",
        port,
        SYSTEM_SESSION_CONTEXT_ID,
    );

    console.log(`Agent server started at ws://localhost:${port}`);
    writeServerPid(port, process.pid);
    scheduleIdleShutdown();
}

process.on("unhandledRejection", (reason, _promise) => {
    console.error("[agent-server] Unhandled promise rejection:", reason);
    // Log but do not exit — crashing the server kills all concurrent workers.
});

process.on("uncaughtException", (err) => {
    console.error("[agent-server] Uncaught exception:", err);
    // Log but do not exit for non-fatal errors.
});

await main().catch((err: any) => {
    if (err?.code === "ERR_INSTANCE_LOCKED") {
        // Friendly, single-line message — no stack trace for this expected
        // case (another shell/server already owns the profile directory).
        console.error(`\n[agent-server] ${err.message}\n`);
        process.exit(1);
    }
    console.error("[agent-server] Fatal startup error:", err);
    process.exit(1);
});
