///|
/// Log rotation policy and the pure planning logic behind it.
///
/// This is the pm3 re-implementation of `pm2-logrotate`. The original module
/// works by *copying* a log file to a dated name and then truncating the
/// original. That has three well-known problems:
///
/// 1. **Data loss** — anything appended between the copy and the truncate is
///    discarded.
/// 2. **O(size) work** — a multi-gigabyte log has to be read and written in
///    full before it can be truncated, stalling the worker.
/// 3. **Sparse holes** — the writer keeps its file offset after truncation, so
///    the next writes land at the old offset, leaving a NUL-filled prefix.
///
/// pm3 avoids all three: the daemon owns every managed log file descriptor and
/// rotates by *renaming* the current file and reopening a fresh one. That is
/// an O(1) metadata operation, is atomic on POSIX, and loses nothing because
/// the writer immediately switches to the new descriptor. The functions here
/// describe that policy; the daemon performs the rename/reopen.

///|
/// Rotation policy. Persisted as JSON so `pm3 set logrotate:...` survives
/// daemon restarts.
pub struct LogrotateConfig {
  /// Rotate when a log reaches this many bytes. `0` disables size rotation.
  mut max_size : Int64
  /// Keep at most this many rotated files per log. `0` keeps only the current.
  mut retain : Int
  /// Gzip rotated files in the background.
  mut compress : Bool
  /// Gzip level, 1 (fast) to 9 (small). Defaults to 9, the fixed
  /// `Z_BEST_COMPRESSION` level `pm2-logrotate` uses.
  mut compression_level : Int
  /// `moment`-style token string used in rotated file names.
  mut date_format : String
  /// How often, in seconds, the daemon checks log sizes. Minimum 1.
  mut worker_interval : Int
  /// Cron expression that forces a rotation.
  mut rotate_interval : String
  /// Optional fixed UTC offset for file names, e.g. `UTC+8`. Empty = system.
  mut tz : String
  /// Rotate logs of pm3-managed modules as well.
  mut rotate_module : Bool
  /// Never rotate a file smaller than this. Guards against rotating empty logs.
  mut min_size : Int64
  /// Delete rotated files older than this many days. `0` disables the age cap.
  mut max_age_days : Int
} derive(Eq, Debug, ToJson, FromJson)

///|
/// Default policy, matching the familiar `pm2-logrotate` defaults.
pub fn default_logrotate_config() -> LogrotateConfig {
  {
    max_size: 10L * 1024L * 1024L,
    retain: 30,
    compress: false,
    compression_level: 9,
    date_format: "YYYY-MM-DD_HH-mm-ss",
    worker_interval: 30,
    rotate_interval: "0 0 * * *",
    tz: "",
    rotate_module: true,
    min_size: 0L,
    max_age_days: 0,
  }
}

///|
/// Parse a `true`/`false` value, also accepting `1`/`0` and `yes`/`no`.
pub fn parse_bool_value(text : StringView) -> Bool? {
  match text.trim().to_lower().to_owned() {
    "true" | "1" | "yes" | "on" => Some(true)
    "false" | "0" | "no" | "off" => Some(false)
    _ => None
  }
}

///|
/// A rotated log file: its name and modification time.
///
/// The modification time is in nanoseconds since the epoch. Sub-second
/// precision matters: a busy log can rotate several times inside one second,
/// and retention has to tell those files apart to keep the true newest ones.
pub(all) struct RotatedFile {
  name : String
  mtime : Int64
} derive(Eq, Debug)

///|
/// Compute the path a rotated log should be renamed to.
///
/// `log_path` is the live log (e.g. `.../logs/api-out.log`); the result is
/// `.../logs/api-out__.log` (plus `.gz` when compression is enabled).
pub fn rotated_path(
  log_path : String,
  stamp : String,
  compress : Bool,
) -> String {
  let dir = path_dirname(log_path)
  let base = strip_suffix_owned(path_basename(log_path), ".log")
  let ext = if compress { ".log.gz" } else { ".log" }
  path_join(dir, "\{base}__\{stamp}\{ext}")
}

///|
/// If `file_name` is a rotated sibling of `log_path`, return the timestamp
/// suffix that was embedded in its name.
///
/// This replaces the original's `indexOf(base)` test, which also matched
/// unrelated files such as `api-out__backup.log` or a differently named log
/// that merely contained the base name.
pub fn parse_rotated_stamp(log_path : String, file_name : String) -> String? {
  let base = strip_suffix_owned(path_basename(log_path), ".log")
  let prefix = "\{base}__"
  let view = file_name.view()
  guard view.has_prefix(prefix) else { return None }
  let rest = view.view(start_offset=prefix.length(), end_offset=view.length())
  let body = match rest.strip_suffix(".gz") {
    Some(v) => v
    None => rest
  }
  guard body.has_suffix(".log") else { return None }
  Some(body.view(start_offset=0, end_offset=body.length() - 4).to_owned())
}

///|
/// Whether a log of `size` bytes should be rotated under `config`.
pub fn rotation_due(size : Int64, config : LogrotateConfig) -> Bool {
  if size <= 0L {
    return false
  }
  if config.min_size > 0L && size < config.min_size {
    return false
  }
  config.max_size > 0L && size >= config.max_size
}

