// Main package file for the redstart process manager library.
// Contains data types, config I/O, and process state management.

///|
/// Error type for all redstart operations.
pub suberror RedstartError {
  RedstartError(String)
}

///|
pub impl Show for RedstartError with output(self, logger) {
  match self {
    RedstartError(msg) => logger.write_string(msg)
  }
}

///|
/// A single managed process entry, combining its definition and runtime state.
/// Note: `proc_name` is used internally (MoonBit reserves `alias`).
pub struct ProcessEntry {
  id : String
  proc_name : String // displayed as "alias" to users
  command : String
  cwd : String?
  env : Map[String, String]
  created_at : String
  mut status : String // "running" | "stopped" | "failed" | "unknown"
  mut pid : Int?
  mut started_at : String?
  mut stopped_at : String?
  mut exit_code : Int?
  mut retry_count : Int
} derive(ToJson, FromJson, Debug)

///|
pub struct ProcessGroupMember {
  pid : Int
  ppid : Int
  pgid : Int
  command : String
} derive(ToJson, Debug)

///|
pub struct ProcessInspectSnapshot {
  process : ProcessEntry
  process_group_members : Array[ProcessGroupMember]
  child_processes : Array[ProcessGroupMember]
} derive(ToJson, Debug)

///|
pub fn new_process_group_member(
  pid : Int,
  ppid : Int,
  pgid : Int,
  command : String,
) -> ProcessGroupMember {
  { pid, ppid, pgid, command }
}

///|
pub impl Show for ProcessEntry with output(self, logger) {
  let pid_str = match self.pid {
    None => "null"
    Some(n) => n.to_string()
  }
  logger.write_string(
    "ProcessEntry { name: \{self.proc_name}, status: \{self.status}, pid: \{pid_str} }",
  )
}

///|
/// Update entry to reflect a successful start.
pub fn ProcessEntry::mark_running(
  self : ProcessEntry,
  pid : Int,
  ts : String,
) -> Unit {
  self.status = "running"
  self.pid = Some(pid)
  self.started_at = Some(ts)
  self.stopped_at = None
  self.exit_code = None
  self.retry_count = 0
}

///|
/// Update entry to reflect a clean stop.
pub fn ProcessEntry::mark_stopped(
  self : ProcessEntry,
  ts : String,
  code : Int?,
) -> Unit {
  self.status = "stopped"
  self.pid = None
  self.stopped_at = Some(ts)
  self.exit_code = code
}

///|
/// Update entry to reflect a failed start.
pub fn ProcessEntry::mark_failed(self : ProcessEntry) -> Unit {
  self.status = "failed"
  self.pid = None
  self.retry_count = self.retry_count + 1
}

///|
/// Update entry status to unknown (lost track of process).
pub fn ProcessEntry::mark_unknown(self : ProcessEntry) -> Unit {
  self.status = "unknown"
  self.pid = None
}

///|
/// Root configuration / state file structure.
pub struct Config {
  version : String
  mut processes : Array[ProcessEntry]
} derive(ToJson, FromJson, Debug)

///|
pub impl Show for Config with output(self, logger) {
  logger.write_string(
    "Config { version: \{self.version}, processes: \{self.processes.length()} }",
  )
}

///|
/// Add a process entry to the config.
pub fn Config::add_process(self : Config, entry : ProcessEntry) -> Unit {
  self.processes.push(entry)
}

///|
/// Remove a process entry by name or id.
pub fn Config::remove_process(self : Config, target : String) -> Bool {
  let before = self.processes.length()
  self.processes = self.processes.filter(fn(p) {
    p.proc_name != target && p.id != target
  })
  self.processes.length() < before
}

///|
pub let config_file : String = "redstart.json5"

///|
pub let monitor_dir : String = ".redstart-monitor"

///|
/// Load config from redstart.json5 in current directory.
pub fn load_config() -> Config raise RedstartError {
  guard @fs.path_exists(config_file) else {
    raise RedstartError("redstart.json5 not found, run 'redstart init' first")
  }
  let content = @fs.read_file_to_string(config_file) catch {
    e => raise RedstartError("failed to read config: \{@debug.to_repr(e)}")
  }
  let json = @json5.parse_json(content) catch {
    e => raise RedstartError("failed to parse config: \{@debug.to_repr(e)}")
  }
  @json.from_json(json) catch {
    e => raise RedstartError("failed to decode config: \{@debug.to_repr(e)}")
  }
}

///|
/// Save config to redstart.json5 in pretty-printed form.
pub fn save_config(config : Config) -> Unit raise RedstartError {
  let json = config.to_json()
  let content = json_to_pretty_string(json)
  @fs.write_string_to_file(config_file, content) catch {
    e => raise RedstartError("failed to write config: \{@debug.to_repr(e)}")
  }
}

