import { spawn, ChildProcess, execFile } from 'child_process';
import { promisify } from 'util';
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
import * as http from 'http';
import * as net from 'net';
import { findSocketPath, waitForSocketPath } from './socket-finder';
import {
  DEFAULT_PROXY_PROCESS_READY_TIMEOUT_MS,
  DEFAULT_PROXY_TARGET_WAIT_TIMEOUT_MS,
  DEFAULT_PROXY_POLL_INITIAL_MS,
  DEFAULT_PROXY_POLL_MAX_MS,
} from '../config/defaults';

const execFileAsync = promisify(execFile);

// Supervisor: restart an unexpectedly-exited proxy with exponential backoff.
const RESTART_BASE_DELAY_MS = 1_000;
const RESTART_MAX_DELAY_MS = 30_000;
const RESTART_MAX_ATTEMPTS = 5;
/** A proxy that stayed up this long is considered stable; the attempt counter resets. */
const RESTART_STABLE_RESET_MS = 60_000;

export interface ProxyOptions {
  /**
   * WebKit Inspector proxy port for device connections.
   * Defaults to 9322 (chosen to avoid conflict with openchrome on 9222).
   * Can also be set via the OPENSAFARI_PROXY_PORT environment variable.
   */
  port?: number;
  /**
   * Port on which ios_webkit_debug_proxy serves the device list.
   * When not specified, derived as `port - 1` (e.g. 9321 for the default port 9322).
   * Can also be set via the OPENSAFARI_PROXY_DEVICE_LIST_PORT environment variable.
   * Explicit constructor option takes highest precedence.
   */
  deviceListPort?: number;
  /** Additional CLI flags for ios_webkit_debug_proxy */
  extraArgs?: string[];
}

/**
 * Manages an ios_webkit_debug_proxy process for WebKit remote debugging.
 *
 * Readiness is split into two distinct states:
 *
 *   Process-ready: the proxy HTTP endpoint responds (device-list page loads).
 *     This is always awaited by start().
 *
 *   Target-ready: at least one usable Safari/WebView page target exists under /json.
 *     This is opt-in via waitForTarget() and is NOT awaited by start().
 *     An empty [] response from /json is healthy (proxy is up) but not target-ready.
 *
 * The default device-connection port is **9322**, deliberately offset from the
 * Chrome DevTools default (9222) so OpenSafari and openchrome can coexist.
 *
 * Port resolution order:
 *  1. Explicit `port` option passed to the constructor
 *  2. `OPENSAFARI_PROXY_PORT` environment variable
 *  3. Default 9322
 *
 * Device-list port resolution order:
 *  1. Explicit `deviceListPort` option passed to the constructor
 *  2. `OPENSAFARI_PROXY_DEVICE_LIST_PORT` environment variable
 *  3. Derived as `port - 1` (default: 9321 for port 9322)
 *
 * This derivation ensures that multiple proxy instances on different ports
 * (e.g. port=9500 → deviceListPort=9499) can coexist without sharing the
 * same device-list port and causing false "reuse" detection.
 */
export class WebInspectorProxy {
  private process: ChildProcess | null = null;
  private _port: number;
  private _deviceListPort: number;
  private _running = false;
  private _reusing = false;
  /** True once a caller has asked for stop(); suppresses supervised restarts. */
  private stopRequested = false;
  /** True while teardown() is killing our own process (not an unexpected exit). */
  private tearingDown = false;
  private restartAttempts = 0;
  private restartTimer: ReturnType<typeof setTimeout> | null = null;
  private lastSpawnTime = 0;
  private lastStartOptions: { targetUdid?: string } | undefined;

