///|
/// The shrc command-line interface.
///
/// `dispatch` is a pure-ish function: it returns the text to print and the
/// exit status, and performs its file writes through the store. Keeping the
/// printing out of it makes the commands testable without capturing a
/// terminal.

///|
/// What a command produced: an exit status, text for standard output and text
/// for standard error.
pub(all) struct CliResult {
  code : Int32
  out : String
  err : String
}

///|
fn ok(out : String) -> CliResult {
  { code: 0, out, err: "", }
}

///|
fn ok_with(out : String, err : String) -> CliResult {
  { code: 0, out, err, }
}

///|
fn failure(message : String) -> CliResult {
  { code: 1, out: "", err: message, }
}

///|
/// The help screen.
pub fn help_text() -> String {
  [
    "shrc — manage environment variables and aliases across shells", "", "Usage:",
    "  shrc set [-a|--alias] [-q|--quiet] NAME VALUE", "  shrc set [-a|--alias] [-q|--quiet] NAME=VALUE",
    "  shrc set [-p|--prepend | --append | --drop] [-q|--quiet] NAME VALUE", "  shrc set [-r|--raw] [-q|--quiet] NAME VALUE",
    "  shrc rm  [-a|--alias] [-q|--quiet] NAME", "  shrc ls  [NAME]", "  shrc all",
    "  shrc init [-n|--dry-run] [-q|--quiet]", "  shrc refresh [-n|--dry-run] [-q|--quiet]",
    "  shrc version", "  shrc help", "", "Commands:", "  set   Add or update an environment variable (default) or an alias in",
    "        every detected startup file (~/.profile, ~/.bashrc, ~/.zshrc,", "        ~/.config/fish/config.fish). Prints the statement to eval so the",
    "        change also applies to the current shell. A search-path variable", "        (PATH, PYTHONPATH, LD_LIBRARY_PATH, ...) is stored as an ordered",
    "        list: a directory on its own is prepended, --append puts it last.",
    "  rm    Remove every entry named NAME.", "  ls    List managed entries, optionally filtered by NAME. A search path",
    "        is listed as one row per directory, in search order.", "  all   Show the full managed configuration and every startup file.",
    "  init     Import what the startup files already define into the manifest,",
    "           so `shrc ls` shows them. Files are not modified. `refresh` is", "           an alias. Use `shrc init -n` to preview without writing.",
    "  refresh  Re-scan the startup files and sync the manifest with them (same",
    "           as `init`): new definitions are imported, manually edited ones",
    "           overwrite the stored value, removed ones stay tracked until", "           `shrc rm`. Files are not modified.",
    "", "Options:", "  -a, --alias   Operate on an alias instead of an environment variable.",
    "  -e, --env     Operate on an environment variable (the default).", "  -q, --quiet   Print neither the statement nor the summary.",
    "  -p, --prepend add a directory at the front of a search path.", "      --append  add a directory at the end of a search path.",
    "      --drop    remove a directory from a search path.", "  -r, --raw     store VALUE as shell syntax: $VAR references stay live.",
    "  -n, --dry-run init: report what would be imported, changing nothing.", "",
    "Apply a change to the current shell with eval:", "  eval \"$(shrc set EDITOR vim)\"",
    "  eval \"$(shrc rm EDITOR)\"", "  eval \"$(shrc set --prepend PATH /opt/bin)\"",
    "", "Environment:", "  SHRC_HOME   where the manifest lives (default: ~/.shrc)",
    "  SHELL       used to pick the syntax of the statement printed for eval",
  ].join("\n")
}

