# reflection/nulib/logger.nu — JSONL action logger with config-driven rotation.
# Config source: .ontoref/config.ncl → log { level, path, rotation, compress, archive, max_files }
# Env override: ONTOREF_LOG_LEVEL overrides config level if set (for CI/scripts).

use ./shared.nu [project-root]
use ../modules/store.nu [daemon-export-safe]

def level-rank [level: string]: nothing -> int {
  match $level {
    "write"       => 0,
    "read"        => 1,
    "interactive" => 2,
    _             => 1,
  }
}

# Load log config from .ontoref/config.ncl, with defaults for missing/broken config.
def load-log-config [root: string]: nothing -> record {
  let defaults = {
    level: "none",
    path: ".coder/actions.jsonl",
    rotation: "none",
    compress: false,
    archive: ".coder/archive",
    max_files: 10,
  }

  let config_file = $"($root)/.ontoref/config.ncl"
  if not ($config_file | path exists) { return $defaults }

  let cfg = (daemon-export-safe $config_file)
  if $cfg == null { return $defaults }
  let log = ($cfg.log? | default {})
  {
    level:     ($log.level?     | default $defaults.level),
    path:      ($log.path?      | default $defaults.path),
    rotation:  ($log.rotation?  | default $defaults.rotation),
    compress:  ($log.compress?  | default $defaults.compress),
    archive:   ($log.archive?   | default $defaults.archive),
    max_files: ($log.max_files? | default $defaults.max_files),
  }
}

# Determine rotation suffix from current date and policy.
def rotation-suffix [policy: string, ts: datetime]: nothing -> string {
  match $policy {
    "daily"   => ($ts | format date "%Y-%m-%d"),
    "weekly"  => ($ts | format date "%Y-W%V"),
    "monthly" => ($ts | format date "%Y-%m"),
    _         => "",
  }
}

# Check if the current log file needs rotation. Returns true if rotation boundary crossed.
def needs-rotation [log_file: string, policy: string]: nothing -> bool {
  if $policy == "none" { return false }
  if not ($log_file | path exists) { return false }

  let stat = ls -l $log_file | first
  let file_modified = $stat.modified
  let now = (date now)

  let file_suffix = (rotation-suffix $policy $file_modified)
  let current_suffix = (rotation-suffix $policy $now)

  $file_suffix != $current_suffix
}

# Rotate: move current log to archive with date suffix, optionally compress, prune old files.
def rotate-log [root: string, cfg: record] {
  let log_file = $"($root)/($cfg.path)"
  if not ($log_file | path exists) { return }

  let archive_dir = $"($root)/($cfg.archive)"
  if not ($archive_dir | path exists) { mkdir $archive_dir }

  let basename = ($log_file | path parse | get stem)
  let stat = ls -l $log_file | first
  let suffix = (rotation-suffix $cfg.rotation $stat.modified)
  let archive_name = $"($basename)-($suffix).jsonl"
  let archive_path = $"($archive_dir)/($archive_name)"

  mv $log_file $archive_path

  if $cfg.compress {
    let gz_result = do { ^gzip $archive_path } | complete
    if $gz_result.exit_code != 0 {
      let err_msg = ($gz_result.stderr | str trim)
      print -e $"  warn: gzip failed for ($archive_path): ($err_msg)"
    }
  }

  # Prune: keep only max_files most recent archives
  let pattern = $"($archive_dir)/($basename)-*"
  let archives = (glob $pattern | sort -r)
  if ($archives | length) > $cfg.max_files {
    let to_remove = ($archives | skip $cfg.max_files)
    for f in $to_remove { rm $f }
  }
}

# ── Query commands (exported for dispatcher) ────────────────────────────────