  constructor(private options: ProxyOptions = {}) {
    const rawPort = process.env.OPENSAFARI_PROXY_PORT
      ? parseInt(process.env.OPENSAFARI_PROXY_PORT, 10)
      : undefined;
    const envPort = rawPort !== undefined && !Number.isNaN(rawPort) ? rawPort : undefined;
    const rawDeviceListPort = process.env.OPENSAFARI_PROXY_DEVICE_LIST_PORT
      ? parseInt(process.env.OPENSAFARI_PROXY_DEVICE_LIST_PORT, 10)
      : undefined;
    const envDeviceListPort = rawDeviceListPort !== undefined && !Number.isNaN(rawDeviceListPort)
      ? rawDeviceListPort
      : undefined;
    this._port = options.port ?? envPort ?? 9322;
    this._deviceListPort = options.deviceListPort ?? envDeviceListPort ?? (this._port - 1);
  }

  async findSocketPath(targetUdid?: string): Promise<string | null> {
    return findSocketPath(targetUdid ? { targetUdid } : undefined);
  }

  /**
   * Start the proxy process. Resolves once the proxy process is ready (process-ready state).
   *
   * Target-ready state (a Safari/WebView page being available) is NOT awaited here.
   * Call waitForTarget() separately when an active page is required.
   *
   * The reuse path also does NOT wait for a target — existing sessions may already
   * have one, and waiting would add unnecessary latency.
   */
  async start(options?: { targetUdid?: string }): Promise<void> {
    if (this._running) return;
    this.stopRequested = false;
    this.cancelScheduledRestart();
    this.lastStartOptions = options;

    // Check if our device-list port already has a healthy proxy (from another session)
    const deviceListInUse = await this.isPortInUse(this._deviceListPort);
    if (deviceListInUse) {
      const healthy = await this.isProxyHealthy();
      if (healthy) {
        console.error(`[WebInspectorProxy] Reusing existing proxy on port ${this._deviceListPort}`);
        this._running = true;
        this._reusing = true;
        this.registerRefSync();
        // Reuse path: process-ready is confirmed by isProxyHealthy(). No target wait.
        return;
      }
      throw new Error(
        `Port ${this._deviceListPort} already in use by a non-proxy process. ` +
        `Use a different port (e.g. OPENSAFARI_PROXY_PORT=${this._port + 100}) ` +
        `or stop the existing process.`
      );
    }

    const portInUse = await this.isPortInUse(this._port);
    if (portInUse) {
      throw new Error(
        `Port ${this._port} already in use. ` +
        `Use a different port (e.g. OPENSAFARI_PROXY_PORT=${this._port + 100}) ` +
        `or stop the existing process.`
      );
    }

    try {
      await execFileAsync('which', ['ios_webkit_debug_proxy']);
    } catch {
      throw new Error('ios_webkit_debug_proxy not found. Install: brew install ios-webkit-debug-proxy');
    }

    const socketPath = await waitForSocketPath({
      targetUdid: options?.targetUdid,
      timeout: 10_000,
    });
    if (!socketPath) {
      throw new Error('Web Inspector socket not found. Is a simulator booted?');
    }

    const args = [
      '-s', `unix:${socketPath}`,
      '-c', `null:${this._deviceListPort},:${this._port}-${this._port + 100}`,
      '-F',
      ...(this.options.extraArgs ?? []),
    ];

    this.process = spawn('ios_webkit_debug_proxy', args, {
      stdio: ['ignore', 'pipe', 'pipe'],
      detached: false,
    });

    this.lastSpawnTime = Date.now();
    this._running = true;
    this._reusing = false;
    this.registerRefSync();

    this.process.stderr?.on('data', (data: Buffer) => {
      console.error(`[WebInspectorProxy] ${data.toString().trim()}`);
    });

    this.process.on('error', (err) => {
      console.error(`[WebInspectorProxy] process error: ${err.message}`);
      this._running = false;
      this.process = null;
    });

    this.process.on('exit', (code) => {
      console.error(`[WebInspectorProxy] exited with code ${code}`);
      // Distinguish an unexpected crash from the three intentional paths:
      // caller stop() (stopRequested), teardown of our own failed start
      // (tearingDown), and reuse/detach (where _running is already false).
      const unexpected =
        this._running && !this._reusing && !this.stopRequested && !this.tearingDown;
      this._running = false;
      this.process = null;
      if (unexpected) {
        if (Date.now() - this.lastSpawnTime >= RESTART_STABLE_RESET_MS) {
          this.restartAttempts = 0;
        }
        this.scheduleRestart();
      }
    });

    // Only wait for process-ready (device-list endpoint responds).
    // Target-ready is opt-in via waitForTarget().
    try {
      await this.waitForProcessReady();
    } catch (err) {
      // teardown(), not stop(): this is internal cleanup of our own failed
      // start, not a caller-requested stop, so it must not suppress the
      // supervisor for future starts.
      try { await this.teardown(); } catch { /* swallow stop errors so original error propagates */ }
      throw err;
    }
  }