///|
/// A POSIX environment variable name: a letter or `_`, then letters, digits or
/// `_`. Anything else would not be valid `export` syntax and could break the
/// startup file it is written into.
pub fn is_valid_env_name(name : String) -> Bool {
  let chars = name.to_array()
  if chars.length() == 0 {
    return false
  }
  let first = chars[0]
  let first_ok = (first >= 'a' && first <= 'z') ||
    (first >= 'A' && first <= 'Z') ||
    first == '_'
  if !first_ok {
    return false
  }
  for i = 1; i < chars.length(); i = i + 1 {
    let ch = chars[i]
    let ok = (ch >= 'a' && ch <= 'z') ||
      (ch >= 'A' && ch <= 'Z') ||
      (ch >= '0' && ch <= '9') ||
      ch == '_'
    if !ok {
      return false
    }
  }
  true
}

///|
/// An alias name: permissive enough for `..` and friends, but no whitespace or
/// shell metacharacters, so the rendered `alias NAME=...` stays a single
/// statement that cannot break out into code.
pub fn is_valid_alias_name(name : String) -> Bool {
  let chars = name.to_array()
  if chars.length() == 0 {
    return false
  }
  for ch in chars {
    let ok = (ch >= 'a' && ch <= 'z') ||
      (ch >= 'A' && ch <= 'Z') ||
      (ch >= '0' && ch <= '9') ||
      ch == '_' ||
      ch == '-' ||
      ch == '.' ||
      ch == ':' ||
      ch == '@' ||
      ch == '%' ||
      ch == ',' ||
      ch == '+' ||
      ch == '^' ||
      ch == '/'
    if !ok {
      return false
    }
  }
  true
}

///|
/// Split `NAME=VALUE` at the first `=`.
fn split_eq(text : String) -> (String, String)? {
  match text.find("=") {
    Some(index) =>
      Some(
        (
          text.view(end_offset=index).to_owned(),
          text.view(start_offset=index + 1).to_owned(),
        ),
      )
    None => None
  }
}

///|
/// The flags shared by `set` and `rm`. `kind` is `None` when neither `-a`
/// nor `-e` was given, which lets `rm` act on both kinds at once. The raw and
/// search-path flags only make sense for `set`, which rejects them elsewhere.
priv struct CommonFlags {
  kind : EntryKind?
  quiet : Bool
  raw : Bool
  prepend : Bool
  append_dir : Bool
  drop : Bool
}

///|
fn parse_common(args : Array[String]) -> (CommonFlags, Array[String]) {
  let mut kind : EntryKind? = None
  let mut quiet = false
  let mut raw = false
  let mut prepend = false
  let mut append_dir = false
  let mut drop = false
  let positional = []
  for arg in args {
    match arg {
      "-a" | "--alias" => kind = Some(Alias)
      "-e" | "--env" => kind = Some(Env)
      "-q" | "--quiet" => quiet = true
      "-r" | "--raw" => raw = true
      "-p" | "--prepend" => prepend = true
      "--append" => append_dir = true
      "--drop" => drop = true
      _ => positional.push(arg)
    }
  }
  ({ kind, quiet, raw, prepend, append_dir, drop, }, positional)
}

///|
/// Whether `kind` passes the `-a`/`-e` filter (`None` accepts both).
fn kind_matches(filter : EntryKind?, kind : EntryKind) -> Bool {
  match filter {
    None => true
    Some(wanted) => kind_eq(wanted, kind)
  }
}

///|
/// The statement that applies `entry` to the shell that is evaluating it.
fn apply_statement(entry : Entry) -> String {
  render_apply(current_eval_family(), entry)
}

///|
/// What `shrc set` decided: the entry to store, or `None` with a note when
/// there is nothing to change (dropping a directory that is not there).
priv struct SetOutcome {
  entry : Entry?
  note : String
}

