/**
 * OS / display-server capability detection for the desktop-control
 * subsystem.
 *
 * Probed ONCE at boot. The result is a frozen snapshot the rest of
 * the DesktopTool stack consults synchronously before every op:
 *
 *   const caps = await detectOSCapabilities();
 *   if (!caps.mouseInput) return { code: 'OS_UNSUPPORTED', remediation: caps.degradedReason };
 *
 * Why probe at boot, not per-op:
 *   - OS / display server doesn't change while the CLI runs
 *   - Permission grants do, but those are caught at the op boundary
 *     when nut.js itself fails (handled in OSController)
 *   - Probing once means the rest of the code can branch on a
 *     plain object without async overhead
 *
 * No native deps — pure Node stdlib + child_process for tiny probes.
 * The OS-specific permission checks use `osascript` (macOS) and
 * `wmctrl --version` (Linux X11 probe). Failures fall through to
 * permissive defaults rather than blocking startup.
 *
 * @typedef {Object} OSCapabilities
 * @property {'win32'|'darwin'|'linux'|'unknown'} os
 * @property {'x11'|'wayland'|null} display          - null on non-Linux
 * @property {boolean} screenshot
 * @property {boolean} mouseInput
 * @property {boolean} keyboardInput
 * @property {boolean} windowEnumeration
 * @property {boolean} windowFocus
 * @property {boolean} dpiAware
 * @property {Object} permissions
 * @property {boolean} permissions.requiresAccessibility       - macOS only
 * @property {boolean} permissions.requiresScreenRecording     - macOS only
 * @property {boolean} permissions.portalsOnly                 - Linux Wayland
 * @property {boolean|null} permissions.accessibilityGranted   - null = unknown
 * @property {boolean|null} permissions.screenRecordingGranted - null = unknown
 * @property {string|null} degradedReason   - operator-friendly explanation
 * @property {number} probedAt              - epoch ms
 */

import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';

const exec = promisify(execCb);

/** Identifiers we standardise on across the codebase. */
export const PlatformId = Object.freeze({
  WIN:   'win32',
  MAC:   'darwin',
  LINUX: 'linux',
  UNKNOWN: 'unknown',
});

export const DisplayServer = Object.freeze({
  X11:     'x11',
  WAYLAND: 'wayland',
});

/** Tiny exec timeout for permission probes — fail fast. */
const PROBE_TIMEOUT_MS = 5_000;

/**
 * Build and return a capability snapshot for the current process.
 *
 * Cached after first call; pass `{ refresh: true }` to re-probe.
 *
 * @param {Object} [opts]
 * @param {boolean} [opts.refresh=false]
 * @param {Object}  [opts.env=process.env]      - injectable for tests
 * @param {string}  [opts.platform=process.platform] - injectable for tests
 * @returns {Promise<OSCapabilities>}
 */
let cached = null;
export async function detectOSCapabilities(opts = {}) {
  if (cached && !opts.refresh) return cached;

  const env = opts.env || process.env;
  const platform = opts.platform || process.platform;
  const os = mapPlatform(platform);
  const display = os === PlatformId.LINUX ? detectDisplayServer(env) : null;

  // Permissive defaults — every OS is fully-capable until proven otherwise.
  const caps = {
    os,
    display,
    screenshot:        true,
    mouseInput:        true,
    keyboardInput:     true,
    windowEnumeration: true,
    windowFocus:       true,
    dpiAware:          true,
    permissions: {
      requiresAccessibility:    os === PlatformId.MAC,
      requiresScreenRecording:  os === PlatformId.MAC,
      portalsOnly:              display === DisplayServer.WAYLAND,
      accessibilityGranted:     null,
      screenRecordingGranted:   null,
    },
    degradedReason: null,
    probedAt: Date.now(),
  };

  // OS-specific overrides — these MUTATE caps in place because each
  // probe is independent and writes only its own fields.
  if (os === PlatformId.LINUX && display === DisplayServer.WAYLAND) {
    applyWaylandDegradation(caps);
  }
  if (os === PlatformId.MAC) {
    await applyMacOSPermissionProbes(caps);
  }

  cached = Object.freeze(deepFreeze(caps));
  return cached;
}

/** Reset cache (tests only). */
export function _resetCacheForTests() {
  cached = null;
}

// ─── internal helpers ───────────────────────────────────────────────

function mapPlatform(p) {
  if (p === 'win32')  return PlatformId.WIN;
  if (p === 'darwin') return PlatformId.MAC;
  if (p === 'linux')  return PlatformId.LINUX;
  return PlatformId.UNKNOWN;
}

