// src/gadget-drone.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

import env from "./config/env.ts";
import assert from "node:assert";
import fs from "node:fs/promises";
import path from "node:path";

import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client";
import { input as inqInput, password as inqPassword } from "@inquirer/prompts";

import AgentService, { IAgentWorkOrder } from "./services/agent.ts";
import AiService from "./services/ai.ts";
import PlatformService from "./services/platform.ts";
import SubProcessService from "./services/subprocess.ts";
import WorkspaceService from "./services/workspace.ts";
import {
  DroneStatus,
  GadgetLog,
  GadgetLogTransportSocket,
  IUser,
  SubProcessStat,
} from "@gadget/api";

import { GadgetProcess } from "./lib/process.ts";
import {
  ClientToServerEvents,
  FileTreeEntry,
  FileTreeRequestArgs,
  IChatSession,
  IChatTurn,
  IDroneRegistration,
  IProject,
  ProcessWorkOrderCallback,
  RequestSessionLockCallback,
  RequestWorkspaceModeCallback,
  ServerToClientEvents,
  WorkspaceMode,
} from "@gadget/api";

interface UserCredentials {
  email: string;
  password: string;
}

interface ISessionLock {
  project: IProject;
  session: IChatSession;
}

type ClientSocket = Socket<ServerToClientEvents, ClientToServerEvents>;

class GadgetDrone extends GadgetProcess {
  private registration: IDroneRegistration | undefined;
  private user: IUser | undefined;

  private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing;
  private sessionLock: ISessionLock | undefined;
  private isProcessingWorkOrder: boolean = false;

  private socket: ClientSocket | undefined;
  private isShuttingDown: boolean = false;
  private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;

  get name(): string {
    return "GadgetDrone";
  }

  get slug(): string {
    return "gadget-drone";
  }

  constructor() {
    super();
  }

  async start(): Promise<void> {
    /*
     * Initialize the system
     */

    this.hookProcessSignals();
    await this.startServices();

    /*
     * Initialize workspace directory structure and load/create workspace identity.
     */

    const workspaceDir = process.cwd();
    await WorkspaceService.initialize(workspaceDir);
    this.log.info("workspace initialized", {
      workspaceId: WorkspaceService.workspaceId,
      workspaceDir,
    });

    /*
     * Register this Drone with the Gadget Code web services platform.
     * Include the workspace ID for crash recovery routing.
     */

    const credentials = await this.getUserCredentials();
    this.registration = await PlatformService.register(
      credentials.email,
      credentials.password,
      workspaceDir,
      WorkspaceService.workspaceId!,
    );

    this.user = this.registration.user as IUser;
    this.log.info("registered with platform", {
      registrationId: this.registration._id,
      user: this.user.displayName,
    });

    // Update workspace with registration
    WorkspaceService.updateRegistration({
      _id: this.registration._id,
      status: DroneStatus.Starting,
    });
    await WorkspaceService.writeWorkspaceData();

    /*
     * Connect to the Gadget Code web services platform and configure the real-
     * time messaging system on Socket.IO.
     */
    await this.connectSocket();

    /*
     * Check for incomplete work order (crash recovery)
     */
    await this.checkCrashRecovery();

    /*
     * Mark this Drone as available and ready to accept work orders.
     */

    await PlatformService.setStatus(DroneStatus.Available);
    WorkspaceService.updateRegistration({
      _id: this.registration._id,
      status: DroneStatus.Available,
    });
    await WorkspaceService.writeWorkspaceData();

    this.log.info(`Gadget Drone v${env.pkg.version} started`);
  }

