import {
    type ConnectionInfo,
    type IFileSystem,
    type IProjectCleanupOptions,
    type IProjectPackOptions,
    type IProjectToolFactory,
    type IProjectValidateOptions,
    type IToolLogger,
    PackJobTargetKind,
    type ProjectOperationContext,
    ToolErrorCodes,
    ToolResult,
    toRelativeUrl,
    translate,
} from "@uipath/solutionpackager-tool-core";
import { toProjectCleanupInfo } from "./cleanup-result.js";
import { PackResultProcessor } from "./pack-result-processor.js";
import {
    CompletedStatus,
    type PackAgentResult,
    PackagerHubConnection,
} from "./packager-hub-connection.js";

type ResolveCallback = (result: ToolResult) => void;

export enum BundleOperation {
    Pack = "Pack",
    Validate = "Validate",
    Cleanup = "Cleanup",
}

interface PackEntry {
    operation: BundleOperation.Pack;
    options: IProjectPackOptions;
    resolve: ResolveCallback;
}

interface ValidateEntry {
    operation: BundleOperation.Validate;
    options: IProjectValidateOptions;
    resolve: ResolveCallback;
}

interface CleanupEntry {
    operation: BundleOperation.Cleanup;
    options: IProjectCleanupOptions;
    resolve: ResolveCallback;
}

type ProjectEntry = PackEntry | ValidateEntry | CleanupEntry;

interface BundleSession {
    operation: BundleOperation;
    expectedCount: number;
    projects: Map<string, ProjectEntry>;
    timeout?: ReturnType<typeof setTimeout>;
}

