(** cn_hub.ml — Hub discovery, path constants, and shared utilities

    Hub detection, path helpers, peer loading, timestamped naming.
    Shared infrastructure used by all domain modules.

    #156: Discovery now checks for .cn/placement.json first, enabling
    attached hubs with distinct hub_root and workspace_root. *)

open Cn_lib

(* === Hub Detection === *)

(** Legacy single-root discovery. Walks up from dir looking for
    .cn/config.{yaml,json} or state/peers.md. Returns the directory
    containing hub markers. *)
let rec find_hub_path dir =
  match dir with
  | "/" -> None
  | _ ->
      let has_yaml = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir ".cn/config.yaml") in
      let has_json = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir ".cn/config.json") in
      let has_peers = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir "state/peers.md") in
      match has_yaml || has_json || has_peers with
      | true -> Some dir
      | false -> find_hub_path (Cn_ffi.Path.dirname dir)

(** Placement-aware discovery (#156). Walks up from dir:
    1. Check for .cn/placement.json — if found, parse and return explicit roots
    2. Fall back to legacy single-root discovery (standalone mode)

    Returns a placement record with hub_root and workspace_root. *)
let rec discover dir =
  match dir with
  | "/" -> None  (* walked to root without finding hub or placement manifest *)
  | _ ->
      match Cn_placement.find_placement dir with
      | Some placement -> Some placement
      | None ->
          (* No placement manifest at this level — check for legacy hub markers *)
          let has_yaml = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir ".cn/config.yaml") in
          let has_json = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir ".cn/config.json") in
          let has_peers = Cn_ffi.Fs.exists (Cn_ffi.Path.join dir "state/peers.md") in
          if has_yaml || has_json || has_peers then
            Some (Cn_placement.standalone dir)
          else
            discover (Cn_ffi.Path.dirname dir)

(* === Logging === *)

let log_action _hub_path action details =
  (* Compatibility shim: forward to Cn_trace structured events.
     Infers component/layer from action prefix for richer traceability. *)
  let component, layer =
    if String.length action >= 6 && String.sub action 0 6 = "daemon" then
      "telegram", Cn_trace.Sensor
    else if String.length action >= 10 && String.sub action 0 10 = "projection" then
      "projection", Cn_trace.Sensor
    else if String.length action >= 5 && String.sub action 0 5 = "actor" then
      "runtime", Cn_trace.Body
    else if String.length action >= 7 && String.sub action 0 7 = "process" then
      "runtime", Cn_trace.Body
    else if String.length action >= 2 && String.sub action 0 2 = "io" then
      "runtime", Cn_trace.Body
    else
      "runtime", Cn_trace.Body
  in
  Cn_trace.gemit ~component ~layer
    ~event:action ~severity:Info ~status:Ok_
    ~reason:details ()

(* === Peers === *)

let load_peers hub_path =
  let peers_path = Cn_ffi.Path.join hub_path "state/peers.md" in
  match Cn_ffi.Fs.exists peers_path with
  | true -> parse_peers_md (Cn_ffi.Fs.read peers_path)
  | false -> []

(* === String Helpers === *)

let slugify ?max_len s =
  let s = match max_len with
    | Some n when String.length s > n -> String.sub s 0 n
    | _ -> s
  in
  let s = String.lowercase_ascii s in
  let buf = Buffer.create (String.length s) in
  let _last_was_sep =
    String.fold_left (fun was_sep c ->
      if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') then begin
        Buffer.add_char buf c; false
      end else if not was_sep then begin
        Buffer.add_char buf '-'; true
      end else was_sep
    ) true s
  in
  let result = Buffer.contents buf in
  let len = String.length result in
  if len > 0 && result.[len-1] = '-' then String.sub result 0 (len-1)
  else result

let sanitize_timestamp s =
  String.map (fun c -> if c = ':' || c = '.' then '-' else c) s

let remove_char c s =
  String.split_on_char c s |> String.concat ""

(* === Helpers === *)

let is_md_file = ends_with ~suffix:".md"
let split_lines s = String.split_on_char '\n' s |> List.filter non_empty

(* === Path Constants (v2 structure) === *)

let threads_in hub = Cn_ffi.Path.join hub "threads/in"
let threads_mail_inbox hub = Cn_ffi.Path.join hub "threads/mail/inbox"
let threads_mail_outbox hub = Cn_ffi.Path.join hub "threads/mail/outbox"
let threads_mail_sent hub = Cn_ffi.Path.join hub "threads/mail/sent"
(* threads_reflections_daily / _weekly removed in v3.37.0 (#184) —
   the daily/weekly reflection file layout is now owned by the
   package command shell scripts at
   src/agent/commands/{daily,weekly}/cn-<id>. *)
let threads_adhoc hub = Cn_ffi.Path.join hub "threads/adhoc"
let mca_dir hub = Cn_ffi.Path.join hub "state/mca"

(* === Timestamped Naming === *)

let timestamp_slug () =
  let iso = Cn_fmt.now_iso () in
  let date = Cn_fmt.date_of_iso iso |> remove_char '-' in
  let time = Cn_fmt.time_of_iso iso |> remove_char ':' in
  Printf.sprintf "%s-%s" date time

let make_thread_filename slug =
  Printf.sprintf "%s-%s.md" (timestamp_slug ()) slug
