// System-level actions Studio invokes by spawning the `fai`
// binary or asking the OS to open a file. Used by Doctor's
// daemon-control + open-paths affordances so Windows operators
// who never touch a shell can restart the daemon, apply an
// update, or open a log file from inside the GUI.
//
// Cross-platform notes:
// - macOS uses `open` for files + URLs.
// - Linux uses `xdg-open`.
// - Windows uses `explorer` (file manager) and a quoted CLI
//   call for processes.
//
// The `fai` binary is resolved from PATH first; falls back to
// `~/.chain/bin/chain` (Unix) or `%USERPROFILE%\.chain\bin\chain.exe`
// (Windows). The PowerShell installer puts both in PATH but
// some operators don't restart their shell after install — the
// fallback covers that case.

import 'dart:io';

import 'package:shared_preferences/shared_preferences.dart';

/// Sentinel returned in `_runFai(...).stderr` when no `fai`
/// binary could be located. UI layers should not display this
/// verbatim — they check [SystemActions.chainBinaryExists] first
/// and render a localized, actionable message instead.
const String kFaiBinaryNotFound = 'fai-binary-not-found';

/// Getting-started / install guide for the Ch∆In platform. Opened
/// in the OS browser as the fallback affordance when no `fai`
/// binary can be located on the machine.
const String kFaiInstallDocsUrl =
    'https://git.flemming.ai/fai/chain#installation';

class SystemActions {
  SystemActions._();

  /// SharedPreferences key for an operator-chosen `fai` binary
  /// path (set via the file picker when auto-detection fails).
  static const String _kFaiBinaryPrefKey = 'system.chain_binary_path';

  /// In-memory cache of the operator override, loaded once at
  /// startup by [loadFaiBinaryOverride]. Null = no override.
  static String? _faiBinaryOverride;

  /// Restore the persisted `fai` binary override (if any) so it
  /// is available before the first daemon action. Call once at
  /// app start, alongside the other persisted-state loaders.
  static Future<void> loadFaiBinaryOverride() async {
    final prefs = await SharedPreferences.getInstance();
    final p = prefs.getString(_kFaiBinaryPrefKey);
    if (p != null && p.isNotEmpty && File(p).existsSync()) {
      _faiBinaryOverride = p;
    }
  }