///|
/// The entry one `shrc set` invocation should store.
///
/// A search path is special: a plain directory value is prepended so the
/// variable keeps its previous contents, `--append`/`--drop` move or remove a
/// single directory, and an identical directory is never duplicated.
fn set_entry(
  kind : EntryKind,
  name : String,
  value : String,
  flags : CommonFlags,
  existing : Entry?,
) -> Result[SetOutcome, String] {
  let path_like = kind_eq(kind, Env) && is_search_path_name(name)
  let base = match existing {
    Some(entry) => entry.value
    None => ""
  }
  let was_raw = match existing {
    Some(entry) => entry.raw
    None => true
  }
  let was_imported = match existing {
    Some(entry) => entry.imported
    None => false
  }
  if flags.drop {
    match existing {
      None => Ok({ entry: None, note: "no entry named \{name}", })
      Some(entry) => {
        let (updated, removed) = path_remove(entry.value, value)
        if !removed {
          Ok({ entry: None, note: "\{value} is not in \{name}", })
        } else {
          Ok({
            entry: Some(
              Entry::new_full(
                entry.kind,
                entry.name,
                updated,
                entry.raw,
                entry.list,
                imported=entry.imported,
              ),
            ),
            note: "dropped \{value} from \{name}",
          })
        }
      }
    }
  } else if flags.prepend || flags.append_dir {
    let root = if base.length() > 0 { base } else { self_ref(name) }
    let updated = path_insert(root, value, flags.append_dir)
    let verb = if flags.append_dir { "appended" } else { "prepended" }
    Ok({
      entry: Some(
        Entry::new_full(
          Env,
          name,
          updated,
          was_raw,
          true,
          imported=was_imported,
        ),
      ),
      note: "\{verb} \{value} to \{name}",
    })
  } else if flags.raw {
    if value.contains("\\") || !refs_translatable(value) {
      Err(
        "`\{value}` is not shell syntax shrc can translate; use --prepend or --append for one directory",
      )
    } else {
      let list = path_like ||
        (match existing {
          Some(entry) => entry.list
          None => false
        })
      Ok({
        entry: Some(
          Entry::new_full(
            Env,
            name,
            canonical_list(name, value),
            true,
            list,
            imported=was_imported,
          ),
        ),
        note: "",
      })
    }
  } else if path_like {
    if contains_live(value) {
      if value.contains("\\") ||
        !refs_translatable(value) ||
        value.contains("[") ||
        value.contains("]") ||
        value.contains("(") ||
        value.contains(")") {
        Err(
          "`\{value}` uses shell syntax shrc cannot translate; pass a directory, a plain search path, or --raw",
        )
      } else {
        Ok({
          entry: Some(
            Entry::new_full(
              Env,
              name,
              canonical_list(name, value),
              true,
              true,
              imported=was_imported,
            ),
          ),
          note: "",
        })
      }
    } else if value.contains(":") || value.contains(";") {
      Ok({
        entry: Some(
          Entry::new_full(
            Env,
            name,
            canonical_list(name, value),
            false,
            true,
            imported=was_imported,
          ),
        ),
        note: "",
      })
    } else {
      let root = if base.length() > 0 { base } else { self_ref(name) }
      let updated = path_insert(root, value, false)
      Ok({
        entry: Some(
          Entry::new_full(
            Env,
            name,
            updated,
            was_raw,
            true,
            imported=was_imported,
          ),
        ),
        note: "prepended \{value} to \{name}",
      })
    }
  } else {
    Ok({
      entry: Some(
        Entry::new_full(kind, name, value, false, false, imported=was_imported),
      ),
      note: "",
    })
  }
}

