import {
  FAILURE_CODES,
  FailureError,
  TypedEventEmitter,
  type ServiceBlueprint,
  type ServiceEvents,
} from "@argent/registry";
import { discoverMetro } from "../utils/debugger/discovery";
import { classifyDevice } from "../utils/device-info";
import { proxyStart } from "../utils/sim-remote";
import { selectTarget } from "../utils/debugger/target-selection";
import { rememberDeviceAlias, forgetDeviceAlias } from "../utils/debugger/device-alias";
import { CDPClient, type ConsoleAPICalledParams } from "../utils/debugger/cdp-client";
import { createSourceResolver, type SourceResolver } from "../utils/debugger/source-resolver";
import { SourceMapsRegistry } from "../utils/debugger/source-maps";
import { DISABLE_LOGBOX_SCRIPT } from "../utils/debugger/scripts/disable-logbox";
import { LogFileWriter } from "../utils/debugger/log-file-writer";
import { consoleTimestampToIso } from "../utils/debugger/console-timestamp";
import { WebSocketServer, WebSocket } from "ws";
import * as http from "node:http";

export const JS_RUNTIME_DEBUGGER_NAMESPACE = "JsRuntimeDebugger";

export interface ConsoleLogEntry {
  id: number;
  level: string;
  args: Array<{ type: string; value?: unknown; description?: string }>;
  message: string;
  timestamp: number;
  stackTrace?: {
    callFrames: Array<{
      functionName: string;
      scriptId: string;
      url: string;
      lineNumber: number;
      columnNumber: number;
    }>;
  };
}

export type ConsoleLogEvents = {
  log: (entry: ConsoleLogEntry) => void;
};

function stringifyConsoleValue(value: unknown): string {
  if (typeof value === "object" && value !== null) {
    try {
      return JSON.stringify(value);
    } catch {
      return Object.prototype.toString.call(value);
    }
  }
  return String(value);
}

function formatConsoleArgs(params: ConsoleAPICalledParams): string {
  return params.args
    .map((arg) => {
      if (arg.value !== undefined) return stringifyConsoleValue(arg.value);
      if (arg.description) return arg.description;
      return `[${arg.type}]`;
    })
    .join(" ");
}

function createConsoleLogServer(
  consoleEvents: TypedEventEmitter<ConsoleLogEvents>,
  logWriter: LogFileWriter
): Promise<{ url: string; close: () => Promise<void> }> {
  return new Promise((resolve, reject) => {
    const server = http.createServer();
    const wss = new WebSocketServer({ server });

    wss.on("connection", (ws) => {
      for (const entry of logWriter.readAll()) {
        ws.send(JSON.stringify(entry));
      }

      const onLog = (entry: ConsoleLogEntry) => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify(entry));
        }
      };
      consoleEvents.on("log", onLog);
      ws.on("close", () => consoleEvents.off("log", onLog));
    });

    server.listen(0, "127.0.0.1", () => {
      const addr = server.address();
      if (!addr || typeof addr === "string") {
        reject(
          new FailureError("Failed to bind console log server", {
            error_code: FAILURE_CODES.JS_RUNTIME_CONSOLE_SERVER_BIND_FAILED,
            failure_stage: "js_runtime_console_server_bind",
            failure_area: "tool_server",
            error_kind: "network",
          })
        );
        return;
      }
      const url = `ws://127.0.0.1:${addr.port}`;
      resolve({
        url,
        close: () =>
          new Promise<void>((res) => {
            wss.clients.forEach((c) => c.close());
            wss.close(() => server.close(() => res()));
          }),
      });
    });

    server.on("error", reject);
  });
}

export interface JsRuntimeDebuggerApi {
  port: number;
  projectRoot: string;
  deviceName: string;
  appName: string;
  logicalDeviceId: string | undefined;
  isNewDebugger: boolean;
  cdp: CDPClient;
  sourceResolver: SourceResolver;
  sourceMaps: SourceMapsRegistry;
  logWriter: LogFileWriter;
  consoleEvents: TypedEventEmitter<ConsoleLogEvents>;
  consoleSocketUrl: string;
}

