///|
/// The kind of configuration file recognised by agconf.
pub(all) enum ContentKind {
  Skill
  Prompt
} derive(Eq, Debug)

///|
pub(all) enum ConflictMode {
  Overwrite
  Skip
  Ask
} derive(Eq, Debug)

///|
pub struct Item {
  kind : ContentKind
  source : String
  relative : String
  skill_name : String?
} derive(Eq, Debug)

///|
fn join(left : String, right : String) -> String {
  if left == "" {
    right
  } else if left.has_suffix("/") {
    left + right
  } else {
    left + "/" + right
  }
}

///|
fn basename(path : String) -> String {
  let path = path.trim_end(chars="/")
  match path.rev_find("/") {
    Some(i) => path[i + 1:].to_owned()
    None => path.to_owned()
  }
}

///|
fn dirname(path : String) -> String {
  match path.rev_find("/") {
    Some(i) => path[:i].to_owned()
    None => ""
  }
}

///|
fn is_markdown(path : String) -> Bool {
  path.has_suffix(".md")
}

///|
fn is_inside_skills_directory(path : String) -> Bool {
  path.has_prefix("skills/") || path.find("/skills/") is Some(_)
}

///|
fn is_inside_content_directory(path : String) -> Bool {
  let name = basename(path)
  name == "prompts" ||
  name == "commands" ||
  path.has_prefix("prompts/") ||
  path.has_prefix("commands/") ||
  path.find("/prompts/") is Some(_) ||
  path.find("/commands/") is Some(_)
}

///|
fn is_skill_file(path : String) -> Bool {
  basename(path) == "SKILL.md" && is_inside_skills_directory(dirname(path))
}

///|
fn discover_file(
  root : String,
  file : String,
  relative : String,
  explicit : Bool,
) -> Item? {
  let name = basename(file)
  if !explicit && (name == "README.md" || name == "CHANGELOG.md") {
    return None
  }
  if name == "SKILL.md" && is_skill_file(file) {
    return Some({
      kind: Skill,
      source: file,
      relative,
      skill_name: Some(basename(dirname(file))),
    })
  }
  if explicit && is_markdown(file) && !is_inside_skills_directory(file) {
    return Some({
      kind: Prompt,
      source: file,
      relative: basename(file),
      skill_name: None,
    })
  }
  if is_markdown(file) &&
    (
      relative.has_prefix("prompts/") ||
      relative.has_prefix("commands/") ||
      is_inside_content_directory(root)
    ) {
    let output_relative = if is_inside_content_directory(root) {
      relative
    } else {
      match relative.find("/") {
        Some(i) => relative[i + 1:].to_owned()
        None => relative
      }
    }
    return Some({
      kind: Prompt,
      source: file,
      relative: output_relative,
      skill_name: None,
    })
  }
  None
}

///|
async fn discover_tree(
  root : String,
  path : String,
  relative : String,
  items : Array[Item],
) -> Unit {
  let entries = @fs.readdir(path, include_hidden=false, sort=true)
  for entry in entries {
    let child = join(path, entry)
    let child_relative = if relative == "" {
      entry
    } else {
      join(relative, entry)
    }
    match @fs.kind(child) {
      Directory => discover_tree(root, child, child_relative, items)
      Regular =>
        match discover_file(root, child, child_relative, false) {
          Some(item) => items.push(item)
          None => ()
        }
      _ => ()
    }
  }
}

///|
/// Discover only documented skill and prompt patterns below a source path.
pub async fn discover(path : String) -> Array[Item] {
  match @fs.kind(path) {
    Regular =>
      match discover_file(path, path, basename(path), true) {
        Some(item) => [item]
        None => []
      }
    Directory => {
      let items = []
      discover_tree(path, path, "", items)
      items
    }
    _ => []
  }
}

///|
fn parse_agent(agent : String) -> String raise {
  match agent {
    "universal" | "pi" | "codex" => agent
    _ =>
      raise Failure::Failure(
        "unknown agent: \{agent}; expected universal, pi, or codex",
      )
  }
}

///|
fn home() -> String raise {
  match @env.get_env_var("HOME") {
    Some(value) => value
    None => raise Failure::Failure("HOME is required for --global")
  }
}

///|
fn skills_directory(agent : String, global : Bool) -> String raise {
  let agent = parse_agent(agent)
  if global {
    let prefix = home()
    match agent {
      "pi" => join(prefix, ".pi/agent/skills")
      _ => join(prefix, ".agents/skills")
    }
  } else {
    match agent {
      "universal" | "codex" => ".agents/skills"
      "pi" => ".pi/skills"
      _ => abort("validated agent")
    }
  }
}