  /**
   * Wait until at least one Safari/WebView page target is available on the forwarding port.
   *
   * Uses adaptive polling: starts at DEFAULT_PROXY_POLL_INITIAL_MS and doubles each
   * iteration up to DEFAULT_PROXY_POLL_MAX_MS, reducing fixed latency when a target
   * appears quickly.
   *
   * On timeout, logs a warning and resolves (non-fatal) — the caller decides whether
   * to surface a "No Safari targets found" error to the user.
   *
   * @param options.timeout - Override the default target-wait timeout in ms.
   */
  async waitForTarget(options?: { timeout?: number }): Promise<void> {
    if (!this._running) {
      throw new Error('WebInspectorProxy must be started before waiting for a target');
    }

    const timeout = options?.timeout ?? DEFAULT_PROXY_TARGET_WAIT_TIMEOUT_MS;
    const start = Date.now();
    let pollInterval = DEFAULT_PROXY_POLL_INITIAL_MS;

    while (Date.now() - start < timeout) {
      if (!this._running) {
        throw new Error('WebInspectorProxy process exited while waiting for target');
      }
      try {
        const body = await this.httpGet(`http://localhost:${this._port}/json`);
        const targets = JSON.parse(body);
        if (Array.isArray(targets) && targets.length > 0) return;
      } catch { /* retry */ }
      // Cap the sleep so it never overshoots the remaining budget
      const remaining = timeout - (Date.now() - start);
      if (remaining <= 0) break;
      const sleepMs = Math.min(pollInterval, remaining);
      await new Promise(r => setTimeout(r, sleepMs));
      // Adaptive backoff: double the interval each iteration, capped at max
      pollInterval = Math.min(pollInterval * 2, DEFAULT_PROXY_POLL_MAX_MS);
    }

    // One last attempt at the boundary — a target that appeared during the final
    // sleep would otherwise be reported as "not ready".
    if (this._running) {
      try {
        const body = await this.httpGet(`http://localhost:${this._port}/json`);
        const targets = JSON.parse(body);
        if (Array.isArray(targets) && targets.length > 0) return;
      } catch { /* fall through to timeout warning */ }
    }

    // If the proxy died right at the timeout boundary, surface it as a hard error
    if (!this._running) {
      throw new Error('WebInspectorProxy process exited while waiting for target');
    }
    // Don't throw — callers that require a target should check listTargets() afterward
    console.error(`[WebInspectorProxy] No Safari target appeared within ${timeout}ms — Safari may not be open`);
  }

  /** Stop the proxy process gracefully with SIGKILL fallback. */
  async stop(): Promise<void> {
    this.stopRequested = true;
    this.cancelScheduledRestart();
    return this.teardown();
  }

  /**
   * Kill/detach the proxy process without marking a caller-requested stop.
   * Shared by stop() and by start()'s internal failure cleanup.
   */
  private async teardown(): Promise<void> {
    this.tearingDown = true;
    try {
      await this.teardownProcess();
    } finally {
      this.tearingDown = false;
    }
  }