export interface IProjectBundleExecutor {
    packAsync(
        options: IProjectPackOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult>;

    validateAsync(
        options: IProjectValidateOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult>;

    cleanupAsync(
        options: IProjectCleanupOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult>;
}

/**
 * Coordinates pack or validate of multiple projects within a single operation context.
 * Waits until all compatible projects have submitted their options,
 * then executes a single bundled remote-agent operation and resolves all pending promises.
 */
/**
 * Shape of the `POST /odata/Attachments` response. Orchestrator returns
 * PascalCase (`Id`, `BlobFileAccess.Uri`); the camelCase fallbacks mirror the
 * defensive reads in `@uipath/orchestrator-sdk`'s `uploadJobAttachment`.
 */
interface AttachmentResponse {
    Id?: string;
    id?: string;
    BlobFileAccess?: { Uri: string };
    blobFileAccess?: { Uri: string };
}

export class ProjectBundleExecutor implements IProjectBundleExecutor {
    static readonly SESSION_TIMEOUT_MS = 1_800_000;

    /**
     * Maximum length (in characters) of the inline `inputArguments` payload
     * accepted by the serverless jobs endpoint. Beyond this the arguments must
     * be uploaded as a file attachment and referenced via `inputFile`.
     */
    static readonly MAX_INLINE_INPUT_ARGUMENTS_LENGTH = 10_000;

    private readonly sessions = new Map<string, BundleSession>();
    private readonly pendingSessionCreations = new Map<
        string,
        Promise<BundleSession>
    >();

    constructor(
        private readonly toolFactory: IProjectToolFactory,
        private readonly fileSystem: IFileSystem,
    ) {}

    async packAsync(
        options: IProjectPackOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult> {
        return this.enqueueAsync(context, {
            operation: BundleOperation.Pack,
            options,
            resolve: () => {},
        });
    }

    async validateAsync(
        options: IProjectValidateOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult> {
        return this.enqueueAsync(context, {
            operation: BundleOperation.Validate,
            options,
            resolve: () => {},
        });
    }

    async cleanupAsync(
        options: IProjectCleanupOptions,
        context: ProjectOperationContext,
    ): Promise<ToolResult> {
        if (
            context.downloadUrl &&
            !options.dryRun &&
            !context.solutionId &&
            !context.projectId
        ) {
            return ToolResult.error(
                ToolErrorCodes.InternalError,
                "Cleanup save-back requires a solutionId or projectId for downloaded workspaces.",
            );
        }

        return this.enqueueAsync(context, {
            operation: BundleOperation.Cleanup,
            options,
            resolve: () => {},
        });
    }

    private async enqueueAsync(
        context: ProjectOperationContext,
        entry: ProjectEntry,
    ): Promise<ToolResult> {
        const projectPath = entry.options.projectPath;
        context.logger?.info(
            `[BundleExecutor] ${entry.operation}Async called for project: ${projectPath}`,
        );
        const session = await this.getSessionAsync(context, entry.operation);

        if (session.operation !== entry.operation) {
            return ToolResult.error(
                "BundleOperationMismatch",
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.bundleOperationMismatch",
                    {
                        sessionOperation: session.operation,
                        entryOperation: entry.operation,
                    },
                ),
            );
        }

        const promise = new Promise<ToolResult>((resolve) => {
            entry.resolve = resolve;
        });
        session.projects.set(projectPath, entry);
        const allReceived = session.projects.size === session.expectedCount;
        context.logger?.info(
            `[BundleExecutor] session (${session.operation}): ${session.projects.size}/${session.expectedCount} projects received. allReceived=${allReceived}`,
        );

        if (allReceived) {
            clearTimeout(session.timeout);
            await this.runAgentAsync(session, context);
            this.sessions.delete(context.id);
        }

        return promise;
    }

    private getSessionAsync(
        context: ProjectOperationContext,
        operation: BundleOperation,
    ): Promise<BundleSession> {
        const existing = this.sessions.get(context.id);
        if (existing) {
            return Promise.resolve(existing);
        }

        let pending = this.pendingSessionCreations.get(context.id);
        if (!pending) {
            pending = this.createSessionAsync(context, operation);
            this.pendingSessionCreations.set(context.id, pending);
        }
        return pending;
    }

    private async createSessionAsync(
        context: ProjectOperationContext,
        operation: BundleOperation,
    ): Promise<BundleSession> {
        const expectedCount = this.countCompatibleProjects(context);
        const session: BundleSession = {
            operation,
            expectedCount,
            projects: new Map(),
        };
        session.timeout = setTimeout(() => {
            if (session.projects.size < session.expectedCount) {
                context.logger?.error(
                    `[BundleExecutor] Session timed out after ${ProjectBundleExecutor.SESSION_TIMEOUT_MS}ms: ` +
                        `received ${session.projects.size}/${session.expectedCount} projects`,
                );
                this.resolveAll(
                    session,
                    ToolResult.error(
                        "SessionTimeoutError",
                        translate.t(
                            "toolWorkflowCompilerBrowser.errors.sessionTimeout",
                            {
                                received: session.projects.size,
                                expected: session.expectedCount,
                            },
                        ),
                    ),
                );
                this.sessions.delete(context.id);
            }
        }, ProjectBundleExecutor.SESSION_TIMEOUT_MS);
        this.sessions.set(context.id, session);
        this.pendingSessionCreations.delete(context.id);
        return session;
    }

    private async runAgentAsync(
        session: BundleSession,
        context: ProjectOperationContext,
    ): Promise<void> {
        const projectPaths = [...session.projects.keys()];
        context.logger?.info(
            translate.t(
                "toolWorkflowCompilerBrowser.info.startingRemoteAgent",
                {
                    operation: session.operation,
                    count: session.expectedCount,
                    paths: projectPaths.join(", "),
                },
            ),
        );

        const connection = context.connection;
        context.logger?.info(
            `[BundleExecutor] connection info: cloudUrl=${connection?.cloudUrl ?? "MISSING"}, hasToken=${!!connection?.accessToken}, folderId=${connection?.folderId ?? "MISSING"}`,
        );
        if (
            !connection?.cloudUrl ||
            !connection.accessToken ||
            !connection.folderId
        ) {
            this.resolveAll(
                session,
                ToolResult.error(
                    "ConnectionError",
                    translate.t(
                        "toolWorkflowCompilerBrowser.errors.missingConnectionInfo",
                    ),
                ),
            );
            return;
        }

        const sessionId = crypto.randomUUID();
        const hubConnection = await this.connectToHubAsync(
            connection,
            sessionId,
            context.logger,
        );
        if (!hubConnection) {
            this.resolveAll(
                session,
                ToolResult.error(
                    "SignalRConnectionError",
                    translate.t(
                        "toolWorkflowCompilerBrowser.errors.signalRConnectionFailed",
                    ),
                ),
            );
            return;
        }

        try {
            context.logger?.info(`[BundleExecutor] Sending BeginSession...`);
            await this.sendBeginSessionAsync(
                session,
                context,
                connection,
                sessionId,
            );
            context.logger?.info(
                `[BundleExecutor] BeginSession succeeded. Waiting for completion...`,
            );
            const result = await hubConnection.waitForCompletionAsync();
            context.logger?.info(
                `[BundleExecutor] Got result: errorCode=${result.errorCode}, status=${result.status}, message=${result.message}, bucketId=${result.bucketId}`,
            );
            await this.handleAgentResultAsync(result, session, context);
        } catch (error) {
            const message =
                error instanceof Error ? error.message : String(error);
            context.logger?.error(
                `[BundleExecutor] runAgentAsync caught error: ${message}`,
            );
            this.resolveAll(
                session,
                ToolResult.error(`${session.operation}AgentError`, message),
            );
        } finally {
            await hubConnection.disconnectAsync();
        }
    }

    private async connectToHubAsync(
        connection: ConnectionInfo,
        sessionId: string,
        logger?: IToolLogger,
    ): Promise<PackagerHubConnection | null> {
        const relativeBase = toRelativeUrl(connection.cloudUrl!);
        const hubUrl = `${relativeBase}/orchestrator_/signalr/robotdebug?sessionId=${sessionId}`;
        logger?.info(
            translate.t("toolWorkflowCompilerBrowser.info.connectingToHub", {
                url: hubUrl,
            }),
        );

        const hubConnection = new PackagerHubConnection(logger);
        try {
            await hubConnection.connectAsync(hubUrl, connection.accessToken!);
            return hubConnection;
        } catch (error) {
            const message =
                error instanceof Error ? error.message : String(error);
            logger?.error(
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.hubConnectionFailed",
                    { url: hubUrl, message },
                ),
            );
            return null;
        }
    }

    private async handleAgentResultAsync(
        result: PackAgentResult,
        session: BundleSession,
        context: ProjectOperationContext,
    ): Promise<void> {
        if (
            result.errorCode !== 0 ||
            result.status !== CompletedStatus.Succeeded
        ) {
            this.resolveAll(
                session,
                ToolResult.error(
                    `${session.operation}AgentError`,
                    result.message ??
                        translate.t(
                            "toolWorkflowCompilerBrowser.errors.agentFailed",
                            {
                                operation: session.operation,
                                status: result.status,
                                errorCode: result.errorCode,
                            },
                        ),
                ),
            );
            return;
        }

        if (session.operation === BundleOperation.Pack) {
            await this.handlePackBucketAsync(result, session, context);
            return;
        }

        if (session.operation === BundleOperation.Cleanup) {
            this.resolveCleanup(result, session, context);
            return;
        }

        this.resolveAll(session, ToolResult.success());
    }

    /**
     * The agent removed unused items in-place and (for cloud solutions) saved the
     * solution back to StudioWeb; there is no bucket to download. Resolve each
     * project with its own result carrying the changed files (the full map, which
     * solution-cleanup-service unions) and that project's cleanup payload mapped
     * to the generic {@link toProjectCleanupInfo} shape.
     */
    private resolveCleanup(
        result: PackAgentResult,
        session: BundleSession,
        context: ProjectOperationContext,
    ): void {
        for (const [projectPath, entry] of session.projects) {
            const details: Record<string, unknown> = {};
            if (result.changedFilesByProject) {
                details.changedFilesByProject = result.changedFilesByProject;
            }

            const designId = context.projects.find(
                (p) => p.ProjectPath === projectPath,
            )?.Id;
            const raw = designId
                ? result.unusedDependenciesByProject?.[designId]
                : undefined;
            if (raw) {
                details.projectCleanup = toProjectCleanupInfo(raw);
            }

            const projectResult = ToolResult.success();
            if (Object.keys(details).length > 0) {
                projectResult.details = details;
            }
            entry.resolve(projectResult);
        }
    }

    private async handlePackBucketAsync(
        result: PackAgentResult,
        session: BundleSession,
        context: ProjectOperationContext,
    ): Promise<void> {
        if (result.bucketId == null) {
            this.resolveAll(
                session,
                ToolResult.error(
                    "PackAgentError",
                    translate.t(
                        "toolWorkflowCompilerBrowser.errors.packSucceededNoBucket",
                    ),
                ),
            );
            return;
        }

        const packEntries = new Map<string, { options: IProjectPackOptions }>();
        for (const [projectPath, entry] of session.projects) {
            if (entry.operation === BundleOperation.Pack) {
                packEntries.set(projectPath, { options: entry.options });
            }
        }

        const resultProcessor = new PackResultProcessor(
            this.fileSystem,
            context.logger,
        );
        const perProjectResults = await resultProcessor.processAsync(
            result.bucketId,
            packEntries,
            context,
        );
        for (const [projectPath, entry] of session.projects) {
            const projectResult = perProjectResults.get(projectPath);
            entry.resolve(
                projectResult ??
                    ToolResult.error(
                        "PackResultError",
                        translate.t(
                            "toolWorkflowCompilerBrowser.errors.noResultForProject",
                        ),
                    ),
            );
        }
    }

    private async sendBeginSessionAsync(
        session: BundleSession,
        context: ProjectOperationContext,
        connection: ConnectionInfo,
        sessionId: string,
    ): Promise<void> {
        const relativeBase = toRelativeUrl(connection.cloudUrl!);
        const onLocalRobot =
            context.jobTarget?.kind === PackJobTargetKind.LocalRobot;
        // Two different job-creation APIs, not one endpoint with a flag: the serverless
        // endpoint forces RuntimeType=Serverless and takes no machine targeting, while
        // BeginSession takes the runtime/machine fields but no jobType. The agent behaves
        // the same either way — it dispatches on Command in the input arguments.
        const url = onLocalRobot
            ? `${relativeBase}/orchestrator_/api/RobotDebug/BeginSession`
            : `${relativeBase}/orchestrator_/api/serverless/jobs`;

        context.logger?.info(
            `[BundleExecutor] BeginSession URL: ${url} (target=${context.jobTarget?.kind ?? PackJobTargetKind.Serverless})`,
        );

        const firstEntry = session.projects.values().next().value!;
        const opts = firstEntry.options;
        const skipAnalyze = "skipAnalyze" in opts ? opts.skipAnalyze : false;
        const skipValidate = "skipValidate" in opts ? opts.skipValidate : false;

        const rpaProjects = [...session.projects.entries()].map(
            ([projectPath, entry]) => {
                const project = context.projects.find(
                    (p) => p.ProjectPath === projectPath,
                );
                return {
                    RelativePath: project?.ProjectRelativePath ?? "",
                    DesignId: project?.Id ?? "",
                    ProjectStorageId:
                        entry.operation === BundleOperation.Pack
                            ? (entry.options.projectStorageId ?? "")
                            : "",
                    PackageId:
                        entry.operation === BundleOperation.Pack
                            ? entry.options.package.id
                            : "",
                };
            },
        );

        const executorInfo: Record<string, unknown> = {
            Command: session.operation,
            SupportedProtocolVersions: ["2.0"],
            DownloadUrl: context.downloadUrl ?? "",
            SolutionId: context.solutionId ?? "",
            ProjectId: context.projectId ?? "",
            SessionId: sessionId,
            ProfileKey: "StudioWeb",
            LicenseType: "Development",
            LogLevel: opts.logLevel ?? "Warning",
            Culture: "en-US",
            DownloadTimeoutInSeconds: 300,
            SkipAnalyze: String(skipAnalyze),
            SkipValidate: String(skipValidate),
            TargetFramework: context.targetFramework,
            RpaProjects: rpaProjects,
            WorkflowCompilerVersion: context.workflowCompilerVersion ?? "",
        };

        if (firstEntry.operation === BundleOperation.Pack) {
            executorInfo.Configuration = context.configuration;
            const pkg = firstEntry.options.package;
            executorInfo.Package = {
                Name: pkg.id,
                Version: pkg.version,
                Author: pkg.author ?? "",
                Description: pkg.description ?? "",
                ReleaseNotes: pkg.releaseNotes ?? "",
                Tags: pkg.tags ?? "",
                IconUrl: pkg.iconUrl ?? "",
                ProjectUrl: pkg.projectUrl ?? "",
                RepositoryType: pkg.repositoryType ?? "",
                RepositoryUrl: pkg.repositoryUrl ?? "",
                RepositoryBranch: pkg.repositoryBranch ?? "",
                RepositoryCommit: pkg.repositoryCommit ?? "",
            };
            // Flat fields retained for backward compatibility with agents that
            // predate the nested Package structure.
            executorInfo.Name = pkg.id;
            executorInfo.Version = pkg.version;
            executorInfo.Author = pkg.author ?? "";
            executorInfo.Description = pkg.description ?? "";
            executorInfo.ReleaseNotes = pkg.releaseNotes ?? "";
        }

        if (firstEntry.operation === BundleOperation.Cleanup) {
            executorInfo.DryRun = firstEntry.options.dryRun;
            executorInfo.SkipImports = firstEntry.options.skipImports;
            // Forward the StudioWeb editing session's solution lock key so the agent's
            // save-back overwrite is accepted while the solution is locked by that session.
            if (context.lockKey) {
                executorInfo.LockKey = context.lockKey;
            }
        }

        const inputArguments = JSON.stringify(executorInfo);

        const body: Record<string, unknown> = onLocalRobot
            ? {
                  source: "StudioWeb",
                  targetFramework: context.targetFramework,
                  // Development runtime + runAsMe is what confines the job to this
                  // user's own machines; without both, Orchestrator is free to
                  // schedule it anywhere. Mirrors StudioWeb debug's payload.
                  runtimeType: "Development",
                  runAsMe: true,
                  jobsCount: 1,
                  strategy: "ModernJobsCount",
                  ...(context.jobTarget?.machineSessionIds?.length
                      ? {
                            machineSessionIds:
                                context.jobTarget.machineSessionIds,
                        }
                      : {}),
              }
            : {
                  source: "StudioWeb",
                  targetFramework: context.targetFramework,
                  jobType: "PublishStudioProject",
              };

        // Both job endpoints cap inline inputArguments at
        // MAX_INLINE_INPUT_ARGUMENTS_LENGTH characters. For larger payloads we
        // upload the arguments as a file attachment and reference it via
        // inputFile instead (the two are mutually exclusive on the server).
        if (
            inputArguments.length >
            ProjectBundleExecutor.MAX_INLINE_INPUT_ARGUMENTS_LENGTH
        ) {
            context.logger?.info(
                translate.t(
                    "toolWorkflowCompilerBrowser.info.offloadingInputArguments",
                    { length: inputArguments.length },
                ),
            );
            body.inputFile = await this.uploadInputArgumentsFileAsync(
                inputArguments,
                connection,
                context.logger,
            );
        } else {
            body.inputArguments = inputArguments;
        }

        const headers: Record<string, string> = {
            "Content-Type": "application/json",
            Authorization: `Bearer ${connection.accessToken}`,
            "X-UIPATH-OrganizationUnitId": connection.folderId ?? "",
        };

        if (connection.tenantId) {
            headers["x-uipath-tenantid"] = connection.tenantId;
        }

        const response = await fetch(url, {
            method: "POST",
            headers,
            body: JSON.stringify(body),
        });

        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.beginSessionFailed",
                    { status: response.status, errorText },
                ),
            );
        }
    }