///|
fn prompts_directory(agent : String, global : Bool) -> String? raise {
  let agent = parse_agent(agent)
  match agent {
    "universal" => None
    "codex" if !global => None
    "codex" => Some(join(home(), ".codex/prompts"))
    "pi" if global => Some(join(home(), ".pi/agent/prompts"))
    "pi" => Some(".pi/prompts")
    _ => abort("validated agent")
  }
}

///|
async fn warn_unsupported_prompt(
  agent : String,
  global : Bool,
  warned_content : Array[String],
) -> Unit {
  let warning_key = agent + ":prompt"
  if warned_content.contains(warning_key) {
    return
  }
  warned_content.push(warning_key)
  let reason = match agent {
    "universal" => "universal has no prompts directory"
    "codex" if !global => "codex prompts require -g / --global"
    _ => abort("supported prompt destination")
  }
  @stdio.stderr.write("warning: \{reason}; skipping prompts for \{agent}\n")
}

///|
fn output_directory(
  kind : ContentKind,
  agent : String,
  global : Bool,
  as_prompt : Bool,
) -> String? raise {
  if as_prompt || kind is Prompt {
    prompts_directory(agent, global)
  } else {
    Some(skills_directory(agent, global))
  }
}

///|
fn unique_agents_for_item(
  item : Item,
  agents : Array[String],
  global : Bool,
  as_prompt : Bool,
) -> Array[String] raise {
  let unique_agents = []
  let seen_agents = []
  let seen_directories = []
  for agent in agents {
    if !seen_agents.contains(agent) {
      seen_agents.push(agent)
      match output_directory(item.kind, agent, global, as_prompt) {
        Some(directory) =>
          if !seen_directories.contains(directory) {
            seen_directories.push(directory)
            unique_agents.push(agent)
          }
        None => unique_agents.push(agent)
      }
    }
  }
  unique_agents
}

///|
fn frontmatter_close(text : String, from : Int) -> Int? {
  match text[from:].find("\n---") {
    None => None
    Some(offset) => {
      let after = from + offset + 4
      if after == text.length() ||
        text[after] == '\n' ||
        (
          after + 1 < text.length() &&
          text[after] == '\r' &&
          text[after + 1] == '\n'
        ) {
        Some(after)
      } else {
        frontmatter_close(text, after)
      }
    }
  }
}

///|
fn strip_frontmatter(text : String) -> String {
  let has_lf_open = text.has_prefix("---\n")
  let has_crlf_open = text.has_prefix("---\r\n")
  if !has_lf_open && !has_crlf_open {
    return text
  }
  let start = if has_crlf_open { 5 } else { 4 }
  match frontmatter_close(text, start) {
    None => text
    Some(after) =>
      if after == text.length() {
        ""
      } else if text[after] == '\n' {
        text[after + 1:].to_owned()
      } else {
        text[after + 2:].to_owned()
      }
  }
}

///|
async fn ensure_parent(path : String) -> Unit {
  let parent = dirname(path)
  if parent != "" && !@fs.exists(parent) {
    @fs.mkdir(parent, recursive=true)
  }
}

///|
async fn wants_overwrite(path : String, mode : ConflictMode) -> Bool {
  if !@fs.exists(path) {
    return true
  }
  match mode {
    Overwrite => true
    Skip => false
    Ask => {
      println("\{path} exists; overwrite? [y/N]")
      match @stdio.stdin.read_until("\n") {
        Some(answer) => answer.trim() == "y" || answer.trim() == "Y"
        None => false
      }
    }
  }
}

///|
fn validate_items(items : Array[Item], as_prompt : Bool) -> Unit raise {
  for item in items {
    if as_prompt && item.kind is Prompt {
      raise Failure::Failure("--as-prompt only accepts skills")
    }
  }
}

///|
async fn copy_source_file(
  source : String,
  shown_source : String,
  destination : String,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  if dry_run {
    println("\{shown_source} → \{destination}")
    return
  }
  if !wants_overwrite(destination, conflict) {
    println("Skipping \{shown_source} → \{destination}")
    return
  }
  ensure_parent(destination)
  @fs.write_file(
    destination,
    @fs.read_file(source),
    create_mode=CreateOrTruncate,
  )
  println("\{shown_source} → \{destination}")
}