  private async teardownProcess(): Promise<void> {
    const remaining = this.unregisterRefSync();

    if (this._reusing) {
      this._running = false;
      this._reusing = false;
      return;
    }
    if (!this.process) {
      this._running = false;
      return;
    }
    // Other sessions still using this proxy — detach but don't kill
    if (remaining > 0) {
      console.error(`[WebInspectorProxy] ${remaining} other session(s) still using proxy, not killing`);
      this.process = null;
      this._running = false;
      return;
    }
    // We're the last user — kill the proxy process
    const proc = this.process;
    return new Promise<void>((resolve) => {
      const timeout = setTimeout(() => {
        try { proc.kill('SIGKILL'); } catch { /* already dead */ }
        this.process = null;
        this._running = false;
        resolve();
      }, 3000);
      proc.once('exit', () => {
        clearTimeout(timeout);
        this.process = null;
        this._running = false;
        resolve();
      });
      proc.kill('SIGTERM');
    });
  }

  private cancelScheduledRestart(): void {
    if (this.restartTimer) {
      clearTimeout(this.restartTimer);
      this.restartTimer = null;
    }
  }

  /**
   * Schedule a supervised restart after an unexpected proxy exit.
   * Without this, a dead ios_webkit_debug_proxy left every WebKit reconnect
   * attempt failing until the whole MCP server was manually restarted.
   * Exponential backoff with a hard attempt cap so a permanently broken
   * environment (no booted simulator, missing binary) cannot spawn-loop.
   */
  private scheduleRestart(): void {
    if (this.stopRequested || this.restartTimer) return;
    if (this.restartAttempts >= RESTART_MAX_ATTEMPTS) {
      console.error(
        `[WebInspectorProxy] Proxy keeps exiting — giving up after ${RESTART_MAX_ATTEMPTS} restart attempts. ` +
        `Safari tooling will fail until the server is restarted.`,
      );
      return;
    }
    this.restartAttempts++;
    const delay = Math.min(
      RESTART_BASE_DELAY_MS * 2 ** (this.restartAttempts - 1),
      RESTART_MAX_DELAY_MS,
    );
    console.error(
      `[WebInspectorProxy] Unexpected exit — restarting in ${delay}ms ` +
      `(attempt ${this.restartAttempts}/${RESTART_MAX_ATTEMPTS})`,
    );
    this.restartTimer = setTimeout(() => {
      this.restartTimer = null;
      this.start(this.lastStartOptions)
        .then(() => {
          console.error(`[WebInspectorProxy] Proxy restarted successfully`);
        })
        .catch((err) => {
          console.error(`[WebInspectorProxy] Restart attempt failed: ${err}`);
          this.scheduleRestart();
        });
    }, delay);
    this.restartTimer.unref();
  }

  /** Whether the proxy process is currently running. */
  get running(): boolean {
    return this._running;
  }

  /** The port the proxy is configured to listen on. */
  get port(): number {
    return this._port;
  }

  /** The device list port. */
  get deviceListPort(): number {
    return this._deviceListPort;
  }

  /** The PID of the proxy process, or null if not running. */
  get pid(): number | null {
    return this.process?.pid ?? null;
  }

  /** Whether this instance is reusing another session's proxy. */
  get reusing(): boolean {
    return this._reusing;
  }

  private getRefFilePath(): string {
    return `/tmp/opensafari-proxy-${this._deviceListPort}.refs`;
  }

  /** Register this process as a proxy user (synchronous for exit handler compatibility). */
  private registerRefSync(): void {
    const refFile = this.getRefFilePath();
    let pids: number[] = [];
    try {
      const content = readFileSync(refFile, 'utf-8');
      pids = content.trim().split('\n').map(Number).filter(Boolean);
    } catch { /* file doesn't exist yet */ }
    // Clean stale PIDs (processes that no longer exist)
    pids = pids.filter(pid => {
      try { process.kill(pid, 0); return true; } catch { return false; }
    });
    if (!pids.includes(process.pid)) {
      pids.push(process.pid);
    }
    // NOTE: TOCTOU limitation — there is no file lock between the read above and
    // this write. Two processes starting simultaneously could overwrite each other's
    // entry. The window is very narrow and self-heals: stale-PID cleanup on the next
    // read will recover any dropped entry without lasting harm.
    writeFileSync(refFile, pids.join('\n') + '\n');
  }

