# Halts execution with error code 5
def halt_error(): halt(5);

# Checks if input is an array
def is_array(a): type(a) == "array";

# Checks if input is markdown
def is_markdown(m): type(m) == "markdown";

# Checks if input is a boolean
def is_bool(b): type(b) == "bool";

# Checks if input is a number
def is_number(n): type(n) == "number";

# Checks if input is a string
def is_string(s): type(s) == "string";

# Checks if input is None
def is_none(n): type(n) == "None";

# Checks if input is a dictionary
def is_dict(d): type(d) == "dict";

# Checks if string contains a substring
def contains(haystack, needle):
  if (is_dict(haystack)):
    !is_none(haystack[needle])
  else:
    index(haystack, needle) != -1
end

# Removes prefix string from input if it exists
def ltrimstr(s, left):
  if (starts_with(s, left)):
    slice(s, index(s, left) + len(left), len(s))
  else:
    s
end

# Removes suffix string from input if it exists
def rtrimstr(s, right):
  if (ends_with(s, right)):
    slice(s, 0, sub(len(s), len(right)))
  else:
    s
end

# Checks if string, array or dict is empty
def is_empty(s):
  if (is_string(s) || is_array(s) || is_dict(s)):
    len(s) == 0
  elif (is_none(s)):
    true
  else:
    false
end

# Tests if string matches a pattern
def test(s, pattern): is_regex_match(s, pattern);

# Returns value if condition is true, None otherwise
def select(v, f): if (f): v;

# Returns array if input is array, None otherwise
def arrays(a): select(a, is_array(a));

# Returns markdown if input is markdown, None otherwise
def markdowns(m): select(m, is_markdown(m));

# Returns boolean if input is boolean, None otherwise
def booleans(b): select(b, is_bool(b));

# Returns number if input is number, None otherwise
def numbers(n): select(n, is_number(n));

# Formats a date to ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)
def to_date_iso8601(d): to_date(d, "%Y-%m-%dT%H:%M:%SZ");

# Applies a given function to each element of the provided array and returns a new array with the results.
def map(v, f):
  if (is_dict(v)):
    do
      let map_dict = fn(v, f): let mapped = foreach (x, entries(v)): f(x); | dict(mapped);
      | map_dict(v, f)
    end
  elif (is_none(v)):
    None
  else:
    foreach (x, v): f(x);
end

# Applies a function to each element and flattens the result into a single array
def flat_map(v, f):
  if (is_none(v)):
    None
  else:
    foreach (x, v): f(x); | flatten()
end

# Filters the elements of an array based on a provided callback function.
def filter(v, f):
  if (is_dict(v)):
    do
      let filter_dict = fn(v, f): let fileted = foreach (x, entries(v)): select(x, f(x)); | dict(compact(fileted));
      | filter_dict(v, f)
    end
  elif (is_none(v)):
    None
  else:
    do
      let _filter = fn(v, f): foreach (x, v): select(x, f(x)); | compact();
      | _filter(v, f)
    end
end

# Executes a provided function once for each element in an array or each key-value pair in a dictionary.
def each(v, f):
  let target = if (is_dict(v)): entries(v) else: v
  | var i = 0
  | loop:
      if (i >= len(target)): break
      | f(target[i])
      | i += 1
      | None
    end
  | v
end

# Returns the first element of an array
def first(arr): arr[0];

# Returns the last element of an array
def last(arr): arr[len(arr) - 1];

# Returns the second element of an array
def second(arr): if (len(arr) > 1): arr[1];

# Checks if markdown is h1 heading
def is_h1(md): to_md_name(md) == "h1";

# Checks if markdown is h2 heading
def is_h2(md): to_md_name(md) == "h2";

# Checks if markdown is h3 heading
def is_h3(md): to_md_name(md) == "h3";

# Checks if markdown is h4 heading
def is_h4(md): to_md_name(md) == "h4";

# Checks if markdown is h5 heading
def is_h5(md): to_md_name(md) == "h5";

# Checks if markdown is h6 heading
def is_h6(md): to_md_name(md) == "h6";