# Show log config: path, resolved file, settings.
export def log-show-config [] {
  let root = (project-root)
  let cfg = (load-log-config $root)
  let log_file = $"($root)/($cfg.path)"
  let archive_dir = $"($root)/($cfg.archive)"
  let file_exists = ($log_file | path exists)
  let file_size = if $file_exists { (ls $log_file | first | get size) } else { 0 }
  let line_count = if $file_exists { (open $log_file --raw | lines | where { $in | is-not-empty } | length) } else { 0 }
  let archive_count = if ($archive_dir | path exists) {
    let basename = ($log_file | path parse | get stem)
    glob $"($archive_dir)/($basename)-*" | length
  } else { 0 }

  print ""
  print $"  (ansi white_bold)Log config(ansi reset)  (ansi dark_gray)\(.ontoref/config.ncl → log\)(ansi reset)"
  print ""
  print $"  (ansi cyan)level(ansi reset)      ($cfg.level)"
  print $"  (ansi cyan)path(ansi reset)       ($cfg.path)"
  print $"  (ansi cyan)rotation(ansi reset)   ($cfg.rotation)"
  print $"  (ansi cyan)compress(ansi reset)   ($cfg.compress)"
  print $"  (ansi cyan)archive(ansi reset)    ($cfg.archive)"
  print $"  (ansi cyan)max_files(ansi reset)  ($cfg.max_files)"
  print ""
  print $"  (ansi white_bold)Levels(ansi reset) (ansi dark_gray)\(cumulative — each includes all below\)(ansi reset)"
  print ""
  let levels = [
    ["level" "logs" "rank"];
    ["none"  "nothing" "—"]
    ["write" "mutations only (add, done, apply, accept...)" "0"]
    ["read"  "mutations + queries (list, show, status, describe...)" "0+1"]
    ["all"   "mutations + queries + interactive (help, selectors)" "0+1+2"]
  ]
  let active_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
  for row in $levels {
    let marker = if $row.level == $active_level { $"(ansi green_bold)●(ansi reset)" } else { $"(ansi dark_gray)○(ansi reset)" }
    print $"  ($marker)  (ansi cyan)($row.level | fill -w 6)(ansi reset)  ($row.logs)"
  }
  if ($env.ONTOREF_LOG_LEVEL? | is-not-empty) {
    print ""
    print $"  (ansi dark_gray)env override: ONTOREF_LOG_LEVEL=($env.ONTOREF_LOG_LEVEL)(ansi reset)"
  }
  print ""
  print $"  (ansi white_bold)Current file(ansi reset)"
  print $"  (ansi cyan)path(ansi reset)       ($log_file)"
  print $"  (ansi cyan)exists(ansi reset)     ($file_exists)"
  print $"  (ansi cyan)size(ansi reset)       ($file_size)"
  print $"  (ansi cyan)entries(ansi reset)    ($line_count)"
  print $"  (ansi cyan)archived(ansi reset)   ($archive_count) files"
  print ""
  print $"  (ansi white_bold)Columns(ansi reset)  ts | author | actor | level | action"
  print ""
}

# Parse JSONL log entries from the active log file.
# Guards against malformed lines: from json is an internal command and cannot
# be captured with | complete, so we pre-filter to lines that look like objects.
def load-entries [root: string, cfg: record]: nothing -> list<record> {
  let log_file = $"($root)/($cfg.path)"
  if not ($log_file | path exists) { return [] }
  open $log_file --raw
  | lines
  | where { $in | str trim | is-not-empty }
  | where { ($in | str trim | str starts-with "{") and ($in | str trim | str ends-with "}") }
  | each { |line| try { $line | str trim | from json } catch { null } }
  | where { $in != null }
}

# Filter entries by --since/--until (ISO 8601 string comparison).
def filter-time-range [entries: list<record>, since: string, until: string]: nothing -> list<record> {
  mut result = $entries
  if ($since | is-not-empty) {
    $result = ($result | where { |e| ($e.ts? | default "") >= $since })
  }
  if ($until | is-not-empty) {
    $result = ($result | where { |e| ($e.ts? | default "") <= $until })
  }
  $result
}

# Apply column:match-text query filters. Multiple queries are AND-combined.
# Each query is "column:text" — matches if the column value contains text.
def filter-query [entries: list<record>, queries: list<string>]: nothing -> list<record> {
  mut result = $entries
  for q in $queries {
    let parts = ($q | split row ":" | collect)
    if ($parts | length) < 2 { continue }
    let col = ($parts | first)
    let pattern = ($parts | skip 1 | str join ":")
    $result = ($result | where { |e|
      let val = ($e | get -o $col | default "")
      ($val | str contains $pattern)
    })
  }
  $result
}

# Format a single entry for terminal display.
def fmt-entry [entry: record, timestamps: bool]: nothing -> string {
  let level_color = match ($entry.level? | default "read") {
    "write"       => (ansi yellow),
    "read"        => (ansi cyan),
    "interactive" => (ansi magenta),
    _             => (ansi default_dimmed),
  }
  let actor_str = $"(ansi dark_gray)($entry.actor? | default '?')(ansi reset)"
  let action_str = $"($entry.action? | default '')"
  if $timestamps {
    let ts_str = $"(ansi dark_gray)($entry.ts? | default '')(ansi reset)"
    $"  ($ts_str)  ($level_color)($entry.level? | default '?')(ansi reset)  ($actor_str)  ($action_str)"
  } else {
    $"  ($level_color)($entry.level? | default '?')(ansi reset)  ($actor_str)  ($action_str)"
  }
}