  async stop(): Promise<number> {
    this.log.info(`Gadget Drone v${env.pkg.version} shutting down`);
    this.isShuttingDown = true;

    if (this.heartbeatTimer) {
      clearTimeout(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }

    if (this.socket) {
      this.socket.disconnect();
      delete this.socket;
    }

    try {
      await PlatformService.unregister();
    } catch (error) {
      this.log.error("failed to unregister drone from Gadget Code platform", {
        error,
      });
      // fall through
    }

    await this.stopServices();

    return 0;
  }

  async startServices(): Promise<void> {
    this.log.info("starting services");

    await AgentService.start();
    await AiService.start();
    await PlatformService.start();
    await SubProcessService.start();

    this.log.info("services started");
  }

  async stopServices(): Promise<void> {
    this.log.info("stopping services");

    await AgentService.stop();
    await AiService.stop();
    await PlatformService.stop();
    await SubProcessService.stop();

    this.log.info("services stopped");
  }

  async connectSocket(): Promise<void> {
    return new Promise((resolve, reject) => {
      assert(this.registration, "must be registered with Gadget Code platform");
      const options: Partial<ManagerOptions & SocketOptions> = {
        auth: { token: this.registration._id },
        reconnectionAttempts: 10,
        timeout: 5000,
        transports: ["websocket"],
      };

      /*
       * Allow self-signed certs in non-production environments
       */
      if (env.NODE_ENV !== "production") {
        options.rejectUnauthorized = false;
      }

      this.log.debug("connecting to Gadget Code platform...");
      this.socket = io("https://code-dev.g4dge7.com:5174/", options);
      this.socket.on("connect_error", (err) => {
        this.log.error("socket connect error", { err });
        reject(err);
      });
      this.socket.on("connect", () => {
        this.log.info("connected to Gadget Code platform.");

        const socketTransport = new GadgetLogTransportSocket(
          (event, timestamp, component, level, message, metadata): void => {
            (this.socket as any)?.emit(
              event,
              timestamp,
              { name: component.name, slug: component.slug },
              level,
              message,
              metadata,
            );
          },
        );
        GadgetLog.addDefaultTransport(socketTransport);
        this.log.transports.push(socketTransport);
        AgentService.log.transports.push(socketTransport);
        AiService.log.transports.push(socketTransport);
        PlatformService.log.transports.push(socketTransport);

        resolve();
      });

      this.socket.on(
        "requestSessionLock",
        this.onRequestSessionLock.bind(this),
      );
      this.socket.on(
        "requestWorkspaceMode",
        this.onRequestWorkspaceMode.bind(this),
      );
      this.socket.on("processWorkOrder", this.onProcessWorkOrder.bind(this));
      this.socket.on(
        "releaseSessionLock",
        this.onReleaseSessionLock.bind(this),
      );
      this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
      this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
      this.socket.on(
        "requestTermination",
        this.onRequestTermination.bind(this),
      );
      this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
      this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
      this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
      this.socket.on(
        "requestProcessStats",
        this.onRequestProcessStats.bind(this),
      );

      /*
       * Handle socket disconnect: clear the heartbeat timer to prevent
       * spurious timeout firing while disconnected.
       */
      this.socket.on("disconnect", (reason) => {
        this.log.info("socket disconnected from platform", { reason });
        if (this.heartbeatTimer) {
          clearTimeout(this.heartbeatTimer);
          this.heartbeatTimer = null;
        }
      });

      /*
       * Handle socket reconnect: re-emit current drone status so the
       * platform knows the drone is still alive and available.
       * NOTE: "reconnect" is a Manager-level event in Socket.IO v4,
       * not a Socket-level event, so we listen on socket.io.
       */
      this.socket.io.on("reconnect", (attemptNumber: number) => {
        this.log.info("socket reconnected to platform", { attemptNumber });
        if (this.sessionLock) {
          this.socket?.emit("status", "session lock active (reconnected)");
        } else {
          this.socket?.emit("status", "available (reconnected)");
        }
      });
    });
  }

  async onRequestSessionLock(
    registration: IDroneRegistration,
    project: IProject,
    chatSession: IChatSession,
    cb: RequestSessionLockCallback,
  ) {
    assert(this.socket, "invalid application state");

    /*
     * Validate gadget-drone registration to ensure correct sync with IDE
     */

    if (!this.registration) {
      this.log.warn(
        "received session lock request without a valid platform registration",
      );
      return cb(false, "not registered");
    }
    if (registration._id !== this.registration._id) {
      this.log.warn(
        "received session lock request for a different drone registration",
        {
          myId: this.registration._id,
          requestId: registration._id,
        },
      );
      return cb(false, "invalid registration");
    }

    /*
     * Check if the chat session lock is already held.
     */

    if (this.sessionLock) {
      if (chatSession._id !== this.sessionLock.session._id) {
        this.log.warn("rejecting session lock request", {
          sessionLock: {
            project: {
              _id: this.sessionLock.project._id,
              name: this.sessionLock.project.name,
            },
            session: {
              _id: this.sessionLock.session._id,
              name: this.sessionLock.session.name,
            },
          },
          workspaceMode: this.workspaceMode,
        });
        return cb(false, this.sessionLock.session._id);
      }
      // fall through to grant this lock request
      this.log.info("chat session is re-connecting to session lock");
    }

    /*
     * Grant the chat session lock.
     */

    this.log.info("granting session lock", {
      registrationId: registration._id,
      project: { _id: project._id, slug: project.slug },
      chatSession: { _id: chatSession._id, name: chatSession.name },
    });
    this.sessionLock = {
      project,
      session: chatSession,
    };
    this.workspaceMode = WorkspaceMode.Idle;
    this.socket.emit("status", "session lock granted");

    /*
     * Check if this project is already deployed (by ID, not slug).
     */
    const haveProjectInWorkspace = WorkspaceService.hasProjectById(project._id);
    if (!haveProjectInWorkspace) {
      this.socket.emit("status", `deploying project [slug=${project.slug}]`);
      await WorkspaceService.deployProject(project);
    }

    /*
     * Add/update the project in workspace data and lock to it.
     */
    WorkspaceService.addProject({
      _id: project._id,
      slug: project.slug,
      gitUrl: project.gitUrl,
    });

    if (!haveProjectInWorkspace) {
      WorkspaceService.markProjectDeployed(project.slug);
    }

    WorkspaceService.updateLockedProject({
      _id: project._id,
      slug: project.slug,
      gitUrl: project.gitUrl,
    });

    /*
     * Commit the changes to the workspace.
     */
    await WorkspaceService.writeWorkspaceData();
    cb(true, chatSession._id);

    this.socket.emit("status", "session lock granted");
  }

  async onReleaseSessionLock(
    registration: IDroneRegistration,
    project: IProject,
    chatSession: IChatSession,
    cb: (success: boolean) => void,
  ) {
    if (!this.registration) {
      return cb(false);
    }
    if (registration._id !== this.registration._id) {
      return cb(false);
    }

    if (!this.sessionLock) {
      this.log.info("releaseSessionLock: no lock held, nothing to release");
      return cb(true);
    }

    if (chatSession._id !== this.sessionLock.session._id) {
      this.log.warn("releaseSessionLock: session mismatch", {
        requested: chatSession._id,
        current: this.sessionLock.session._id,
      });
    }

    this.log.info("releasing session lock", {
      project: { _id: project._id, slug: project.slug },
      chatSession: { _id: chatSession._id, name: chatSession.name },
    });

    // Clear the heartbeat timer to prevent spurious timeout after release
    if (this.heartbeatTimer) {
      clearTimeout(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }

    this.sessionLock = undefined;
    this.workspaceMode = WorkspaceMode.Syncing;
    this.socket?.emit("status", "session lock released");
    cb(true);
  }

  async onSessionHeartbeat(cb: (ack: boolean) => void) {
    if (this.heartbeatTimer) {
      clearTimeout(this.heartbeatTimer);
    }
    this.heartbeatTimer = setTimeout(() => {
      if (this.isShuttingDown) return;
      this.log.warn("heartbeat timeout: releasing session lock");
      this.sessionLock = undefined;
      this.workspaceMode = WorkspaceMode.Syncing;
      this.socket?.emit(
        "status",
        "session lock released due to heartbeat timeout",
      );
      this.heartbeatTimer = null;
    }, 120000);

    cb(true);
  }

  async onRequestWorkspaceMode(
    registration: IDroneRegistration,
    project: IProject,
    chatSession: IChatSession,
    mode: WorkspaceMode,
    cb: RequestWorkspaceModeCallback,
  ) {
    if (!this.sessionLock) {
      return cb(false, this.workspaceMode);
    }
    if (chatSession._id !== this.sessionLock.session._id) {
      this.log.warn("rejecting workspace mode request", {
        chatSession: { _id: chatSession._id, name: chatSession.name },
      });
      return cb(
        false,
        this.workspaceMode,
        `This drone is locked to a different session (${this.sessionLock.session.name})`,
      );
    }

    this.log.info("requestWorkspaceMode received", {
      registration: { _id: registration._id },
      project: {
        _id: project._id,
        name: project.name,
      },
      chatSession: {
        _id: chatSession._id,
        name: chatSession.name,
      },
      currentMode: this.workspaceMode,
      requestedMode: mode,
    });

    let newMode: WorkspaceMode | null = null;
    let rejectionReason: string | undefined;

    switch (this.workspaceMode) {
      case WorkspaceMode.Idle:
        if (mode === WorkspaceMode.User || mode === WorkspaceMode.Agent) {
          newMode = mode;
        } else {
          rejectionReason = "Invalid mode transition from Idle";
        }
        break;

      case WorkspaceMode.User:
        if (mode === WorkspaceMode.Agent) {
          newMode = mode;
        } else {
          rejectionReason = "Invalid mode transition from User mode";
        }
        break;

      case WorkspaceMode.Agent:
        if (mode === WorkspaceMode.User) {
          if (this.isProcessingWorkOrder) {
            rejectionReason =
              "Agent is currently working. Please wait for the current task to complete.";
          } else {
            newMode = mode;
          }
        } else {
          rejectionReason = "Invalid mode transition from Agent mode";
        }
        break;

      case WorkspaceMode.Syncing:
        rejectionReason = "Cannot change mode during sync operation";
        break;
    }

    if (newMode) {
      const previousMode = this.workspaceMode;
      this.workspaceMode = newMode;
      this.socket!.emit("workspaceModeChanged", this.workspaceMode);
      this.log.info("workspace mode changed", {
        previousMode,
        newMode,
      });
      return cb(true, this.workspaceMode);
    } else {
      this.log.info("workspace mode request rejected", {
        reason: rejectionReason,
      });
      return cb(false, this.workspaceMode, rejectionReason);
    }
  }

  async onProcessWorkOrder(
    registration: IDroneRegistration,
    turn: IChatTurn,
    cb: ProcessWorkOrderCallback,
  ) {
    const project = turn.project as IProject;
    const session = turn.session as IChatSession;

    if (!this.sessionLock) {
      return cb(false, "this drone is not locked to a chat session");
    }
    if (this.sessionLock.session._id !== session._id) {
      return cb(false, "this drone is not locked to your chat session");
    }
    if (this.sessionLock.project._id !== project._id) {
      return cb(
        false,
        `this drone is locked to a different project: ${this.sessionLock.project.name}`,
      );
    }
    if (this.workspaceMode !== WorkspaceMode.Agent) {
      return cb(false, "this drone's workspace is not in Agent mode");
    }

    if (!this.socket) {
      this.log.error("cannot process work order: no socket connection");
      cb(false, "No socket connection");
      return;
    }

    const context = await PlatformService.getChatSessionContext(session);
    const order: IAgentWorkOrder = {
      createdAt: turn.createdAt,
      context: context.data,
      turn,
    };
    this.log.info("processWorkOrder received", {
      registration: { _id: registration._id },
      project: { _id: project._id, name: project.name },
      session: { _id: session._id, name: session.name },
      turn: { _id: turn._id, mode: turn.mode, userPrompt: turn.prompts.user },
    });

    // Write work order cache BEFORE processing (for crash recovery)
    try {
      await WorkspaceService.writeWorkOrderCache(turn);
    } catch (error) {
      const err = error as Error;
      this.log.error("failed to write work order cache", {
        error: err.message,
      });
      // Continue anyway - cache is for recovery, not required
    }

    cb(true, "work order accepted"); // confirm that drone has the work order

    const workspaceDir =
      WorkspaceService.workspaceData?.workspaceDir || process.cwd();
    const projectDir = WorkspaceService.getProjectDirectory(project.slug);
    process.chdir(projectDir);

    this.isProcessingWorkOrder = true;
    try {
      this.socket.emit("status", "processing work order");
      await AgentService.process(order, this.socket);
      this.socket.emit("status", "work order processing finished");
      await WorkspaceService.removeWorkOrderCache();
    } catch (error) {
      const err = error as Error;
      this.log.error("work order processing failed", {
        error: err.message,
      });
      this.socket.emit(
        "status",
        `failed to process work order: ${(error as Error).message}`,
      );
      await WorkspaceService.removeWorkOrderCache();
    } finally {
      process.chdir(workspaceDir);
      this.isProcessingWorkOrder = false;
      this.log.info("work order processing complete", {
        isProcessingWorkOrder: this.isProcessingWorkOrder,
      });
    }
  }

  hookProcessSignals(): void {
    process.title = this.name;

    process.on("unhandledRejection", async (error: Error, p) => {
      this.log.error("Unhandled rejection", {
        error,
        promise: p,
        stack: error.stack,
      });

      const exitCode = await this.stop();
      process.exit(exitCode);
    });

    process.on("warning", (error) => {
      if (error.name === "DeprecationWarning") return;
      this.log.alert("warning", { error });
    });

    process.on("SIGINT", async () => {
      this.log.info("SIGINT received");
      if (this.isShuttingDown) return;

      this.log.info("requesting shutdown");
      const exitCode = await this.stop();

      process.exit(exitCode);
    });
  }

  async getUserCredentials(): Promise<UserCredentials> {
    const args = process.argv.slice(2);
    const userArg = args.find((a) => a.startsWith("--user="));
    const passArg = args.find((a) => a.startsWith("--password="));

    if (userArg && passArg) {
      return {
        email: userArg.split("=")[1],
        password: passArg.split("=")[1],
      };
    }

    return {
      email: await inqInput({ message: "📧 Enter Drone Email: " }),
      password: await inqPassword({ message: "🔑 Enter Password: " }),
    };
  }

  /**
   * Checks for incomplete work order and initiates crash recovery if needed.
   */
  async checkCrashRecovery(): Promise<void> {
    const cache = await WorkspaceService.readWorkOrderCache();
    if (!cache) {
      this.log.debug("no incomplete work order found");
      return;
    }

    const chatSession = cache.turn.session as IChatSession;
    this.log.warn("incomplete work order found - initiating crash recovery", {
      turnId: cache.turn._id,
      chatSessionId: chatSession._id,
    });

    if (!this.socket) {
      this.log.error("cannot initiate crash recovery: no socket connection");
      return;
    }

    // Notify web service that this workspace has pending recovery
    this.socket.emit("requestCrashRecovery", {
      workspaceId: WorkspaceService.workspaceId!,
      turnId: cache.turn._id,
      chatSessionId: chatSession._id,
    });
  }

  async onFileTreeRequest(
    args: FileTreeRequestArgs,
    cb: (
      success: boolean,
      data: { entries?: FileTreeEntry[]; error?: string },
    ) => void,
  ): Promise<void> {
    if (!this.sessionLock) {
      return cb(false, { error: "No session lock active" });
    }

    // Get the project directory for this session
    const projectSlug = this.sessionLock.project.slug;
    const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);

    this.log.debug("fileTreeRequest received", {
      projectSlug,
      projectRoot,
      requestedPath: args.path,
    });

    if (!projectRoot) {
      return cb(false, {
        error: `Project directory not found for slug: ${projectSlug}`,
      });
    }

    // If no path specified, list from project root
    // If path specified, resolve it relative to project root
    const targetPath = args.path
      ? path.resolve(projectRoot, args.path)
      : projectRoot;

    // Security: Ensure resolved path is within project root
    const normalizedTarget = path.normalize(targetPath);
    const normalizedRoot = path.normalize(projectRoot);
    if (
      !normalizedTarget.startsWith(normalizedRoot + path.sep) &&
      normalizedTarget !== normalizedRoot
    ) {
      this.log.warn("fileTreeRequest path traversal attempt", {
        targetPath: normalizedTarget,
        projectRoot: normalizedRoot,
      });
      return cb(false, { error: "Access denied: path outside project root" });
    }

    this.log.debug("fileTreeRequest resolved paths", {
      projectRoot,
      targetPath,
      isRoot: targetPath === projectRoot,
    });

    try {
      const stat = await fs.stat(targetPath);
      if (!stat.isDirectory()) {
        return cb(false, { error: "Path is not a directory" });
      }

      const entries = await this.listDirectoryForTree(
        targetPath,
        false, // showHidden
        0,
        args.maxDepth ?? 1,
        projectRoot,
      );

      this.log.debug("fileTreeRequest completed", {
        entryCount: entries.length,
        entries: entries.slice(0, 10).map((e) => e.name), // Log first 10 entries
      });

      cb(true, { entries });
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      this.log.error("failed to list directory for file tree", {
        path: args.path,
        targetPath,
        error: errorMessage,
      });
      cb(false, { error: `Failed to list directory: ${errorMessage}` });
    }
  }

  async onFileReadRequest(
    args: { path: string },
    cb: (success: boolean, data: { content?: string; error?: string }) => void,
  ): Promise<void> {
    if (!this.sessionLock) {
      return cb(false, { error: "No session lock active" });
    }

    // Get the project directory for this session
    const projectSlug = this.sessionLock.project.slug;
    const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);

    this.log.debug("fileReadRequest received", {
      projectSlug,
      projectRoot,
      requestedPath: args.path,
    });

    if (!projectRoot) {
      return cb(false, {
        error: `Project directory not found for slug: ${projectSlug}`,
      });
    }

    // Resolve path relative to project root
    const targetPath = path.resolve(projectRoot, args.path);

    // Security: Ensure resolved path is within project root
    const normalizedTarget = path.normalize(targetPath);
    const normalizedRoot = path.normalize(projectRoot);
    if (
      !normalizedTarget.startsWith(normalizedRoot + path.sep) &&
      normalizedTarget !== normalizedRoot
    ) {
      this.log.warn("fileReadRequest path traversal attempt", {
        targetPath: normalizedTarget,
        projectRoot: normalizedRoot,
      });
      return cb(false, { error: "Access denied: path outside project root" });
    }

    this.log.debug("fileReadRequest resolved paths", {
      projectRoot,
      targetPath,
    });

    try {
      const stat = await fs.stat(targetPath);
      if (!stat.isFile()) {
        return cb(false, { error: "Path is not a file" });
      }

      // Check file size (limit to 1MB)
      const maxSize = 1 * 1024 * 1024; // 1MB
      if (stat.size > maxSize) {
        return cb(false, {
          error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.`,
        });
      }

      // Read file content
      const content = await fs.readFile(targetPath, "utf-8");

      // Check for binary file (null bytes)
      if (content.includes("\0")) {
        return cb(false, { error: "Cannot edit binary files" });
      }

      this.log.debug("fileReadRequest completed", {
        path: args.path,
        size: stat.size,
      });

      cb(true, { content });
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      this.log.error("failed to read file", {
        path: args.path,
        targetPath,
        error: errorMessage,
      });
      cb(false, { error: `Failed to read file: ${errorMessage}` });
    }
  }

  async onFileWriteRequest(
    args: { path: string; content: string },
    cb: (success: boolean, data: { success?: boolean; error?: string }) => void,
  ): Promise<void> {
    if (!this.sessionLock) {
      return cb(false, { error: "No session lock active" });
    }

    // Validate workspace mode - only allow writes in User mode
    if (this.workspaceMode !== WorkspaceMode.User) {
      this.log.warn("fileWriteRequest rejected: not in User mode", {
        workspaceMode: this.workspaceMode,
      });
      return cb(false, { error: "File writing is only allowed in User mode" });
    }

    // Get the project directory for this session
    const projectSlug = this.sessionLock.project.slug;
    const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);

    this.log.debug("fileWriteRequest received", {
      projectSlug,
      projectRoot,
      requestedPath: args.path,
    });

    if (!projectRoot) {
      return cb(false, {
        error: `Project directory not found for slug: ${projectSlug}`,
      });
    }

    // Resolve path relative to project root
    const targetPath = path.resolve(projectRoot, args.path);

    // Security: Ensure resolved path is within project root
    const normalizedTarget = path.normalize(targetPath);
    const normalizedRoot = path.normalize(projectRoot);
    if (
      !normalizedTarget.startsWith(normalizedRoot + path.sep) &&
      normalizedTarget !== normalizedRoot
    ) {
      this.log.warn("fileWriteRequest path traversal attempt", {
        targetPath: normalizedTarget,
        projectRoot: normalizedRoot,
      });
      return cb(false, { error: "Access denied: path outside project root" });
    }

    this.log.debug("fileWriteRequest resolved paths", {
      projectRoot,
      targetPath,
    });

    try {
      // Ensure parent directory exists
      const parentDir = path.dirname(targetPath);
      await fs.mkdir(parentDir, { recursive: true });

      // Write file content
      await fs.writeFile(targetPath, args.content, "utf-8");

      this.log.info("fileWriteRequest completed", {
        path: args.path,
        size: args.content.length,
      });

      cb(true, { success: true });
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      this.log.error("failed to write file", {
        path: args.path,
        targetPath,
        error: errorMessage,
      });
      cb(false, { error: `Failed to write file: ${errorMessage}` });
    }
  }

  private async listDirectoryForTree(
    dir: string,
    showHidden: boolean,
    depth: number,
    maxDepth: number,
    projectRoot: string,
  ): Promise<FileTreeEntry[]> {
    const results: FileTreeEntry[] = [];

    let entries;
    try {
      entries = await fs.readdir(dir, { withFileTypes: true });
    } catch {
      return results;
    }

    for (const entry of entries) {
      // Skip hidden files unless explicitly requested
      if (!showHidden && entry.name.startsWith(".")) continue;
      // Always skip node_modules and .git
      if (entry.name === "node_modules" || entry.name === ".git") continue;

      const fullPath = path.join(dir, entry.name);
      const relativePath = path.relative(projectRoot, fullPath);

      let stat;
      try {
        stat = await fs.stat(fullPath);
      } catch {
        continue;
      }

      const fileTreeEntry: FileTreeEntry = {
        name: entry.name,
        path: relativePath,
        type: entry.isSymbolicLink()
          ? "symlink"
          : entry.isDirectory()
            ? "directory"
            : "file",
        size: stat.size,
        modified: stat.mtime.toISOString(),
        isHidden: entry.name.startsWith("."),
      };

      results.push(fileTreeEntry);

      // NO RECURSION - lazy loading is handled by frontend
      // The frontend will request children when directories are expanded
    }

    return results;
  }

  async onAbortWorkOrder(
    cb: (success: boolean, message?: string) => void,
  ): Promise<void> {
    this.log.info("abortWorkOrder received from platform", {
      registrationId: this.registration?._id,
      isProcessing: this.isProcessingWorkOrder,
    });

    const aborted = AgentService.abortCurrentWorkOrder();
    cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
  }

  async onRequestProcessStats(
    cb: (
      success: boolean,
      data?: { processes?: SubProcessStat[]; message?: string },
    ) => void,
  ): Promise<void> {
    try {
      const processes = SubProcessService.ps().map((sp) => {
        const summary = SubProcessService.summarize(sp);
        const hasExited = sp.process.exitCode !== null || sp.process.killed;
        const stat: SubProcessStat = {
          pid: summary.pid,
          command: sp.process.spawnargs[0] ?? "unknown",
          args: sp.process.spawnargs.slice(1),
          projectId: summary.project._id,
          projectSlug: summary.project.slug,
          projectName: summary.project.name,
          status: hasExited ? "stopped" : "running",
          createdAt: summary.createdAt.toISOString(),
          updatedAt: summary.updatedAt.toISOString(),
          stdoutFileName: summary.stdoutFileName,
          stderrFileName: summary.stderrFileName,
        };
        return stat;
      });

      cb(true, { processes });
    } catch (error) {
      this.log.error("failed to collect process stats", { error });
      cb(false, { message: "Failed to collect process stats" });
    }
  }

  async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
    this.log.info("requestTermination received from platform", {
      registrationId: this.registration?._id,
    });

    cb(true);

    process.kill(process.pid, "SIGINT");
  }
}

(async () => {
  try {
    const drone = new GadgetDrone();
    await drone.start();
  } catch (error) {
    console.error("failed to start gadget-drone", error);
    process.exit(-1);
  }
})();