function detectDisplayServer(env) {
  // XDG_SESSION_TYPE is the standard freedesktop signal. Wayland
  // sessions set it to 'wayland'. Anything else (or unset) → assume X11.
  const xdg = (env.XDG_SESSION_TYPE || '').toLowerCase();
  if (xdg === 'wayland') return DisplayServer.WAYLAND;
  return DisplayServer.X11;
}

/**
 * Wayland blocks input injection by design (the security model
 * forbids one app from synthesising events into another). We can
 * still capture screen via xdg-desktop-portal but mouse/keyboard
 * are off-limits.
 */
function applyWaylandDegradation(caps) {
  caps.mouseInput    = false;
  caps.keyboardInput = false;
  caps.windowFocus   = false;
  caps.degradedReason =
    'Wayland blocks input injection by design. DesktopTool runs in ' +
    'read-only mode (screenshot + window listing only). Switch to an ' +
    'X11 session, or install a compositor portal that grants input ' +
    'capabilities, to enable full control.';
}

/**
 * macOS requires the host app to be explicitly granted two
 * permissions:
 *   - Accessibility  (for input injection)
 *   - Screen Recording (for screenshots that include other apps)
 *
 * Probing them programmatically without native bindings is fragile;
 * we use the standard `osascript` route which returns a deterministic
 * error if not granted. If the probe itself fails (osascript missing
 * — extremely rare on real macOS) we leave the booleans as null and
 * let runtime errors surface at op time.
 */
async function applyMacOSPermissionProbes(caps) {
  caps.permissions.accessibilityGranted   = await probeMacOSAccessibility();
  caps.permissions.screenRecordingGranted = await probeMacOSScreenRecording();

  if (caps.permissions.accessibilityGranted === false) {
    caps.mouseInput    = false;
    caps.keyboardInput = false;
    caps.degradedReason =
      'macOS Accessibility permission is not granted. Open System ' +
      'Settings > Privacy & Security > Accessibility and enable Loxia.';
  }
  if (caps.permissions.screenRecordingGranted === false) {
    caps.screenshot = false;
    caps.degradedReason = (caps.degradedReason ? caps.degradedReason + ' ' : '') +
      'Screen Recording permission is also required for full-screen ' +
      'capture (System Settings > Privacy & Security > Screen Recording).';
  }
}

/**
 * Returns true if granted, false if denied, null if undetermined.
 * The trick: trigger an AppleScript that probes UI Element control
 * via System Events. If denied, the script errors with code -1719
 * or "not authorized to send Apple events".
 */
async function probeMacOSAccessibility() {
  // Non-intrusive probe: just count the menus of the frontmost app.
  // Doesn't change anything; only requires the same entitlement as
  // any UI control would.
  const script =
    'tell application "System Events" to count of UI elements of (first process whose frontmost is true)';
  try {
    await exec(`osascript -e ${shellQuote(script)}`, { timeout: PROBE_TIMEOUT_MS });
    return true;
  } catch (err) {
    const msg = (err.stderr || err.message || '').toLowerCase();
    if (msg.includes('not authorized') || msg.includes('1719')) return false;
    // Unknown failure — be conservative and report unknown.
    return null;
  }
}

/**
 * Screen Recording is harder to probe without invoking a capture.
 * We use a lightweight `screencapture -x -t png -` to stdout and
 * check if any bytes came back. If permission is denied, macOS
 * silently writes a black image — we can't distinguish from a
 * legitimate black screen. Best-effort only; the runtime check at
 * actual screenshot time is authoritative.
 */
async function probeMacOSScreenRecording() {
  try {
    const { stdout, stderr } = await exec(
      'screencapture -x -t png -C - </dev/null',
      { timeout: PROBE_TIMEOUT_MS, encoding: 'buffer', maxBuffer: 10 * 1024 * 1024 }
    );
    if (stdout && stdout.length > 100) return true;
    if (stderr && stderr.toString().toLowerCase().includes('permission')) return false;
    return null;
  } catch (err) {
    const msg = (err.stderr?.toString?.() || err.message || '').toLowerCase();
    if (msg.includes('permission') || msg.includes('not authorized')) return false;
    return null;
  }
}

/**
 * Single-quote a string for use in a shell command. Handles the
 * embedded-single-quote case by closing → escaping → reopening.
 */
function shellQuote(s) {
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
}

/** Freeze every level of a nested object. */
function deepFreeze(obj) {
  Object.values(obj).forEach(v => {
    if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v);
  });
  return Object.freeze(obj);
}