  /// Persist an operator-chosen `fai` binary path. Used by the
  /// "Locate `fai` binary…" affordance when auto-detection on
  /// PATH and the canonical install dir both come up empty.
  /// Returns false (and persists nothing) when [path] is empty
  /// or does not point at an existing file.
  static Future<bool> setFaiBinaryPath(String path) async {
    if (path.isEmpty || !File(path).existsSync()) return false;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_kFaiBinaryPrefKey, path);
    _faiBinaryOverride = path;
    return true;
  }

  /// True when a `fai` binary can be located (override, PATH, or
  /// the canonical install dir). UI uses this to decide between
  /// the normal daemon-control affordances and the "locate the
  /// binary / read the install guide" recovery path.
  static bool chainBinaryExists() => _faiExecutable() != null;

  /// Ask the OS to open [path] in the default handler. On macOS
  /// this opens text files in TextEdit, configs in the registered
  /// editor, etc. Returns true on a clean spawn (process exited
  /// non-zero is still surfaced as false).
  static Future<({bool ok, String stderr})> openInOs(String path) async {
    if (path.isEmpty) {
      return (ok: false, stderr: 'empty path');
    }
    final cmd = _openCommand();
    try {
      final r = await Process.run(cmd.executable, [...cmd.args, path]);
      if (r.exitCode == 0) return (ok: true, stderr: '');
      return (ok: false, stderr: r.stderr.toString().trim());
    } catch (e) {
      return (ok: false, stderr: e.toString());
    }
  }

  /// Reveal [path] in the platform file manager. Works for binary
  /// files (the SQLite audit DB, the PID file) where `openInOs`
  /// would fall flat — macOS refuses to "open" a file with no
  /// registered app. macOS uses `open -R`; Windows
  /// `explorer /select,`; Linux falls back to opening the parent
  /// directory because there is no portable reveal-and-highlight.
  static Future<({bool ok, String stderr})> revealInOs(String path) async {
    if (path.isEmpty) {
      return (ok: false, stderr: 'empty path');
    }
    try {
      final ProcessResult r;
      if (Platform.isMacOS) {
        r = await Process.run('open', ['-R', path]);
      } else if (Platform.isWindows) {
        // /select, requires no space after the comma; explorer
        // returns 1 even on success, so don't gate on exitCode.
        r = await Process.run('explorer', ['/select,$path']);
        return (ok: true, stderr: '');
      } else {
        // Linux: open the containing directory in the default
        // file manager. Best portable approximation.
        final parent = File(path).parent.path;
        r = await Process.run('xdg-open', [parent]);
      }
      if (r.exitCode == 0) return (ok: true, stderr: '');
      return (ok: false, stderr: r.stderr.toString().trim());
    } catch (e) {
      return (ok: false, stderr: e.toString());
    }
  }

  /// Convenience: reveal binary files, open directories /
  /// text-shaped paths normally. Doctor's path rows use this so
  /// the operator never has to know which strategy fits which
  /// kind of file.
  static Future<({bool ok, String stderr})> openOrReveal(
    String path, {
    required bool isDirectory,
  }) async {
    if (isDirectory) return openInOs(path);
    return revealInOs(path);
  }

  /// Run a `chain daemon ...` subcommand and surface the captured
  /// output. Used by Doctor's "Restart daemon" button.
  static Future<({bool ok, String stdout, String stderr})> chainDaemon(
    List<String> args,
  ) async {
    return _runFai(['daemon', ...args]);
  }

  /// Run `chain update apply --channel <c>`. Long-running on a slow
  /// network — caller should show a spinner.
  static Future<({bool ok, String stdout, String stderr})> chainUpdateApply(
    String channel,
  ) async {
    return _runFai(['update', 'apply', '--channel', channel]);
  }

  /// Switch the active channel pointer at `~/.chain/current-channel`.
  /// The CLI also restarts the daemon for the new channel, so the
  /// caller does not need a follow-up restart.
  static Future<({bool ok, String stdout, String stderr})> chainChannelSwitch(
    String channel,
  ) async {
    return _runFai(['channel', 'switch', channel]);
  }

  /// Install the platform-native autostart unit for [channel]
  /// (launchd plist on macOS, systemd-user unit on Linux). On
  /// Windows the platform CLI returns a "not supported" exit
  /// status that the caller surfaces as an error message.
  static Future<({bool ok, String stdout, String stderr})> chainDaemonEnable(
    String channel,
  ) async {
    return _runFai(['daemon', 'enable', '--channel', channel]);
  }

  /// Remove the autostart unit installed by [chainDaemonEnable].
  static Future<({bool ok, String stdout, String stderr})> chainDaemonDisable(
    String channel,
  ) async {
    return _runFai(['daemon', 'disable', '--channel', channel]);
  }

  /// Run `chain reset --yes`. Wipes operator state with the same
  /// safety net as the CLI: stops every daemon (including off-
  /// channel orphans), atomically backs `~/.chain/` up, recreates
  /// it with `bin/`, `channels/`, `config.yaml`, `current-channel`,
  /// and `registry-token` preserved. The daemon restarts on the
  /// active channel before the call returns.
  ///
  /// Studio's gRPC channel goes down for ~1s while the daemon
  /// restarts — caller should follow up with a reconnect.
  static Future<({bool ok, String stdout, String stderr})> chainReset({
    bool keepModules = false,
    bool keepData = false,
  }) async {
    final args = <String>['reset', '--yes'];
    if (keepModules) args.add('--keep-modules');
    if (keepData) args.add('--keep-data');
    return _runFai(args);
  }

  static Future<({bool ok, String stdout, String stderr})> _runFai(
    List<String> args,
  ) async {
    final exe = _faiExecutable();
    if (exe == null) {
      // Sentinel, not a user-facing string. Callers detect this
      // via `chainBinaryExists()` and render a localized,
      // actionable recovery message (locate binary / install
      // guide) instead of CLI jargon like "set CHAIN_BIN".
      return (ok: false, stdout: '', stderr: kFaiBinaryNotFound);
    }
    try {
      final r = await Process.run(exe, args);
      return (
        ok: r.exitCode == 0,
        stdout: r.stdout.toString(),
        stderr: r.stderr.toString(),
      );
    } catch (e) {
      return (ok: false, stdout: '', stderr: e.toString());
    }
  }

  static ({String executable, List<String> args}) _openCommand() {
    if (Platform.isMacOS) return (executable: 'open', args: const []);
    if (Platform.isWindows) return (executable: 'explorer', args: const []);
    return (executable: 'xdg-open', args: const []);
  }

  /// Locate the `fai` binary. Order: operator override (set via
  /// the file picker), $CHAIN_BIN, PATH, fallback to the canonical
  /// install location under the user's home dir.
  static String? _faiExecutable() {
    final override = _faiBinaryOverride;
    if (override != null &&
        override.isNotEmpty &&
        File(override).existsSync()) {
      return override;
    }

    final fromEnv = Platform.environment['CHAIN_BIN'];
    if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
      return fromEnv;
    }

    final isWindows = Platform.isWindows;
    final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai');
    if (fromPath != null) return fromPath;

    final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME'];
    if (home == null || home.isEmpty) return null;
    final fallback = isWindows
        ? '$home\\.chain\\bin\\chain.exe'
        : '$home/.chain/bin/chain';
    return File(fallback).existsSync() ? fallback : null;
  }

  /// Tiny PATH walker — we cannot rely on `which`/`where` being
  /// reachable via Process.run on every platform.
  static String? _whichFai(String name) {
    final pathEnv = Platform.environment['PATH'];
    if (pathEnv == null || pathEnv.isEmpty) return null;
    final sep = Platform.isWindows ? ';' : ':';
    final slash = Platform.isWindows ? '\\' : '/';
    for (final dir in pathEnv.split(sep)) {
      if (dir.isEmpty) continue;
      final candidate = '$dir$slash$name';
      if (File(candidate).existsSync()) return candidate;
    }
    return null;
  }
}