# Checks if markdown is heading
def is_h(md): or(is_h1(md), is_h2(md), is_h3(md), is_h4(md), is_h5(md), is_h6(md));

# Checks if markdown is a heading of the specified level (1-6)
def is_h_level(md, level):
  if (level == 1):
    is_h1(md)
  elif (level == 2):
    is_h2(md)
  elif (level == 3):
    is_h3(md)
  elif (level == 4):
    is_h4(md)
  elif (level == 5):
    is_h5(md)
  elif (level == 6):
    is_h6(md)
  else:
    error("Invalid heading level: " + to_string(level))
end

# Checks if markdown is table align
def is_table_align(md): to_md_name(md) == "table_align";

# Checks if markdown is table cell
def is_table_cell(md): to_md_name(md) == "table_cell";

# Checks if markdown is emphasis
def is_em(md): to_md_name(md) == "emphasis";

# Checks if markdown is html
def is_html(md): to_md_name(md) == "html";

# Checks if markdown is yaml
def is_yaml(md): to_md_name(md) == "yaml";

# Checks if markdown is toml
def is_toml(md): to_md_name(md) == "toml";

# Checks if markdown is code block
def is_code(md): to_md_name(md) == "code";

# Checks if markdown is text
def is_text(text): to_md_name(text) == "text";

# Checks if markdown is list
def is_list(list): to_md_name(list) == "list";

# Checks if markdown node's URL matches a specified URL
# deprecated: use select(.link.url == url) instead
def matches_url(node, url): get_url(node) == url;

# Checks if markdown is MDX Flow Expression
def is_mdx_flow_expression(mdx): to_md_name(mdx) == "mdx_flow_expression";

# Checks if markdown is MDX Jsx Flow Element
def is_mdx_jsx_flow_element(mdx): to_md_name(mdx) == "mdx_jsx_flow_element";

# Checks if markdown is MDX Jsx Text Element
def is_mdx_jsx_text_element(mdx): to_md_name(mdx) == "mdx_jsx_text_element";

# Checks if markdown is MDX Text Expression
def is_mdx_text_expression(mdx): to_md_name(mdx) == "mdx_text_expression";

# Checks if markdown is MDX Js Esm
def is_mdx_js_esm(mdx): to_md_name(mdx) == "mdx_js_esm";

# Checks if markdown is MDX
def is_mdx(mdx):
  is_mdx_flow_expression(mdx) ||
    is_mdx_jsx_flow_element(mdx) ||
    is_mdx_jsx_text_element(mdx) ||
    is_mdx_text_expression(mdx) ||
    is_mdx_js_esm(mdx)
end

# Checks if markdown is a callout block
def is_callout(md):
  let first_line = do to_text(md) | split("\n") | first();
  | to_md_name(md) == "blockquote" && starts_with(first_line, "[!") && contains(first_line, "]")
end

# Returns an array of length n filled with the given value.
def fill(value, n):
  if (n < 0):
    error("n must be non-negative")
  elif (n == 0):
    []
  else:
    foreach (i, range(0, n - 1)): value;
end

# Sorts an array using a key function that extracts a comparable value for each element.
def sort_by(arr, f):
  let decorate_arr = foreach (x, arr): [f(x), x]; | _sort_by_impl(decorate_arr) | map(second);

# Returns the count of elements in the array that satisfy the provided function.
def count_by(arr, f):
  if (!is_array(arr) || is_empty(arr)):
    0
  else:
    filter(arr, f) | len()
end

# Skips the first n elements of an array and returns the rest
def skip(arr, n):
  if (n < 0):
    error("n must be non-negative")
  elif (n > len(arr)): []
  else:
    slice(arr, n, len(arr))
end

# Takes the first n elements of an array
def take(arr, n):
  if (n < 0):
    error("n must be non-negative")
  elif (n > len(arr)):
    arr
  else:
    slice(arr, 0, n)
end

# Returns the index of the first element in an array that satisfies the provided function.
def find_index(arr, f):
  let _find_index_impl = fn(arr, f, idx):
      if (idx == len(arr)):
        -1
      elif (f(get(arr, idx))):
        idx
      else:
        _find_index_impl(arr, f, idx + 1);

  | if (not(is_array(arr))):
      error("first argument must be an array")
    else:
      _find_index_impl(arr, f, 0)