///|
/// `shrc set ...`
fn cmd_set(args : Array[String]) -> CliResult {
  let (flags, positional) = parse_common(args)
  let (name, value) = match positional.length() {
    2 => (positional[0], positional[1])
    1 =>
      match split_eq(positional[0]) {
        Some(pair) => pair
        None => return failure("shrc: set needs NAME VALUE or NAME=VALUE")
      }
    _ => return failure("shrc: set needs NAME VALUE or NAME=VALUE")
  }
  let kind = flags.kind.unwrap_or(Env)
  if flags.prepend && flags.append_dir {
    return failure("shrc: set takes --prepend or --append, not both")
  }
  if flags.raw && (flags.prepend || flags.append_dir || flags.drop) {
    return failure(
      "shrc: --raw cannot be combined with --prepend, --append or --drop",
    )
  }
  if kind_eq(kind, Alias) &&
    (flags.raw || flags.prepend || flags.append_dir || flags.drop) {
    return failure(
      "shrc: --raw, --prepend, --append and --drop only apply to environment variables",
    )
  }
  let valid = match kind {
    Env => is_valid_env_name(name)
    Alias => is_valid_alias_name(name)
  }
  if !valid {
    return failure(
      match kind {
        Env => "shrc: '\{name}' is not a valid environment variable name"
        Alias => "shrc: '\{name}' is not a valid alias name"
      },
    )
  }
  let entries = load_entries()
  let existing = find_entry(entries, Entry::new(kind, name, value))
  let outcome = match set_entry(kind, name, value, flags, existing) {
    Ok(outcome) => outcome
    Err(message) => return failure("shrc: \{message}")
  }
  let entry = match outcome.entry {
    None => {
      if flags.quiet {
        return ok("")
      }
      return ok_with("", "shrc: \{outcome.note}")
    }
    Some(entry) => entry
  }
  let entries = upsert_entry(entries, entry)
  match save_entries(entries) {
    Ok(_) => ()
    Err(message) => return failure("shrc: \{message}")
  }
  let shells = current_shells()
  // An imported entry keeps its original place in the startup files: edit it
  // in place instead of moving it into the managed block.
  if entry.imported {
    for shell in shells {
      if @fs.path_exists(shell.rc) {
        let text = @fs.read_file_to_string(shell.rc) catch { _ => "" }
        let (updated, changed) = edit_definition_in_text(
          text,
          shell.family,
          entry.name,
          Some(render_entry(shell.family, entry)),
        )
        if changed {
          @fs.write_string_to_file(shell.rc, updated) catch {
            _ => ()
          }
        }
      }
    }
  }
  let errors = apply_to_shells(entries, shells)
  let out = if flags.quiet { "" } else { apply_statement(entry) }
  let notes = []
  if !flags.quiet {
    if outcome.note.length() > 0 {
      notes.push("shrc: \{outcome.note}")
    } else {
      notes.push(
        "shrc: set \{kind_label(entry.kind)} \{entry.name} in \{shells.length()} file(s)",
      )
    }
  }
  for message in errors {
    notes.push("shrc: \{message}")
  }
  if errors.length() > 0 {
    { code: 1, out, err: notes.join("\n"), }
  } else {
    ok_with(out, notes.join("\n"))
  }
}

///|
/// `shrc rm ...`
fn cmd_rm(args : Array[String]) -> CliResult {
  let (flags, positional) = parse_common(args)
  if flags.raw || flags.prepend || flags.append_dir || flags.drop {
    return failure("shrc: rm takes only -a, -e and -q")
  }
  if positional.length() != 1 {
    return failure("shrc: rm needs NAME")
  }
  let name = positional[0]
  let remaining = []
  let removed = []
  for entry in load_entries() {
    let matches = entry.name == name && kind_matches(flags.kind, entry.kind)
    if matches {
      removed.push(entry)
    } else {
      remaining.push(entry)
    }
  }
  match save_entries(remaining) {
    Ok(_) => ()
    Err(message) => return failure("shrc: \{message}")
  }
  let shells = current_shells()
  // Imported entries live in their original place: remove the definition there.
  for entry in removed {
    if entry.imported {
      for shell in shells {
        if @fs.path_exists(shell.rc) {
          let text = @fs.read_file_to_string(shell.rc) catch { _ => "" }
          let (updated, changed) = edit_definition_in_text(
            text,
            shell.family,
            entry.name,
            None,
          )
          if changed {
            @fs.write_string_to_file(shell.rc, updated) catch {
              _ => ()
            }
          }
        }
      }
    }
  }
  let errors = apply_to_shells(remaining, shells)
  let statements = []
  if !flags.quiet {
    let family = current_eval_family()
    for entry in removed {
      statements.push(render_remove(family, entry))
    }
  }
  let notes = []
  if removed.length() == 0 {
    notes.push("shrc: no entry named \{name}")
  } else if !flags.quiet {
    notes.push("shrc: removed \{name} from \{shells.length()} file(s)")
  }
  for message in errors {
    notes.push("shrc: \{message}")
  }
  let out = statements.join("\n")
  if errors.length() > 0 {
    { code: 1, out, err: notes.join("\n"), }
  } else {
    ok_with(out, notes.join("\n"))
  }
}