    /**
     * Uploads the input arguments JSON as a file attachment and returns the
     * attachment id, to be passed as `inputFile` on the serverless job request.
     * Mirrors the two-step flow used by the Orchestrator UI: create the
     * attachment (which returns a signed blob write URI) then PUT the contents
     * to that URI using the storage-provided headers.
     */
    private async uploadInputArgumentsFileAsync(
        inputArguments: string,
        connection: ConnectionInfo,
        logger?: IToolLogger,
    ): Promise<string> {
        const baseUrl = toRelativeUrl(connection.cloudUrl!);
        const attachmentsUrl = `${baseUrl}/orchestrator_/odata/Attachments`;

        const headers: Record<string, string> = {
            "Content-Type": "application/json",
            Authorization: `Bearer ${connection.accessToken}`,
            "X-UIPATH-OrganizationUnitId": connection.folderId ?? "",
        };
        if (connection.tenantId) {
            headers["x-uipath-tenantid"] = connection.tenantId;
        }

        const createResponse = await fetch(attachmentsUrl, {
            method: "POST",
            headers,
            body: JSON.stringify({ name: "input.json" }),
        });
        if (!createResponse.ok) {
            const errorText = await createResponse.text();
            throw new Error(
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.createAttachmentFailed",
                    { status: createResponse.status, errorText },
                ),
            );
        }