///|
async fn copy_text_file(
  shown_source : String,
  destination : String,
  content : String,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  if dry_run {
    println("\{shown_source} → \{destination}")
    return
  }
  if !wants_overwrite(destination, conflict) {
    println("Skipping \{shown_source} → \{destination}")
    return
  }
  ensure_parent(destination)
  @fs.write_file(destination, content, create_mode=CreateOrTruncate)
  println("\{shown_source} → \{destination}")
}

///|
fn skill_shown_source(
  display_source : String,
  source : String,
  source_relative : String,
) -> String {
  if display_source == "" {
    source
  } else if display_source.has_suffix(".md") {
    join(dirname(display_source), source_relative)
  } else {
    join(display_source, source_relative)
  }
}

///|
async fn copy_skill_entry(
  source : String,
  relative : String,
  destination : String,
  display_source : String,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  match @fs.kind(source) {
    Directory =>
      copy_skill_tree(
        source, relative, destination, display_source, dry_run, conflict,
      )
    Regular =>
      copy_source_file(
        source,
        skill_shown_source(display_source, source, relative),
        destination,
        dry_run,
        conflict,
      )
    _ => ()
  }
}

///|
async fn copy_skill_tree(
  source_root : String,
  source_relative : String,
  destination_root : String,
  display_source : String,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  let entries = @fs.readdir(source_root, include_hidden=true, sort=true)
  let skill_source = join(source_root, "SKILL.md")
  if @fs.exists(skill_source) && @fs.kind(skill_source) is Regular {
    copy_skill_entry(
      skill_source,
      join(source_relative, "SKILL.md"),
      join(destination_root, "SKILL.md"),
      display_source,
      dry_run,
      conflict,
    )
  }
  // @fs.readdir sorts descending; visit the remaining paths in reverse.
  for index = entries.length() - 1; index >= 0; {
    let entry = entries[index]
    if entry != "SKILL.md" {
      copy_skill_entry(
        join(source_root, entry),
        join(source_relative, entry),
        join(destination_root, entry),
        display_source,
        dry_run,
        conflict,
      )
    }
    continue index - 1
  }
}

///|
fn source_relative(source : String, root : String) -> String {
  let prefix = join(root, "")
  if source.has_prefix(prefix) {
    source[prefix.length():].to_owned()
  } else {
    basename(source)
  }
}

///|
async fn copy_item(
  item : Item,
  display_source : String,
  source_root : String,
  agent : String,
  global : Bool,
  as_prompt : Bool,
  dry_run : Bool,
  conflict : ConflictMode,
  warned_content : Array[String],
) -> Unit {
  match (item.kind, as_prompt) {
    (Skill, true) => {
      let name = item.skill_name.unwrap_or(basename(dirname(item.source)))
      let shown_source = skill_shown_source(
        display_source,
        item.source,
        item.relative,
      )
      match prompts_directory(agent, global) {
        Some(directory) =>
          copy_text_file(
            shown_source,
            join(directory, name + ".md"),
            strip_frontmatter(@fs.read_file(item.source).text()),
            dry_run,
            conflict,
          )
        None => warn_unsupported_prompt(agent, global, warned_content)
      }
    }
    (Skill, false) => {
      let name = item.skill_name.unwrap_or(basename(dirname(item.source)))
      copy_skill_tree(
        dirname(item.source),
        dirname(item.relative),
        join(skills_directory(agent, global), name),
        display_source,
        dry_run,
        conflict,
      )
    }
    (Prompt, false) => {
      let shown_source = if display_source == "" {
        item.source
      } else if display_source.has_suffix(".md") {
        display_source
      } else {
        join(display_source, source_relative(item.source, source_root))
      }
      match prompts_directory(agent, global) {
        Some(directory) =>
          copy_source_file(
            item.source,
            shown_source,
            join(directory, item.relative),
            dry_run,
            conflict,
          )
        None => warn_unsupported_prompt(agent, global, warned_content)
      }
    }
    (Prompt, true) => raise Failure::Failure("--as-prompt only accepts skills")
  }
}