///|
/// Scan every detected startup file (and the files it sources) for definitions.
/// Merges them into `entries` (the loaded manifest) so conflicts and
/// duplicates are detected, and returns the newly imported entries plus the
/// scan details. Does not modify any file.
fn discover_candidates(
  entries : Array[Entry],
) -> (Array[Entry], Array[(Shell, FileScan, String)], Int32, Int32, Int32) {
  let shells = current_shells()
  let imported = []
  let scans = []
  let mut duplicates = 0
  let mut conflicts = 0
  let mut retained = 0
  let follow = []
  for shell in shells {
    if @fs.path_exists(shell.rc) {
      let original = @fs.read_file_to_string(shell.rc) catch { _ => "" }
      let scan = scan_file(shell.family, original)
      retained = retained + scan.retained
      if scan.entries.length() > 0 {
        scans.push((shell, scan, original))
      }
      let tally = merge_candidates(entries, imported, scan.entries)
      duplicates = duplicates + tally.0
      conflicts = conflicts + tally.1
      follow.push((shell.family, original))
    }
  }
  let seen = []
  for shell in shells {
    seen.push(shell.rc)
  }
  let queue = []
  for item in follow {
    let (family, original) = item
    for path in sourced_files(family, original) {
      if !array_contains(seen, path) {
        seen.push(path)
        queue.push((family, path))
      }
    }
  }
  for item in queue {
    let (family, path) = item
    let original = @fs.read_file_to_string(path) catch { _ => "" }
    let scan = scan_file(family, original)
    retained = retained + scan.retained
    if scan.entries.length() > 0 {
      scans.push((shell(path_basename(path), family, path), scan, original))
    }
    let tally = merge_candidates(entries, imported, scan.entries)
    duplicates = duplicates + tally.0
    conflicts = conflicts + tally.1
  }
  (imported, scans, duplicates, conflicts, retained)
}

///|
/// `shrc init [-n|--dry-run] [-q|--quiet]`
///
/// Import what the startup files already define into the manifest, so
/// `shrc ls` shows them. The startup files themselves are not touched: their
/// original definitions stay where they are, and `shrc set`/`shrc rm` are the
/// only commands that rewrite the managed block. A name the manifest already
/// knows keeps its value.
fn cmd_init(args : Array[String]) -> CliResult {
  let mut dry_run = false
  let mut quiet = false
  for arg in args {
    match arg {
      "-n" | "--dry-run" => dry_run = true
      "-q" | "--quiet" => quiet = true
      _ => return failure("shrc: init takes only -n/--dry-run and -q/--quiet")
    }
  }
  let entries = load_entries()
  let (imported, scans, duplicates, conflicts, retained) = discover_candidates(
    entries,
  )
  let has_work = imported.length() > 0 ||
    (scans.length() > 0 && entries.length() > 0)
  let notes = []
  if !quiet {
    if imported.length() > 0 {
      let verb = if dry_run { "would import" } else { "imported" }
      notes.push(
        "shrc: \{verb} \{imported.length()} entry(s) from \{scans.length()} file(s)",
      )
    } else if !has_work {
      notes.push("shrc: nothing to import")
    } else {
      notes.push("shrc: no new entries to import")
    }
    if duplicates > 0 && imported.length() > 0 {
      notes.push("shrc: skipped \{duplicates} duplicate(s)")
    }
    if conflicts > 0 {
      notes.push(
        "shrc: kept the managed value for \{conflicts} conflicting name(s)",
      )
    }
    if retained > 0 {
      notes.push(
        "shrc: \{retained} definition(s) could not be moved and stay in place",
      )
    }
  }
  let out = if quiet || imported.length() == 0 {
    ""
  } else {
    format_ls(imported).trim_end().to_owned()
  }
  if dry_run || imported.length() == 0 {
    return ok_with(out, notes.join("\n"))
  }
  match save_entries(entries) {
    Ok(_) => ()
    Err(message) => return failure("shrc: \{message}")
  }
  ok_with(out, notes.join("\n"))
}