///|
/// Choose which rotated files to delete.
///
/// Files are ordered newest-first by modification time (nanoseconds since the
/// epoch); everything after the first `retain` is deleted. When
/// `max_age_days > 0`, files older than that are deleted as well, regardless
/// of position.
pub fn select_for_deletion(
  files : Array[RotatedFile],
  retain : Int,
  max_age_days : Int,
  now : Int64,
) -> Array[String] {
  let sorted = files.copy()
  sorted.sort_by(fn(a, b) { compare_int64(b.mtime, a.mtime) })
  let doomed : Array[String] = []
  for i in 0.. 0 &&
      now - file.mtime > max_age_days.to_int64() * 86400L * 1_000_000_000L
    if i >= retain || too_old {
      doomed.push(file.name)
    }
  }
  doomed
}

///|
/// Three-way comparison for `Int64` values.
fn compare_int64(a : Int64, b : Int64) -> Int {
  if a < b {
    -1
  } else if a > b {
    1
  } else {
    0
  }
}

///|
/// Update one field of the policy from a string, as used by
/// `pm3 set logrotate: `.
///
/// Keys use pm3's snake_case spelling; the camelCase names `pm2-logrotate`
/// documents (`dateFormat`, `workerInterval`, `rotateInterval`,
/// `rotateModule`, `TZ`) are accepted as aliases.
pub fn LogrotateConfig::set_field(
  self : LogrotateConfig,
  key : String,
  value : String,
) -> Result[Unit, String] {
  match key {
    "max_size" =>
      match parse_size(value) {
        Some(v) => {
          self.max_size = v
          Ok(())
        }
        None => Err("max_size must be a size such as 10M, 1G or 512K")
      }
    "min_size" =>
      match parse_size(value) {
        Some(v) => {
          self.min_size = v
          Ok(())
        }
        None => Err("min_size must be a size such as 1K")
      }
    "retain" =>
      match parse_non_negative_int(value) {
        Some(v) => {
          self.retain = v
          Ok(())
        }
        None => Err("retain must be a non-negative integer")
      }
    "max_age_days" =>
      match parse_non_negative_int(value) {
        Some(v) => {
          self.max_age_days = v
          Ok(())
        }
        None => Err("max_age_days must be a non-negative integer")
      }
    "compress" =>
      match parse_bool_value(value) {
        Some(v) => {
          self.compress = v
          Ok(())
        }
        None => Err("compress must be true or false")
      }
    "compression_level" =>
      match parse_non_negative_int(value) {
        Some(v) if v >= 1 && v <= 9 => {
          self.compression_level = v
          Ok(())
        }
        _ => Err("compression_level must be between 1 and 9")
      }
    "date_format" | "dateFormat" =>
      if value.length() == 0 {
        Err("date_format must not be empty")
      } else {
        self.date_format = value
        Ok(())
      }
    "worker_interval" | "workerInterval" =>
      match parse_non_negative_int(value) {
        Some(v) if v >= 1 => {
          self.worker_interval = v
          Ok(())
        }
        _ => Err("worker_interval must be at least 1 second")
      }
    "rotate_interval" | "rotateInterval" =>
      match parse_cron(value) {
        Some(_) => {
          self.rotate_interval = value
          Ok(())
        }
        None => Err("rotate_interval must be a valid cron expression")
      }
    "tz" | "TZ" => {
      self.tz = value
      Ok(())
    }
    "rotate_module" | "rotateModule" =>
      match parse_bool_value(value) {
        Some(v) => {
          self.rotate_module = v
          Ok(())
        }
        None => Err("rotate_module must be true or false")
      }
    _ => Err("unknown logrotate option '\{key}'")
  }
}

///|
/// Parse a base-10 non-negative integer.
pub fn parse_non_negative_int(text : StringView) -> Int? {
  let t = text.trim()
  if t.length() == 0 {
    return None
  }
  for i in 0.. '9' {
      return None
    }
  }
  Some(@string.parse_int(t)) catch {
    _ => None
  }
}

///|
/// Render the policy as aligned `key = value` lines for `pm3 logrotate`.
pub fn LogrotateConfig::to_display(self : LogrotateConfig) -> String {
  let buf = StringBuilder()
  buf.write_string("max_size          = \{format_size(self.max_size)}\n")
  buf.write_string("min_size          = \{format_size(self.min_size)}\n")
  buf.write_string("retain            = \{self.retain}\n")
  buf.write_string("max_age_days      = \{self.max_age_days}\n")
  buf.write_string("compress          = \{self.compress}\n")
  buf.write_string("compression_level = \{self.compression_level}\n")
  buf.write_string("date_format       = \{self.date_format}\n")
  buf.write_string("worker_interval   = \{self.worker_interval}s\n")
  buf.write_string("rotate_interval   = \{self.rotate_interval}\n")
  let tz_display = if self.tz.length() == 0 { "" } else { self.tz }
  buf.write_string("tz                = \{tz_display}\n")
  buf.write_string("rotate_module     = \{self.rotate_module}\n")
  buf.to_string()
}