///|
/// Parse a GitHub URL into (repo, ref, subpath).
/// Accepted forms:
///   https://github.com//
///   https://github.com///tree//
///   https://github.com///blob//
fn github_source(source : String) -> (String, String, String) raise {
  let raw = source[19:].to_owned()
  // Strip query, fragment, and an optional trailing slash.
  let path_only = match raw.find("?") {
    Some(i) => raw[:i]
    None =>
      match raw.find("#") {
        Some(i) => raw[:i]
        None => raw
      }
  }
  let pieces = path_only
    .trim_end(chars="/")
    .split("/")
    .map(piece => piece.to_owned())
    .collect()
  guard pieces.length() >= 2 else {
    raise Failure::Failure("invalid GitHub URL: \{source}")
  }
  if pieces.contains(".") || pieces.contains("..") {
    raise Failure::Failure(
      "invalid GitHub URL: path must not contain '..' or '.' segments",
    )
  }
  let repo = pieces[0] + "/" + pieces[1]
  if pieces.length() == 2 {
    (repo, "", "")
  } else if pieces.length() >= 4 && (pieces[2] == "tree" || pieces[2] == "blob") {
    let rest = if pieces.length() > 4 { pieces[4:].join("/") } else { "" }
    if pieces[2] == "blob" && rest == "" {
      raise Failure::Failure(
        "invalid GitHub URL: blob URL requires a file path",
      )
    }
    (repo, pieces[3], rest)
  } else {
    raise Failure::Failure(
      "invalid GitHub URL: expected /tree// or /blob//",
    )
  }
}

///|
fn github_clone_url(repo : String) -> String {
  let repository = if repo.has_suffix(".git") { repo } else { repo + ".git" }
  "https://github.com/\{repository}"
}

///|
fn is_local_path(source : String) -> Bool {
  source == "." ||
  source == ".." ||
  source.has_prefix("./") ||
  source.has_prefix("../") ||
  source.has_prefix("/")
}

///|
async fn fetch_local(
  source : String,
  agents : Array[String],
  global : Bool,
  as_prompt : Bool,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  if !@fs.exists(source) {
    raise Failure::Failure("local source does not exist: \{source}")
  }
  let items = discover(source)
  if items.length() == 0 {
    raise Failure::Failure("no recognised content in \{source}")
  }
  validate_items(items, as_prompt)
  let warned_content = []
  for item in items {
    for agent in unique_agents_for_item(item, agents, global, as_prompt) {
      copy_item(
        item, "", source, agent, global, as_prompt, dry_run, conflict, warned_content,
      )
    }
  }
}

///|
async fn fetch_github(
  source : String,
  git_ref : String,
  agents : Array[String],
  global : Bool,
  as_prompt : Bool,
  dry_run : Bool,
  conflict : ConflictMode,
) -> Unit {
  let (repo, url_ref, subpath) = github_source(source)
  let selected_ref = if git_ref == "" { url_ref } else { git_ref }
  @async.with_task_group() <| group => {
    let tmp = @fs.tmpdir(prefix="agconf")
    group.add_defer() <| () => {
      @async.protect_from_cancel(() => @fs.rmdir(tmp, recursive=true))
    }
    let checkout = join(tmp, "repo")
    let args = ["clone", "--depth", "1"]
    if selected_ref != "" {
      args.push("--branch")
      args.push(selected_ref)
    }
    args.push(github_clone_url(repo))
    args.push(checkout)
    let (status, _, _) = @process.collect_output("git", args)
    if status != 0 {
      raise Failure::Failure("git clone failed for \{repo}")
    }
    let selected = if subpath == "" {
      checkout
    } else {
      join(checkout, subpath)
    }
    if !@fs.exists(selected) {
      raise Failure::Failure("GitHub path not found: \{subpath}")
    }
    let items = discover(selected)
    if items.length() == 0 {
      raise Failure::Failure("no recognised content in \{source}")
    }
    validate_items(items, as_prompt)
    let warned_content = []
    for item in items {
      for agent in unique_agents_for_item(item, agents, global, as_prompt) {
        copy_item(
          item, source, selected, agent, global, as_prompt, dry_run, conflict, warned_content,
        )
      }
    }
  }
}

///|
/// Fetch a local path or GitHub URL into agent configuration directories.
pub async fn fetch(
  source : String,
  agents? : Array[String] = [],
  global? : Bool = false,
  as_prompt? : Bool = false,
  git_ref? : String = "",
  dry_run? : Bool = false,
  conflict? : ConflictMode = Overwrite,
) -> Unit {
  let agents = if agents.length() == 0 {
    ["pi", "codex", "universal"]
  } else {
    agents
  }
  for agent in agents {
    ignore(parse_agent(agent))
  }
  let local_exists = @fs.exists(source) catch { _ => false }
  if local_exists || is_local_path(source) {
    fetch_local(source, agents, global, as_prompt, dry_run, conflict)
  } else if source.has_prefix("https://github.com/") {
    fetch_github(source, git_ref, agents, global, as_prompt, dry_run, conflict)
  } else {
    raise Failure::Failure(
      "unsupported source: \{source}; use a https://github.com/ URL or local path",
    )
  }
}