        const attachment = (await createResponse.json()) as AttachmentResponse;
        const attachmentId = attachment.Id ?? attachment.id ?? "";
        const writeUri =
            attachment.BlobFileAccess?.Uri ?? attachment.blobFileAccess?.Uri;
        if (!attachmentId || !writeUri) {
            throw new Error(
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.attachmentResponseMalformed",
                ),
            );
        }

        // PUT the raw bytes to the SAS blob URI. The Azure blob endpoint
        // requires the BlockBlob type header; matches the proven contract in
        // `@uipath/orchestrator-sdk`'s uploadJobAttachment.
        const uploadResponse = await fetch(writeUri, {
            method: "PUT",
            headers: {
                "Content-Type": "application/octet-stream",
                "x-ms-blob-type": "BlockBlob",
            },
            body: inputArguments,
        });
        if (!uploadResponse.ok) {
            throw new Error(
                translate.t(
                    "toolWorkflowCompilerBrowser.errors.uploadInputArgumentsFailed",
                    {
                        status: uploadResponse.status,
                        statusText: uploadResponse.statusText,
                    },
                ),
            );
        }

        logger?.info(
            `[BundleExecutor] Uploaded input arguments as attachment ${attachmentId}`,
        );
        return attachmentId;
    }

    private resolveAll(session: BundleSession, result: ToolResult): void {
        for (const [, entry] of session.projects) {
            entry.resolve(result);
        }
    }

    private countCompatibleProjects(context: ProjectOperationContext): number {
        return context.projects.filter(
            (p) =>
                p.ProjectPath != null &&
                this.toolFactory.supportedTypes.includes(p.Type),
        ).length;
    }
}