end

# Skips elements from the beginning of an array while the provided function returns true
def skip_while(arr, f):
  if (!is_array(arr)):
    error("first argument must be an array")
  elif (is_empty(arr)):
    []
  else:
    do
      var i = 0
      | while (i < len(arr) && f(get(arr, i))):
          i += 1
        end
      | slice(arr, i, len(arr))
    end
end

# Takes elements from the beginning of an array while the provided function returns true
def take_while(arr, f):
  if (not(is_array(arr))):
    error("first argument must be an array")
  elif (is_empty(arr)):
    []
  else:
    do
      var i = 0
      | while (and(i < len(arr), f(get(arr, i)))):
          i += 1
        end
      | slice(arr, 0, i)
    end
end

# Groups elements of an array by the result of applying a function to each element
def group_by(arr, f):
  var groups = dict()
  | if (not(is_array(arr))):
      error("first argument must be an array")
    elif (is_empty(arr)):
      dict()
    else:
      do
        var i = 0
        | while (not(is_none(get(arr, i)))):
            let v = get(arr, i)
            | i += 1
            | let key = to_string(f(v))
            | let existing = get(groups, key)
            | let new_group = if (is_none(existing)): [v] else: existing + v
            | groups = set(groups, key, new_group)
            | groups
          end
      end
end

# Returns true if any element in the array satisfies the provided function.
def any(v, f): if (is_empty(v)): false else: len(filter(v, f)) != 0;

# Returns true if all element in the array satisfies the provided function.
def all(v, f): if (is_empty(v)): true else: len(filter(v, f)) == len(v);

# Returns true if the element is in the array.
def in(v, elem):
  if (is_array(v)):
    if (is_array(elem)):
      all(elem, fn(x): in(v, x);)
    else:
      any(v, fn(x): x == elem;)
  else:
    contains(v, elem)
end

# Reduces an array to a single value by applying a function, starting from an initial value.
def fold(arr, init, f):
  if (not(is_array(arr))):
    error("first argument must be an array")
  elif (is_empty(arr)):
    init
  else:
    do
      var acc = init
      | var i = 0
      | while (i != len(arr)):
          acc = f(acc, get(arr, i))
          | i += 1
          | acc
        end
    end
end

# Returns a new array with duplicate elements removed, comparing by the result of the provided function.
def unique_by(arr, f):
  if (not(is_array(arr))):
    error("first argument must be an array")
  elif (is_empty(arr)): []
  else:
    do
      var seen = dict()
      | var result = []
      | var i = 0
      | while (i != len(arr)):
          let item = get(arr, i)
          | let key = to_string(f(item))
          | let already_seen = get(seen, key)
          | result = if (is_none(already_seen)): result + item else: result
          | seen = if (is_none(already_seen)): set(seen, key, true) else: seen
          | i += 1
          | result
        end
    end
end

# Returns the input value unchanged.
def identity(x): x;

# Transposes a 2D array (matrix), swapping rows and columns.
def transpose(matrix):
  if (!is_array(matrix)):
    error("transpose: input must be an array of arrays")
  elif (is_empty(matrix)): []
  elif (!all(matrix, is_array)):
    error("transpose: all elements must be arrays")
  else: do
      let row_count = len(matrix)
      | let col_count = if (row_count == 0): 0 else: len(matrix[0])
      | foreach (j, range(0, col_count - 1)):
          foreach (i, range(0, row_count - 1)):
            matrix[i][j]
          end
        end
    end
end

# Applies a function to a value and returns the value (useful for debugging or side effects).
macro tap(tap_value, tap_expr) do
  quote do
    tap_expr | tap_value
  end
end

# Executes the expression only if the condition is false.
macro unless(unless_cond, unless_expr) do
  quote do
    if (unquote(!unless_cond)): unquote(unless_expr)
  end
end

# Executes the expression repeatedly until the condition is true.
macro until(until_cond, until_expr) do
  quote do
    loop:
      if (unquote(until_cond)): break
      | unquote(until_expr)
    end
  end