  /** Unregister this process. Returns the number of remaining active references. */
  private unregisterRefSync(): number {
    const refFile = this.getRefFilePath();
    let pids: number[] = [];
    try {
      const content = readFileSync(refFile, 'utf-8');
      pids = content.trim().split('\n').map(Number).filter(Boolean);
    } catch { return 0; }
    // Remove self and clean stale PIDs
    // NOTE: Same narrow TOCTOU window as registerRefSync — no file lock between
    // read and write. Self-heals via stale-PID cleanup on subsequent reads.
    pids = pids.filter(pid => {
      if (pid === process.pid) return false;
      try { process.kill(pid, 0); return true; } catch { return false; }
    });
    if (pids.length > 0) {
      writeFileSync(refFile, pids.join('\n') + '\n');
    } else {
      try { unlinkSync(refFile); } catch { /* ignore */ }
    }
    return pids.length;
  }

  /**
   * Wait until the proxy process HTTP endpoint (device-list port) responds.
   * This confirms the proxy process is alive and serving — "process-ready".
   * An empty target list at the forwarding port is acceptable at this stage.
   */
  private async waitForProcessReady(timeout = DEFAULT_PROXY_PROCESS_READY_TIMEOUT_MS): Promise<void> {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      try {
        // Probe /json — works under both default frontend and -F (no-frontend)
        // modes. Always returns a JSON array (possibly empty) when the proxy
        // is alive. We spawn with -F (see start()), so probing `/` directly
        // can return non-HTML content and miss the readiness signal.
        const body = await this.httpGet(`http://localhost:${this._deviceListPort}/json`);
        if (body.trimStart().startsWith('[')) return;
      } catch { /* retry */ }
      await new Promise(r => setTimeout(r, 500));
    }
    throw new Error(`WebInspectorProxy did not become ready within ${timeout}ms`);
  }

  private isPortInUse(port: number): Promise<boolean> {
    return new Promise(resolve => {
      const socket = net.connect({ port, host: '127.0.0.1' });
      socket.setTimeout(2000);
      socket.once('connect', () => {
        socket.destroy();
        resolve(true);
      });
      socket.once('timeout', () => {
        socket.destroy();
        resolve(false);
      });
      socket.once('error', () => {
        socket.destroy();
        resolve(false);
      });
    });
  }

  private async isProxyHealthy(): Promise<boolean> {
    try {
      // See waitForReady — probe /json so the check works under -F mode.
      const body = await this.httpGet(`http://localhost:${this._deviceListPort}/json`);
      return body.trimStart().startsWith('[');
    } catch {
      return false;
    }
  }

  private httpGet(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
      const req = http.get(url, { timeout: 3000 }, res => {
        let data = '';
        res.on('data', chunk => { data += chunk; });
        res.on('end', () => resolve(data));
      });
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(); reject(new Error('HTTP request timed out')); });
    });
  }
}

// ---------------------------------------------------------------------------
// Module-level singleton so the proxy can be shared across tool calls
// and stopped cleanly on process exit.
// ---------------------------------------------------------------------------

let _sharedProxy: WebInspectorProxy | null = null;

export function getSharedProxy(): WebInspectorProxy {
  if (!_sharedProxy) {
    _sharedProxy = new WebInspectorProxy();
  }
  return _sharedProxy;
}

// Ensure the proxy is stopped when the host process exits — only if we own it
process.on('exit', () => {
  if (_sharedProxy) {
    // Note: stop() already called unregisterRefSync() during normal shutdown.
    // Calling it again here is intentional and harmless — our PID was already
    // removed, so this is a no-op that returns the current live ref count.
    const remaining = _sharedProxy['unregisterRefSync']();
    // Only kill proxy if we own it AND no other sessions reference it
    if (!_sharedProxy.reusing && _sharedProxy.running && remaining === 0) {
      try { _sharedProxy['process']?.kill('SIGTERM'); } catch { /* ignore */ }
    }
  }
});