# Query log entries with filters. Used by `strat log`.
export def log-query [
  --tail_n: int = -1,
  --since: string = "",
  --until: string = "",
  --latest,
  --timestamps (-t),
  --level: string = "",
  --actor: string = "",
  --query (-q): list<string> = [],
  --fmt (-f): string = "text",
] {
  let root = (project-root)
  let cfg = (load-log-config $root)
  let log_file = $"($root)/($cfg.path)"

  if not ($log_file | path exists) {
    print "  No log entries."
    return
  }

  mut entries = (load-entries $root $cfg)
  $entries = (filter-time-range $entries $since $until)

  if ($level | is-not-empty) {
    $entries = ($entries | where { |e| ($e.level? | default "") == $level })
  }
  if ($actor | is-not-empty) {
    $entries = ($entries | where { |e| ($e.actor? | default "") == $actor })
  }
  if ($query | is-not-empty) {
    $entries = (filter-query $entries $query)
  }

  if $latest {
    $entries = if ($entries | is-empty) { [] } else { [($entries | last)] }
  } else if $tail_n > 0 {
    let total = ($entries | length)
    if $total > $tail_n {
      $entries = ($entries | skip ($total - $tail_n))
    }
  }

  match $fmt {
    "json" => { print ($entries | to json) },
    "jsonl" => {
      for e in $entries { print ($e | to json -r) }
    },
    "table" => { print ($entries | table --expand) },
    _ => {
      if ($entries | is-empty) {
        print "  No log entries."
        return
      }
      for e in $entries {
        print (fmt-entry $e $timestamps)
      }
    },
  }
}

# Follow log output — polls for new entries every 2 seconds.
export def log-follow [--timestamps (-t), --query (-q): list<string> = []] {
  let root = (project-root)
  let cfg = (load-log-config $root)
  let log_file = $"($root)/($cfg.path)"
  let has_filter = ($query | is-not-empty)

  let filter_label = if $has_filter { $" (ansi cyan)filter: ($query | str join ', ')(ansi reset)" } else { "" }
  print $"  (ansi dark_gray)Following ($cfg.path)(ansi reset)($filter_label)(ansi dark_gray) — Ctrl+C to stop(ansi reset)"
  print ""

  mut last_count = if ($log_file | path exists) {
    (load-entries $root $cfg) | length
  } else { 0 }

  # Show last 10 entries as context (filtered if query given)
  if $last_count > 0 {
    mut entries = (load-entries $root $cfg)
    if $has_filter { $entries = (filter-query $entries $query) }
    let total = ($entries | length)
    let start = if $total > 10 { $total - 10 } else { 0 }
    let tail_entries = ($entries | skip $start)
    for e in $tail_entries {
      print (fmt-entry $e $timestamps)
    }
  }

  loop {
    sleep 2sec
    let current_count = if ($log_file | path exists) {
      (load-entries $root $cfg) | length
    } else { 0 }

    if $current_count < $last_count {
      # File was rotated (replaced with a shorter one) — reset position.
      $last_count = 0
    }
    if $current_count > $last_count {
      let entries = (load-entries $root $cfg)
      mut new_entries = ($entries | skip $last_count)
      if $has_filter { $new_entries = (filter-query $new_entries $query) }
      for e in $new_entries {
        print (fmt-entry $e $timestamps)
      }
      $last_count = $current_count
    }
  }
}

# ── Write ────────────────────────────────────────────────────────────────────

# Manual record — for agents, hooks, scripts, and any external actor.
# Bypasses level filtering (always writes if log is not "none").
export def log-record [
  action: string,
  --level (-l): string = "write",
  --author (-a): string = "",
  --actor: string = "",
] {
  let root = (project-root)
  let cfg = (load-log-config $root)

  let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
  if $effective_level == "none" { return }

  let log_file = $"($root)/($cfg.path)"
  let dir = ($log_file | path dirname)
  if not ($dir | path exists) { mkdir $dir }

  if (needs-rotation $log_file $cfg.rotation) {
    rotate-log $root $cfg
  }

  let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z")
  let resolved_author = if ($author | is-not-empty) { $author } else { ($env.ONTOREF_AUTHOR? | default "unknown") }
  let resolved_actor = if ($actor | is-not-empty) { $actor } else { ($env.ONTOREF_ACTOR? | default "developer") }
  let entry = { ts: $ts, author: $resolved_author, actor: $resolved_actor, level: $level, action: $action }
  let line = ($entry | to json --raw)
  $line + "\n" | save -a $log_file
}

# Auto log — called internally by dispatcher on each canonical command.
export def log-action [action: string, level: string = "read"] {
  let root = (project-root)
  let cfg = (load-log-config $root)

  # Env override: ONTOREF_LOG_LEVEL takes precedence over config
  let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
  if $effective_level == "none" { return }

  # "all" maps to interactive (rank 2) — allows everything through
  let threshold_key = if $effective_level == "all" { "interactive" } else { $effective_level }
  let action_rank = (level-rank $level)
  let threshold_rank = (level-rank $threshold_key)
  if $action_rank > $threshold_rank { return }

  let log_file = $"($root)/($cfg.path)"
  let dir = ($log_file | path dirname)
  if not ($dir | path exists) { mkdir $dir }

  # Rotation check before write
  if (needs-rotation $log_file $cfg.rotation) {
    rotate-log $root $cfg
  }

  let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z")
  let author = ($env.ONTOREF_AUTHOR? | default "unknown")
  let actor = ($env.ONTOREF_ACTOR? | default "developer")
  let entry = { ts: $ts, author: $author, actor: $actor, level: $level, action: $action }
  let line = ($entry | to json --raw)
  $line + "\n" | save -a $log_file
}