///|
/// Find a process entry by proc_name (alias) or id.
pub fn find_process(config : Config, target : String) -> ProcessEntry? {
  for p in config.processes {
    if p.proc_name == target || p.id == target {
      return Some(p)
    }
  }
  None
}

///|
/// Generate a unique 8-character ID from a timestamp mixed with a seed string.
/// Mixing in the seed ensures IDs differ even when called multiple times per second.
pub fn generate_id(now_ts : Int64, seed : String) -> String {
  // Hash the seed into an Int64 using djb2 style
  let mut h = 5381L
  for c in seed {
    h = h * 33L + c.to_int().to_int64()
  }
  let buf = StringBuilder::new()
  let mut n = now_ts ^ (h * 0x9e3779b97f4a7c15L)
  for _ in 0..<8 {
    let idx = (n % 36L).to_int().abs() % 36
    let char_code = if idx < 26 { 97 + idx } else { 48 + idx - 26 }
    buf.write_char(char_code.unsafe_to_char())
    n = n / 36L + n * 31L
  }
  buf.to_string()
}

///|
/// Serialize Json to compact JSON5 string (for machine-readable / inspect output).
pub fn json_to_string(json : Json) -> String {
  @json5.stringify_json(json)
}

///|
/// Serialize Json to a pretty-printed JSON5 string with 2-space indentation.
/// Object keys that are valid JSON5 identifiers are written unquoted.
pub fn json_to_pretty_string(json : Json) -> String {
  @json5.stringify_json_pretty(json)
}

///|
/// Format an optional PID for display.
pub fn format_pid(pid : Int?) -> String {
  match pid {
    None => "-"
    Some(n) => n.to_string()
  }
}

///|
pub fn monitor_path(proc : String) -> String {
  "\{monitor_dir}/\{proc}"
}

///|
pub fn stdout_log_path(proc : String) -> String {
  "\{monitor_dir}/\{proc}/stdout.log"
}

///|
pub fn stderr_log_path(proc : String) -> String {
  "\{monitor_dir}/\{proc}/stderr.log"
}

///|
pub fn pid_file_path(proc : String) -> String {
  "\{monitor_dir}/\{proc}/pid"
}

///|
pub fn process_group_snapshot_path(proc : String) -> String {
  "\{monitor_dir}/\{proc}/process-group.tsv"
}

///|
/// Ensure the monitor directory for a process exists.
pub fn ensure_monitor_dir(proc : String) -> Unit raise RedstartError {
  let dir = monitor_path(proc)
  if !@fs.path_exists(dir) {
    @fs.create_dir(dir) catch {
      e =>
        raise RedstartError(
          "failed to create monitor dir: \{@debug.to_repr(e)}",
        )
    }
  }
}

///|
/// Ensure the root .redstart-monitor directory exists.
pub fn ensure_root_monitor_dir() -> Unit raise RedstartError {
  if !@fs.path_exists(monitor_dir) {
    @fs.create_dir(monitor_dir) catch {
      e =>
        raise RedstartError(
          "failed to create .redstart-monitor: \{@debug.to_repr(e)}",
        )
    }
  }
}

///|
/// Create an empty Config.
pub fn new_config() -> Config {
  { version: "1", processes: [] }
}

///|
/// Create a new ProcessEntry in "stopped" state.
pub fn new_process_entry(
  id : String,
  proc_name : String,
  command : String,
  cwd : String?,
  env : Map[String, String],
  created_at : String,
) -> ProcessEntry {
  {
    id,
    proc_name,
    command,
    cwd,
    env,
    created_at,
    status: "stopped",
    pid: None,
    started_at: None,
    stopped_at: None,
    exit_code: None,
    retry_count: 0,
  }
}

///|
/// Check if the config file exists in the current directory.
pub fn config_file_exists() -> Bool {
  @fs.path_exists(config_file)
}

///|
/// Read the last N lines of a file.
pub fn read_last_lines(
  path : String,
  n : Int,
) -> Array[String] raise RedstartError {
  guard @fs.path_exists(path) else { return [] }
  let content = @fs.read_file_to_string(path) catch {
    e => raise RedstartError("failed to read \{path}: \{@debug.to_repr(e)}")
  }
  let all_lines = content.split("\n").map(StringView::to_owned).collect()
  // Remove trailing empty line if present
  let end = if !all_lines.is_empty() && all_lines.last().unwrap() == "" {
    all_lines.length() - 1
  } else {
    all_lines.length()
  }
  let trimmed = all_lines[0:end]
  if trimmed.length() <= n {
    trimmed.iter().collect()
  } else {
    trimmed[trimmed.length() - n:trimmed.length()].iter().collect()
  }
}