///|
/// Append the optional shell integration (auto-eval for set/rm) to every
/// detected startup file that does not already contain it. Safe to call on
/// every run: it is guarded by a marker and never duplicates the block.
pub fn install_shell_integration() -> Unit {
  for shell in current_shells() {
    let block = shell_integration_block(shell.family)
    if block.length() > 0 && @fs.path_exists(shell.rc) {
      let current = @fs.read_file_to_string(shell.rc) catch { _ => "" }
      if !current.contains("shrc shell integration") {
        let next = if current.length() == 0 {
          block
        } else {
          current.trim_end().to_owned() + "\n\n" + block
        }
        @fs.write_string_to_file(shell.rc, next) catch {
          _ => ()
        }
      }
    }
  }
}

///|
/// A note for a store that has never been written — every install path except
/// the installer script lands here on the first run.
fn empty_store_note() -> String {
  if @fs.path_exists(manifest_path()) {
    ""
  } else {
    "shrc: no entries yet — run 'shrc init' to import your existing configuration"
  }
}

///|
/// `shrc ls [NAME]`
fn cmd_ls(args : Array[String]) -> CliResult {
  let filter = []
  for arg in args {
    if !arg.has_prefix("-") {
      filter.push(arg)
    }
  }
  let pattern = if filter.length() > 0 { filter[0] } else { "" }
  let entries = filter_entries(load_entries(), pattern)
  if entries.length() == 0 && pattern.length() > 0 {
    return failure("shrc: no entry matching '\{pattern}'")
  }
  let out = format_ls(entries).trim_end().to_owned()
  let note = if entries.length() == 0 { empty_store_note() } else { "" }
  if note.length() > 0 {
    ok_with(out, note)
  } else {
    ok(out)
  }
}

///|
/// `shrc all`
fn cmd_all(args : Array[String]) -> CliResult {
  for arg in args {
    if arg.has_prefix("-") {
      return failure("shrc: all takes no options")
    }
  }
  let entries = load_entries()
  let out = format_all(entries, current_shells()).trim_end().to_owned()
  let note = if entries.length() == 0 { empty_store_note() } else { "" }
  if note.length() > 0 {
    ok_with(out, note)
  } else {
    ok(out)
  }
}

///|
/// Run one shrc command. `args` is the argument list without the program name.
pub fn dispatch(args : Array[String]) -> CliResult {
  if args.length() == 0 {
    return ok(help_text())
  }
  let command = args[0]
  let rest = args.exact_view(start=1).to_owned()
  match command {
    "set" => cmd_set(rest)
    "rm" | "remove" | "unset" | "delete" => cmd_rm(rest)
    "ls" | "list" => cmd_ls(rest)
    "all" => cmd_all(rest)
    "init" | "refresh" => cmd_init(rest)
    "version" | "--version" | "-v" => ok("shrc \{shrc_version}")
    "help" | "--help" | "-h" => ok(help_text())
    _ => failure("shrc: unknown command '\{command}'\n\n" + help_text())
  }
}