import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type {
  AutomationGuard,
  ClickResult,
  FocusGenshinResult,
  GdiCaptureResult,
  HelperOperationResponse,
  KeyPressResult,
  NativeScannerCatalogStatus,
  NativeScannerDataStatus,
  NativeScannerPreflightStatus,
  NativeScannerRunStatus,
  WindowBounds,
  RuntimeInfo,
  ScrollResult,
} from "../../src/types/global.js";

import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js";

class InputHelperClient {
  private child: ChildProcessWithoutNullStreams | null = null;
  private pending = new Map<string, { resolve: (value: HelperOperationResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
  private buffer = "";
  private nextId = 1;
  private starting: Promise<void> | null = null;
  private disposed = false;

  constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}

  private async ensureStarted() {
    if (this.child) return;
    if (this.disposed) throw new Error("Input helper disposed");
    if (!this.starting) {
      this.starting = this.start().finally(() => {
        this.starting = null;
      });
    }
    await this.starting;
  }

  private async start() {
    // Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
    // start, fall back to the embedded PowerShell helper so the app keeps working
    // on machines where the native exe was never built.
    if (this.options.exePath) {
      try {
        await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
        return;
      } catch {
        this.teardownChild();
      }
    }
    await this.startWith(await this.spawnPowershell());
  }

  private async spawnPowershell() {
    const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1");
    await fs.mkdir(path.dirname(scriptPath), { recursive: true });
    await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
    return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
      windowsHide: true,
      stdio: ["pipe", "pipe", "pipe"],
    });
  }

  private async startWith(child: ChildProcessWithoutNullStreams) {
    child.stdout.setEncoding("utf8");
    child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
    child.stderr.setEncoding("utf8");
    child.stderr.on("data", () => undefined);
    child.on("exit", () => {
      this.child = null;
      this.buffer = "";
      for (const entry of this.pending.values()) {
        clearTimeout(entry.timer);
        entry.reject(new Error("Input helper exited"));
      }
      this.pending.clear();
    });
    this.child = child;

    // The C# sidecar answers ping immediately; the PowerShell fallback compiles
    // Win32 interop on the first request, so give it extra time.
    await this.send("ping", {}, 20000);
  }

  private teardownChild() {
    const child = this.child;
    this.child = null;
    this.buffer = "";
    try {
      child?.kill();
    } catch {
      // Child was never spawned or already gone.
    }
  }

  private handleStdout(chunk: string) {
    this.buffer += chunk;
    let newlineIndex = this.buffer.indexOf("\n");
    while (newlineIndex >= 0) {
      const line = this.buffer.slice(0, newlineIndex).trim();
      this.buffer = this.buffer.slice(newlineIndex + 1);
      newlineIndex = this.buffer.indexOf("\n");
      if (!line.startsWith("{")) continue;
      try {
        const message = JSON.parse(line) as HelperOperationResponse;
        const entry = this.pending.get(String(message.id));
        if (!entry) continue;
        this.pending.delete(String(message.id));
        clearTimeout(entry.timer);
        if (message.ok) entry.resolve(message);
        else entry.reject(new Error(message.error || "Input helper command failed"));
      } catch {
        // Ignore non-JSON noise on stdout.
      }
    }
  }

  private send(op: string, params: Record<string, unknown>, timeoutMs: number) {
    return new Promise<HelperOperationResponse>((resolve, reject) => {
      const child = this.child;
      if (!child?.stdin.writable) {
        reject(new Error("Input helper is not running"));
        return;
      }
      const id = String(this.nextId++);
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`Input helper timed out on ${op}`));
      }, timeoutMs);
      this.pending.set(id, { resolve, reject, timer });
      child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`);
    });
  }

  request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
    return this.ensureStarted().then(() => this.send(op, params, timeoutMs));
  }

  dispose() {
    this.disposed = true;
    this.child?.kill();
    this.child = null;
  }
}

export interface InputHelperService {
  getRuntimeInfo(): Promise<RuntimeInfo>;
  focusGenshinWindow(): Promise<FocusGenshinResult>;
  focusGenshinForScanStart(): Promise<FocusGenshinResult>;
  getGenshinWindowBounds(): Promise<WindowBounds | null>;
  clickScreen(x: number, y: number): Promise<ClickResult>;
  scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
  keyPress(key: string): Promise<KeyPressResult>;
  getAutomationGuard(): Promise<AutomationGuard>;
  capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
  nativeScannerDataStatus(dataDir: string): Promise<NativeScannerDataStatus>;
  nativeScannerCatalog(dataDir: string): Promise<NativeScannerCatalogStatus>;
  nativeScannerPreflight(dataDir: string, category?: string): Promise<NativeScannerPreflightStatus>;
  nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }): Promise<NativeScannerRunStatus>;
  nativeScannerStop(): Promise<NativeScannerRunStatus>;
  nativeScannerStatus(): Promise<NativeScannerRunStatus>;
  dispose(): void;
}

export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
  const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });

  async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
    return inputHelper.request(op, params, timeoutMs);
  }

  async function getRuntimeInfo() {
    const result = await request("runtime", {}, 4000);
    return {
      ok: true,
      isElevated: Boolean(result.isElevated),
      platform: process.platform,
      genshinFound: Boolean(result.genshinFound),
      genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined,
      targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
      foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
      foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined,
      helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined,
    };
  }

  async function focusGenshinWindow() {
    const result = await request("focus", {}, 6000);
    return {
      focused: Boolean(result.focused),
      alreadyForeground: Boolean(result.alreadyForeground),
      genshinFound: Boolean(result.genshinFound),
      foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
      targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
      setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined,
    };
  }

  async function focusGenshinForScanStart() {
    let result = await focusGenshinWindow();
    if (result.focused) return result;
    await new Promise((resolve) => setTimeout(resolve, 700));
    result = await focusGenshinWindow();
    return result;
  }

  async function getGenshinWindowBounds() {
    const result = await request("bounds", {}, 4000);
    if (!result.found) return null;
    return {
      x: Number(result.left),
      y: Number(result.top),
      width: Number(result.width),
      height: Number(result.height),
    };
  }

  async function clickScreen(x: number, y: number) {
    const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse;
    return {
      ok: true,
      x: Math.round(x),
      y: Math.round(y),
      cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
      cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
      escapePressed: Boolean(result.escapePressed),
      enterPressed: Boolean(result.enterPressed),
      f9Pressed: Boolean(result.f9Pressed),
      moved: Boolean(result.moved),
      clicked: Boolean(result.clicked),
      inputBlocked: Boolean(result.inputBlocked),
      focused: Boolean(result.focused),
      alreadyForeground: Boolean(result.alreadyForeground),
      foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
      targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
      isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
    };
  }

  async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) {
    const safeNotches = Math.max(-60, Math.min(60, Math.round(notches)));
    const params: Record<string, unknown> = { notches: safeNotches };
    if (typeof anchorX === "number" && typeof anchorY === "number") {
      params.x = Math.round(anchorX);
      params.y = Math.round(anchorY);
    }
    const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse;
    return {
      ok: true,
      notchesSent: Number(result.notchesSent ?? 0),
      inputBlocked: Boolean(result.inputBlocked),
      isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
    };
  }

  async function keyPress(key: string) {
    const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
    return {
      ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
      key,
      focused: Boolean(result.focused),
      foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
      targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
      isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
      inputBlocked: Boolean(result.inputBlocked),
      eventsSent: Number(result.eventsSent ?? 0),
    };
  }

  async function getAutomationGuard() {
    const result = await request("cursor", {}, 4000);
    return {
      ok: true,
      cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
      cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
      escapePressed: Boolean(result.escapePressed),
      enterPressed: Boolean(result.enterPressed),
      f9Pressed: Boolean(result.f9Pressed),
    };
  }

  async function capturePrimaryScreenViaGdi() {
    const result = await request("capture", {}, 15000);
    // The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
    // fallback writes a temp PNG and returns its path.
    let base64: string;
    if (typeof result.imageBase64 === "string" && result.imageBase64) {
      base64 = result.imageBase64;
    } else {
      const capturePath = String(result.path);
      const buffer = await fs.readFile(capturePath);
      await fs.unlink(capturePath).catch(() => undefined);
      base64 = buffer.toString("base64");
    }
    const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
    const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
      ? captureTargetRaw
      : "primary-screen";

    return {
      ...(typeof result.imageBase64 === "string" && result.imageBase64
        ? { imageBase64: base64 }
        : { dataUrl: `data:image/png;base64,${base64}` }),
      width: Number(result.width),
      height: Number(result.height),
      originX: Number(result.originX),
      originY: Number(result.originY),
      captureTarget,
    };
  }

  function scannerPayload<T>(result: HelperOperationResponse): T {
    return result.scanner as T;
  }

  async function nativeScannerDataStatus(dataDir: string) {
    return scannerPayload<NativeScannerDataStatus>(await request("scanner-data-status", { dataDir }, 5000));
  }

  async function nativeScannerCatalog(dataDir: string) {
    return scannerPayload<NativeScannerCatalogStatus>(await request("scanner-catalog", { dataDir }, 8000));
  }

  async function nativeScannerPreflight(dataDir: string, category = "artifacts") {
    return scannerPayload<NativeScannerPreflightStatus>(await request("scanner-preflight", { dataDir, category }, 8000));
  }

  async function nativeScannerStart(options: { dataDir: string; outputRoot: string; limit?: number; category?: string }) {
    return scannerPayload<NativeScannerRunStatus>(await request("scanner-start", {
      dataDir: options.dataDir,
      outputRoot: options.outputRoot,
      limit: options.limit ?? 100,
      category: options.category ?? "artifacts",
    }, 8000));
  }

  async function nativeScannerStop() {
    return scannerPayload<NativeScannerRunStatus>(await request("scanner-stop", {}, 4000));
  }

  async function nativeScannerStatus() {
    return scannerPayload<NativeScannerRunStatus>(await request("scanner-status", {}, 4000));
  }

  return {
    getRuntimeInfo,
    focusGenshinWindow,
    focusGenshinForScanStart,
    getGenshinWindowBounds,
    clickScreen,
    scrollScreen,
    keyPress,
    getAutomationGuard,
    capturePrimaryScreenViaGdi,
    nativeScannerDataStatus,
    nativeScannerCatalog,
    nativeScannerPreflight,
    nativeScannerStart,
    nativeScannerStop,
    nativeScannerStatus,
    dispose: () => inputHelper.dispose(),
  };
}