end

# Extracts values from an array of objects based on a specified selector.
macro pluck(pluck_obj, selector) do
  quote do
    match (unquote(pluck_obj)):
      | :array: do
          unquote(pluck_obj)
          | map(fn(x):
            match (x):
              | :dict: x[unquote(selector)]
              | _: do
                  x | select(unquote(selector), unquote(selector))
                end
            end
          end) | compact()
        end
      | _: do
          unquote(pluck_obj) | unquote(selector)
        end
    end
  end
end

# Maps over an array and removes None values from the result.
def compact_map(arr, f): map(arr, f) | compact();

# Filters out elements that match the condition (opposite of filter).
def reject(arr, f): filter(arr, fn(x): !f(x););

# Splits an array into two arrays: [matching, not_matching] based on a condition.
def partition(arr, f): [filter(arr, f), filter(arr, fn(x): !f(x);)];

# Safely gets a value from a dict with a default if the key doesn't exist.
def get_or(dict, key, default):
  let val = get(dict, key)
  | if (is_none(val)): default else: val
end

# Executes an expression n times and returns an array of results.
macro times(t_n, t_expr) do
  quote do
    if (unquote(t_n) == 0):
      []
    else:
      foreach (_, range(0, unquote(t_n) - 1)): unquote(t_expr);
  end
end

# Checks if a value is between min and max (inclusive).
def between(value, min, max): value >= min && value <= max;

# Sums elements of an array after applying a transformation function.
def sum_by(arr, f): fold(arr, 0, fn(acc, x): acc + f(x););

# Creates a dictionary indexed by a key extracted from each element.
def index_by(arr, f):
  fold(arr, dict(), fn(acc, item):
    let key = to_string(f(item))
    | let existing = get(acc, key)
    | let new_value = if (is_none(existing)): item else: existing + item
    | set(acc, key, new_value);
  )
end

# Inspects a value by printing its string representation and returning the value.
def inspect(value) do
  print(to_string(value))
  | value
end

# Left-pads a string to a specified length using a given padding string.
def lpad(s, length, pad_str = " "):
  let pad_needed = length - len(s)
  | if (pad_needed <= 0):
      s
    else: do
        let full_repeats = floor(pad_needed / len(pad_str))
        | let remainder = pad_needed % len(pad_str)
        | let full_pad = do foreach (_, range(0, full_repeats - 1)): pad_str; | join("");
        | let final_pad = full_pad + slice(pad_str, 0, remainder)
        | final_pad + s
      end
end

# Right-pads a string to a specified length using a given padding string.
def rpad(s, length, pad_str = " "):
  let pad_needed = length - len(s)
  | if (pad_needed <= 0):
      s
    else: do
        let full_repeats = floor(pad_needed / len(pad_str))
        | let remainder = pad_needed % len(pad_str)
        | let full_pad = do foreach (_, range(0, full_repeats - 1)): pad_str; | join("");
        | let final_pad = full_pad + slice(pad_str, 0, remainder)
        | s + final_pad
      end
end

# Loads a markdown file from the specified path
def load_markdown(path): read_file(path) | to_markdown();

# Prints the debug information of the given value(s).
def debug(*args):
  if (len(args) == 1):
    stderr(s"DEBUG: ${args[0]}")
  else:
    stderr("DEBUG:\n" + do args | map(fn(arg): s" - ${arg}";) | join("\n");)
end

# Increases the depth (numeric level) of a markdown heading node by one,
# effectively demoting the heading (e.g. h1 -> h2), up to a maximum of 6.
def increase_header_depth(node):
  shift_right(node, 1)
end

# Decreases the depth (numeric level) of a markdown heading node by one,
# effectively promoting the heading (e.g. h2 -> h1), down to a minimum of 1.
def decrease_header_depth(node):
  shift_left(node, 1)
end

# Demotes a markdown heading by increasing its depth (numeric level) by one.
# This is an alias for `increase_header_depth`.
def demote_heading(node):
  increase_header_depth(node)
end