export const jsRuntimeDebuggerBlueprint: ServiceBlueprint<JsRuntimeDebuggerApi, string> = {
  namespace: JS_RUNTIME_DEBUGGER_NAMESPACE,

  getURN(payload: string) {
    return `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${payload}`;
  },

  async factory(_deps, payload, options?) {
    const colonIdx = payload.indexOf(":");
    if (colonIdx < 0) {
      throw new FailureError(
        `JsRuntimeDebugger payload must be "port:deviceId", got: "${payload}"`,
        {
          error_code: FAILURE_CODES.JS_RUNTIME_PAYLOAD_INVALID,
          failure_stage: "js_runtime_debugger_payload",
          failure_area: "tool_server",
          error_kind: "validation",
        }
      );
    }
    const deviceId = payload.slice(colonIdx + 1);
    if (!deviceId) {
      throw new FailureError(`JsRuntimeDebugger payload missing deviceId: "${payload}"`, {
        error_code: FAILURE_CODES.JS_RUNTIME_PAYLOAD_DEVICE_MISSING,
        failure_stage: "js_runtime_debugger_payload",
        failure_area: "tool_server",
        error_kind: "validation",
      });
    }
    const port = parseInt(payload.slice(0, colonIdx), 10);
    if (!Number.isFinite(port)) {
      throw new FailureError(`JsRuntimeDebugger payload has invalid port: "${payload}"`, {
        error_code: FAILURE_CODES.JS_RUNTIME_PAYLOAD_PORT_INVALID,
        failure_stage: "js_runtime_debugger_payload",
        failure_area: "tool_server",
        error_kind: "validation",
      });
    }

    // For a remote (cloud) sim the RN app reaches the developer's LOCAL Metro
    // through a sim-remote reverse tunnel: the sim's localhost:<port> is
    // forwarded to this host's Metro. The tool-server still talks to Metro
    // directly on localhost — discoverMetro below is unchanged — so only the
    // app→Metro hop needs the tunnel. proxyStart is idempotent, so re-ensuring
    // it on every (re)connect is cheap; if the app launched before the tunnel
    // existed it won't be in /json/list yet — the caller reloads/relaunches
    // once the tunnel is up so it registers as a CDP target.
    if (classifyDevice(deviceId) === "ios-remote") {
      await proxyStart(deviceId, port);
    }

    const metro = await discoverMetro(port);
    const selected = selectTarget(metro.targets, port, {
      ...options,
      deviceId,
    });

    const cdp = new CDPClient(selected.webSocketUrl);
    await cdp.connect();

    const sourceMaps = new SourceMapsRegistry(metro.projectRoot);

    cdp.events.on("scriptParsed", (script) => {
      sourceMaps.registerFromScriptParsed(script.url, script.scriptId, script.sourceMapURL);
    });

    const ignore = () => {};
    const warnOnError = (label: string) => (err: unknown) => {
      const msg = err instanceof Error ? err.message : String(err);
      process.stderr.write(`[JsRuntimeDebugger:${port}] ${label} failed (non-fatal): ${msg}\n`);
    };

    await cdp.send("FuseboxClient.setClientMetadata", {}).catch(ignore);
    await cdp.send("ReactNativeApplication.enable", {}).catch(ignore);
    await cdp.send("Runtime.enable");
    await cdp.send("Debugger.enable", { maxScriptsCacheSize: 100_000_000 });
    await cdp.send("Debugger.setPauseOnExceptions", { state: "none" });
    await cdp.send("Debugger.setAsyncCallStackDepth", { maxDepth: 32 }).catch(ignore);
    await cdp.send("Runtime.runIfWaitingForDebugger").catch(ignore);
    await cdp.addBinding("__argent_callback");

    await cdp.evaluate(DISABLE_LOGBOX_SCRIPT).catch(warnOnError("DISABLE_LOGBOX_SCRIPT"));

    await sourceMaps.waitForPending();

    const sourceResolver = createSourceResolver(port, metro.projectRoot);

    const logWriter = new LogFileWriter(port);
    const consoleEvents = new TypedEventEmitter<ConsoleLogEvents>();
    let nextLogId = 0;

    cdp.events.on("consoleAPICalled", (params) => {
      const entry: ConsoleLogEntry = {
        id: nextLogId++,
        level: params.type,
        args: params.args.map((a) => ({
          type: a.type,
          value: a.value,
          description: a.description,
        })),
        message: formatConsoleArgs(params),
        timestamp: params.timestamp,
        stackTrace: params.stackTrace as ConsoleLogEntry["stackTrace"],
      };
      logWriter.write({
        id: entry.id,
        timestamp: consoleTimestampToIso(entry.timestamp),
        level: entry.level,
        message: entry.message,
        stackTrace: entry.stackTrace,
      });
      consoleEvents.emit("log", entry);
    });

    const consoleServer = await createConsoleLogServer(consoleEvents, logWriter);

    const api: JsRuntimeDebuggerApi = {
      port,
      projectRoot: metro.projectRoot,
      deviceName: selected.deviceName,
      appName: selected.target.title,
      logicalDeviceId: selected.target.reactNative?.logicalDeviceId,
      isNewDebugger: selected.isNewDebugger,
      cdp,
      sourceResolver,
      sourceMaps,
      logWriter,
      consoleEvents,
      consoleSocketUrl: consoleServer.url,
    };

    // Both ids for this device are known here for the first (and only) time:
    // `deviceId` is what the caller connected with, `logicalDeviceId` is what
    // Metro echoed back. Record the alias so a later tool that forwards the
    // logicalDeviceId canonicalizes back to this instance instead of opening a
    // second connection. See utils/debugger/device-alias.ts.
    rememberDeviceAlias(api.logicalDeviceId, deviceId);

    const events = new TypedEventEmitter<ServiceEvents>();

    cdp.events.on("disconnected", (error) => {
      events.emit(
        "terminated",
        error ??
          new FailureError("CDP disconnected", {
            error_code: FAILURE_CODES.JS_RUNTIME_CDP_DISCONNECTED,
            failure_stage: "js_runtime_debugger_cdp_lifecycle",
            failure_area: "tool_server",
            error_kind: "network",
          })
      );
    });

    return {
      api,
      dispose: async () => {
        forgetDeviceAlias(api.logicalDeviceId);
        await consoleServer.close();
        logWriter.close();
        await cdp.disconnect();
      },
      events,
    };
  },
};