# Promotes a markdown heading by decreasing its depth (numeric level) by one.
# This is an alias for `decrease_header_depth`.
def promote_heading(node):
  decrease_header_depth(node)
end

# Deprecated: use `increase_header_depth` or `demote_heading` instead.
# Kept for backward compatibility; behavior unchanged.
def increase_header_level(node):
  increase_header_depth(node)
end

# Deprecated: use `decrease_header_depth` or `promote_heading` instead.
# Kept for backward compatibility; behavior unchanged.
def decrease_header_level(node):
  decrease_header_depth(node)
end

# Performs a binary search on a sorted array to find the index of the target value.
def bsearch(arr, target):
  if (not(is_array(arr))):
    error("first argument must be an array")
  else: do
      let _bsearch_impl = fn(arr, target, low, high):
              if (low > high):
                -1
              else:
                do
                  let mid = floor((low + high) / 2)
                  | let mid_val = arr[mid]
                  | if (mid_val == target):
                      mid
                    elif (mid_val < target):
                      _bsearch_impl(arr, target, mid + 1, high)
                    else:
                      _bsearch_impl(arr, target, low, mid - 1);
            end
      | _bsearch_impl(arr, target, 0, len(arr) - 1)
    end
end

# Converts a string into a URL-friendly slug by lowercasing, replacing non-alphanumeric characters with hyphens, and trimming hyphens from the ends.
def slugify(s, separator = "-"):
  s
  | downcase()
  | gsub("[^a-z0-9]+", separator)
  | gsub("^-+|-+$", "")
end

# Calculates the p-th percentile of an array of numbers using linear interpolation between closest ranks.
def percentile(arr, p):
  if (not(is_array(arr))):
    error("first argument must be an array")
  elif (is_empty(arr)):
    None
  elif (p < 0 || p > 1):
    error("p must be between 0 and 1")
  else:
    do
      let sorted = sort(arr)
      | let rank = p * (len(sorted) - 1)
      | let lower_index = floor(rank)
      | let upper_index = ceil(rank)
      | let weight = rank - lower_index
      | if (upper_index >= len(sorted)):
          sorted[lower_index]
        else:
          sorted[lower_index] * (1 - weight) + sorted[upper_index] * weight
    end
end

# Splits an array into chunks of a specified size, returning an array of arrays.
def chunks(v, size):
  match (v):
    | :array if (size <= 0): error("size must be a positive integer")
    | :array:
      do
        var result = []
        | var i = 0
        | while (i < len(v)):
            result += [slice(v, i, i + size)]
            | i += size
          end
        | result
      end
    | :string if (size <= 0): error("size must be a positive integer")
    | :string:
      do
        var result = []
        | var i = 0
        | while (i < len(v)):
            result += [slice(v, i, i + size)]
            | i += size
          end
        | result
      end
    | _: error("first argument must be an array or string")
  end
end

# Splits an array into chunks based on the result of applying a function to each element, grouping consecutive elements with the same key together.
def chunk_by(v, f):
  match (v):
    | :array:
      do
        var result = []
        | var current_chunk = []
        | var i = 0
        | while (i < len(v)):
            let item = v[i]
            | let key = f(item)
            | if (is_empty(current_chunk) || key == f(first(current_chunk))):
                current_chunk += [item]
              else:
                do
                  result = result + [current_chunk]
                  | current_chunk = [item]
                end
            | i += 1
          end
        | result += (if (!is_empty(current_chunk)): [current_chunk] else: [])
        | result
      end
    | :string:
      do
        var result = []
        | var current_chunk = ""
        | var i = 0
        | while (i < len(v)):
            let char = v[i]
            | let key = f(char)
            | if (is_empty(current_chunk) || key == f(first(current_chunk))):
                current_chunk += char
              else:
                do
                  result += [current_chunk]
                  | current_chunk = char
                end
            | i += 1
          end
        | result += (if (!is_empty(current_chunk)): [current_chunk] else: [])
        | result
      end
    | _: error("first argument must be an array or string")
  end
end

# Returns a new function that takes the same arguments as the original function but with the first two arguments flipped.
def flip(f, a, b): f(b, a);

