lib/hl7.ex

defmodule HL7 do
  @moduledoc """

  Struct and functions for parsing and manipulating HL7 messages.

  This library specifically handles version 2.x of HL7 as it is by far the most prevalent format in production.

  Check out [HL7 on Wikipedia](https://en.wikipedia.org/wiki/Health_Level_7) for a decent overview of the format.

  Since HL7 messages often lack critical contextual metadata, this struct also contains a `tags` field for metadata support.

  Use `new/2` or `new!/2` to convert HL7 text to a parsed HL7 struct.
  This struct supports the `String.Chars` protocol such that `to_string/1` can be used to format the HL7 message text.

  To see the parsed representation, call `get_segments/1`.

  To query or update HL7 data, use the `sigil_p/2` macro to provide an `HL7.Path` with compile-time guarantees.
  For dynamic path access, use `HL7.Path.new/1` to construct paths on the fly.
  Note that HL7 path formats have been designed to reflect common industry usage.

  The `get/2`, `put/3`, and `update/4` functions are designed to query and manipulate HL7 data as an HL7 struct (containing a message),
  a list of segments, a single segment, a list of repetitions, or a single repetition. These should handle data as
  nested lists (automatically converted to 1-indexed maps), nested sparse maps (1-indexed), simple strings, or mixes of each.

  Use `set_segments/2` to fully replace the content an HL7 message.

  > ### Migrating from HL7.Message, HL7.Segment and HL7.Query {: .tip}
  > To migrate from the deprecated `HL7.Message` struct, use `HL7.new!/2` and `HL7.Message.new/2` to transform from one
  > struct to the other while preserving associated metadata tags.
  >
  > You can use `chunk_by_lead_segment/3` to generate segment groups to update code that relies on `HL7.Query` groupings.
  >
  > Any operations to otherwise query or modify HL7 data should be possible using
  > the `get/2`, `put/3`, `update!/3` and `update/4` functions.
  >
  > If you encounter other issues with feature parity, please open an issue!

  > ### String.Chars Protocol {: .tip}
  > You can use the `to_string()` implementation of the `String.Chars` protocol to quickly render HL7 structs as text.

  """

  defstruct segments: [], tags: %{}

  @buffer_size 32768
  @type file_type_hl7 :: :mllp | :line | nil

  @type hl7_map_data() :: %{optional(non_neg_integer) => hl7_map_data() | String.t()}
  @type hl7_list_data() :: String.t() | [hl7_list_data()]

  @type segment() :: %{
          0 => String.t(),
          optional(pos_integer) => hl7_map_data() | String.t()
        }

  @type t() :: %__MODULE__{tags: map(), segments: [segment()]}

  @type parsed_hl7_segments :: t() | [segment()]
  @type parsed_hl7 :: t() | segment() | [segment()] | hl7_map_data() | String.t()

  alias HL7.Path

  @doc ~S"""
  The `~p` sigil encodes an HL7 path into a struct at compile-time to guarantee correctness and speed.
  It is designed to work with data returned by `HL7.new!/1`, providing a standard way to get and update
  HL7 message content.

  > ### Importing Just the Sigil {: .tip}
  > Use `import HL7, only: :sigils` to access the `~p` sigil without importing the other `HL7` functions.

  The full path structure in HL7 is expressed as:

  `~p"SEGMENT_NAME[SEGMENT_NUMBER]-FIELD[REPETITION].COMPONENT.SUBCOMPONENT`

  A trailing exclamation mark can be used in the path to the return only the leftmost text at the given level.

  Position Name | Valid Values
  ------------ | -------------
  `SEGMENT_NAME` | 3 character string
  `SEGMENT_NUMBER` | Positive integer in square brackets, defaults to 1. All segments of `SEGMENT_NAME` can be accessed with `[*]`
  `FIELD` | Positive integer
  `REPETITION` | Positive integer in square brackets, defaults to 1. All repetitions of the `FIELD` can be accessed with `[*]`
  `COMPONENT` | Positive integer
  `SUBCOMPONENT` | Positive integer

  > ### 1-based Indexes {: .warning}
  > To match industry expectations, HL7 uses 1-based indexes.
  > As noted above, it also includes defaults whereby all paths refer to the 1st segment and/or 1st repetition
  > of any query unless explicitly specified.

  Example paths:

     HL7 Path | Description
  `~p"OBX"` | The 1st OBX segment in its entirety, the same as `~p"OBX[1]"`.
  `~p"OBX-5"` | The 1st repetition of the 5th field of the 1st OBX segment.
  `~p"OBX[1]-5[1]"` | Same as above. The numbers in brackets specify the default repetition and segment number values.
  `~p"OBX-5[*]"` | Every repetition of the 5th field of the 1st OBX segment, returning a list of results.
  `~p"OBX[2]-5"` | The 1st repetition of the 5th field of the 2nd OBX segment.
  `~p"OBX[*]-5"` | The 1st repetition of the 5th field of every OBX segment, returning a list of results.
  `~p"OBX[*]-5[*]"` | Every repetition of the 5th field of every OBX segment, returning a nested list of results.
  `~p"OBX-5.2"` | The 2nd component of the 1st repetition of the 5th field of the 1st OBX segment.
  `~p"OBX-5.2.3"` | The 3rd subcomponent of the 2nd component of the 1st repetition of the 5th field of the 1st OBX segment.
  `~p"OBX[*]-5.2.3"` | Same as above, but returned as a list with a value for each OBX segment.
  `~p"OBX[*]-5[*].2.3"` | Same as above, but now a nested list of results for each repetition within each segment.
  `~p"OBX-5!"` | The `!` will take the first text (leftmost value) at whatever level is specified. Thus, `p"OBX-5!"` is equivalent to `p"OBX-5.1.1"`.
  `~p"5"` | The fifth field of a segment (parsed as an ordinal map starting with 0)
  `~p".2"` | The second component of a repetition (parsed as an ordinal map starting with 1)

  Note that repetitions are uncommon in HL7 and the default of a 1st repetition is often just assumed.
  `~p"PID-3"` is equivalent to `~p"PID-3[1]"` and is the most standard representation.

  All repetitions can be found using a repetition wildcard: `~p"PID-11[*]"`. A list of lists can
  be produced by selecting multiple segments and multiple repetitions with `~p"PID[*]-11[*]"`.

  Components and subcomponents can also be accessed with the path structures.
  `p"OBX-2.3.1"` would return the 1st subcomponent of the 3rd component of the 2nd field of the 1st OBX.

  If dealing with segments or repetitions extracted from parsed HL7, you can use partial paths that
  lack the segment name and/or field like `~p"5"` for the fifth field of a segment or `~p".3"` for the 3rd
  component of a repetition.

  Additionally, if a path might have additional data such that a string might be found at either
  `~p"OBX-2"` or `~p"OBX-2.1"` or even `~p"OBX-2.1.1"`, there is truncation character (`!`) that
  will return the first element found in the HL7 text at the target specificity. Thus, `~p"OBX[*]-2!"`
  would get the 1st piece of data in the 2nd field of every OBX whether it is a string or nested map.

  > ### Nil vs Empty String {: .tip}
  > Paths that query beyond the content of an HL7 document, e.g. asking for the 10th field of a segment with five fields,
  > will return `nil` as opposed to an empty string to indicate that the data does not exist. Adding the
  > trailing `!` to an `HL7.Path` will force all return values to be simple strings in cases where `nil` is not desired.
  > Note that it also returns the leftmost text at the path level, discarding extra data as noted above.

  ## Examples

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7() |> HL7.new!() |> HL7.get(~p"OBX-5")
      "1.80"

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"OBX-3")
      %{2 => "Body Height"}

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"PID-11!")
      "260 GOODWIN CREST DRIVE"

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"OBX[*]-5")
      ["1.80", "79"]

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"OBX[*]-2!")
      ["N", "NM"]

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"PID-11[*].5")
      ["35209", "35200"]

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"PID[*]-11[*].5")
      [["35209", "35200"]]

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"PID-11[2].1")
      "NICKELL’S PICKLES"

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> get(~p"PID-11[*]")
      ...> |> List.last()
      ...> |> get(~p".1")
      "NICKELL’S PICKLES"
  """
  defmacro sigil_p({:<<>>, _, [path]}, _modifiers) do
    path
    |> HL7.Path.new()
    |> Macro.escape()
  end

  @doc ~S"""
  Creates an HL7 struct from valid HL7 data (accepting text, lists or the deprecated `HL7.Message` struct).
  Raises with a `RuntimeError` if the data is not valid HL7.

  ## Examples

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!()
      #HL7<with 8 segments>

  """
  @spec new!(list() | String.t() | HL7.Message.t(), Keyword.t()) :: t()
  def new!(message_content, options \\ []) do
    do_new!(message_content, apply_default_options(message_content, options))
  end

  @spec do_new!(list() | String.t() | HL7.Message.t(), Keyword.t()) :: t()
  defp do_new!(segments, options) when is_list(segments) do
    segments =
      Enum.map(
        segments,
        fn
          segment when is_list(segment) -> to_map(%{}, 0, segment)
          %{0 => _} = segment -> segment
        end
      )

    %__MODULE__{segments: segments, tags: options[:tags] || %{}}
  end

  defp do_new!(text, options) when is_binary(text) do
    text |> HL7.Message.to_list() |> new!(options)
  end

  defp do_new!(%HL7.Message{} = message, options) do
    message |> HL7.Message.to_list() |> new!(options)
  end

  @doc ~S"""
  Creates an HL7 struct from valid HL7 data (accepting text, lists or the deprecated `HL7.Message` struct).
  Returns `{:ok, HL7.t()}` if successful, `{:error, HL7.InvalidMessage.t()}` otherwise.
  """
  @spec new(String.t(), Keyword.t()) :: {:ok, t()} | {:error, HL7.InvalidMessage.t()}
  def new(text, options \\ []) when is_binary(text) do
    case HL7.Message.new(text, Keyword.take(options, [:copy, :validate_string]) |> Map.new()) do
      %HL7.Message{} = message ->
        {:ok, HL7.new!(message, options)}

      invalid_message ->
        {:error, invalid_message}
    end
  end

  @doc ~S"""
  Puts data within an `HL7` struct, parsed segments or repetitions
  using an `HL7.Path` struct (see `sigil_p/2`).

  ## Examples

  Put field data as a string.

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> put(~p"PID-8", "F")
      ...> |> get(~p"PID-8")
      "F"


  Put field data as a string overwriting all repetitions.

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> put(~p"PID-11[*]", "SOME_ID")
      ...> |> get(~p"PID-11[*]")
      ["SOME_ID"]

  Put field data into a single repetition.

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> put(~p"PID-3[2]", ["a", "b", "c"])
      ...> |> get(~p"PID-3[2].3")
      "c"

  Put component data across multiple repetitions.

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> put(~p"PID-11[*].3", "SOME_PLACE")
      ...> |> get(~p"PID-11[*].3")
      ["SOME_PLACE", "SOME_PLACE"]

  Put data in a segment using just the path to a field.

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> get(~p"PID")
      ...> |> put(~p"3", "SOME_ID")
      ...> |> get(~p"3")
      "SOME_ID"

  Put data across multiple segments

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> put(~p"OBX[*]-5", "REDACTED")
      ...> |> get(~p"OBX[*]-5")
      ["REDACTED", "REDACTED"]

  """
  @spec put(parsed_hl7(), Path.t(), String.t() | nil | hl7_map_data()) :: parsed_hl7()
  def put(%HL7{segments: segments} = hl7, %Path{} = path, value) do
    %HL7{hl7 | segments: put(segments, path, value)}
  end

  def put(segment_data, %Path{} = path, value) do
    segment_data |> do_put(path, value)
  end

  @doc ~S"""
  Updates data within an `HL7` struct, parsed segments, or repetitions
  using an `HL7.Path` struct (see `sigil_p/2`).
  """
  @spec update(parsed_hl7(), Path.t(), String.t() | nil | hl7_map_data(), (hl7_map_data() ->
                                                                             hl7_map_data())) ::
          parsed_hl7()
  def update(%HL7{segments: segments} = hl7, %Path{} = path, default, fun) do
    %HL7{hl7 | segments: update(segments, path, default, fun)}
  end

  def update(segment_data, path, default, fun) do
    segment_data |> do_put(path, {default, fun})
  end

  @doc ~S"""
  Updates data within an `HL7` struct, parsed segments, or repetitions
  using an `HL7.Path` struct (see `sigil_p/2`). Raises a `RuntimeError`
  if the path is not present in the source data.
  """
  @spec update!(parsed_hl7(), Path.t(), (hl7_map_data() -> hl7_map_data())) :: parsed_hl7()
  def update!(%HL7{segments: segments} = hl7, %Path{} = path, fun) do
    %HL7{hl7 | segments: update!(segments, path, fun)}
  end

  def update!(segment_data, path, fun) do
    segment_data |> do_put(path, {fun})
  end

  @doc ~S"""
  Returns a list of sparse maps (with ordinal indices and strings) representing
  the parsed segments of an HL7 message stored in an `HL7` struct.
  """
  def get_segments(%HL7{segments: segments}) do
    segments
  end

  @doc ~S"""
  Sets a list of sparse maps (with ordinal indices and strings) representing
  the parsed segments of an HL7 message to define the content of an `HL7` struct.
  """
  def set_segments(%HL7{} = hl7, segments) do
    %HL7{hl7 | segments: segments}
  end

  @doc ~S"""
  Returns a map of custom metadata associated with the `HL7` struct.
  """
  def get_tags(%HL7{tags: tags}) do
    tags
  end

  @doc ~S"""
  Sets a map of custom metadata associated with the `HL7` struct.
  """
  def set_tags(%HL7{} = hl7, tags) when is_map(tags) do
    %HL7{hl7 | tags: tags}
  end

  @doc ~S"""
  Creates a minimal map representing an empty HL7 segment that can
  be modified via this module.
  """
  @spec new_segment(String.t()) :: segment()
  def new_segment(<<_::binary-size(3)>> = segment_name) do
    %{0 => segment_name}
  end

  @doc ~S"""
  Converts an `HL7` struct into its string representation.
  Convenience options will be added in the future.
  """
  def format(%HL7{} = hl7, _options \\ []) do
    to_string(hl7)
  end

  @doc ~S"""
  Labels source data (a segment map or list of segment maps) by using `HL7.Path`s in a labeled
  output template.

  One-arity functions placed as output template values will be called with the source data.

  ## Examples

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.label(%{mrn: ~p"PID-3!", name: ~p"PID-5.2"})
      %{mrn: "56782445", name: "BARRY"}

  """
  @spec label(t() | segment(), map()) :: map()
  def label(segment_or_segments, template_map) do
    for {key, output_param} <- template_map, into: Map.new() do
      {key, do_label(segment_or_segments, output_param) |> nil_for_empty()}
    end
  end

  @doc ~S"""
  Finds data within an `HL7` struct, parsed segments or repetitions
  using an `HL7.Path` struct (see `sigil_p/2`).

  Selecting data across multiple segments or repetitions with the wildcard `[*]` pattern
  will return a list of results.

  Repetition data can be searched using a partial path containing ony the component and/or
  subcomponent with a preceding period, e.g. `~p".2.3"`.

  See the `sigil_p/2` docs and tests for more examples!

  ## Examples

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.get(~p"OBX-5")
      "1.80"

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.get(~p"OBX[*]-5")
      ["1.80", "79"]

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.get(~p"OBX[*]-2!")
      ["N", "NM"]

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.get(~p"PID-11[*].5")
      ["35209", "35200"]

      iex> import HL7, only: :sigils
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> HL7.new!()
      ...> |> HL7.get(~p"PID-11[2].1")
      "NICKELL’S PICKLES"

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7()
      ...> |> new!()
      ...> |> get(~p"PID")
      ...> |> get(~p"11[*]")
      ...> |> get(~p".1")
      ["260 GOODWIN CREST DRIVE", "NICKELL’S PICKLES"]

      iex> import HL7
      iex> HL7.Examples.wikipedia_sample_hl7() |> new!() |> get(~p"OBX[*]")
      [
        %{
          0 => "OBX",
          1 => "1",
          2 => %{1 => %{1 => "N", 2 => %{1 => "K", 2 => "M"}}},
          3 => %{1 => %{2 => "Body Height"}},
          5 => "1.80",
          6 => %{1 => %{1 => "m", 2 => "Meter", 3 => "ISO+"}},
          11 => "F"
        },
        %{
          0 => "OBX",
          1 => "2",
          2 => "NM",
          3 => %{1 => %{2 => "Body Weight"}},
          5 => "79",
          6 => %{1 => %{1 => "kg", 2 => "Kilogram", 3 => "ISO+"}},
          11 => "F"
        }
      ]
  """
  @spec get(parsed_hl7(), Path.t()) :: hl7_map_data() | [hl7_map_data()] | String.t() | nil
  def get(data, path) do
    data
    |> do_get(path)
    |> maybe_truncate(path)
  end

  @doc """
  Creates a list of lists in which the specified `segment_name` is used to get the first segment map
  of each list. This function helps to do things like grouping `OBX` segments with their parent `OBR` segment.

  Options:

  `keep_prefix_segments: true` will leave the first set of non-matching segments in the return value.
  """
  @spec chunk_by_lead_segment(t() | [segment()], String.t(), Keyword.t()) :: [[segment()]]
  def chunk_by_lead_segment(segments, segment_name, options \\ []) do
    do_chunk_by_segment(segments, segment_name)
    |> maybe_keep_prefix_segments(!!options[:keep_prefix_segments])
  end

  @doc """
  Converts an HL7 message struct, segments or segment into a nested list of strings.
  """
  @spec to_list(t() | hl7_map_data()) :: hl7_list_data()
  def to_list(%HL7{segments: segments}) do
    to_list(segments)
  end

  def to_list(map_data) when is_list(map_data) do
    Enum.map(map_data, fn segment_map -> do_to_list(segment_map) end)
  end

  def to_list(map_data) when is_map(map_data) do
    do_to_list(map_data)
  end

  @doc """
  Opens an HL7 file stream of either `:mllp` or `:line`. If the file_type is not specified
  it will be inferred from the first three characters of the file contents.
  """
  @spec open_hl7_file_stream(String.t(), file_type_hl7()) :: Enumerable.t()

  def open_hl7_file_stream(file_path, file_type \\ nil) when is_atom(file_type) do
    found_file_type =
      file_type
      |> case do
        nil ->
          infer_file_type(file_path)

        _ ->
          if File.exists?(file_path) do
            {:ok, file_type}
          else
            {:error, :enoent}
          end
      end

    found_file_type
    |> case do
      {:ok, :line} ->
        file_path
        |> File.stream!(:line)

      {:ok, :mllp} ->
        file_path
        |> File.stream!(@buffer_size)
        |> HL7.MLLPStream.raw_to_messages()

      {:error, reason} ->
        {:error, reason}
    end
  end

  # internals

  defp get_max_index(data) when is_map(data) do
    data |> Map.keys() |> Enum.max() |> max(0)
  end

  defp to_segment_map(value) when is_list(value), do: to_map(%{}, 0, value)

  defp to_map(value) when is_binary(value) do
    value
  end

  defp to_map(value) when is_list(value) do
    to_map(%{}, 1, value)
  end

  defp to_map(value) when is_map(value) do
    value
  end

  defp to_map(acc, index, [h]) do
    Map.put(acc, index, to_map(h))
  end

  defp to_map(acc, index, [h | t]) do
    case to_map(h) do
      "" -> acc
      v -> Map.put(acc, index, v)
    end
    |> to_map(index + 1, t)
  end

  defp do_to_list(hl7_map_data) when is_binary(hl7_map_data) do
    hl7_map_data
  end

  defp do_to_list(hl7_map_data) do
    do_to_list([], hl7_map_data, get_max_index(hl7_map_data))
  end

  defp do_to_list(acc, %{0 => _} = hl7_map_data, index) when index > -1 do
    chunk = hl7_map_data[index] || ""
    do_to_list([do_to_list(chunk) | acc], hl7_map_data, index - 1)
  end

  defp do_to_list(acc, hl7_map_data, index) when index > 0 do
    chunk = hl7_map_data[index] || ""
    do_to_list([do_to_list(chunk) | acc], hl7_map_data, index - 1)
  end

  defp do_to_list(acc, _hl7_map_data, _index) do
    acc
  end

  defp do_chunk_by_segment(%HL7{segments: segment_list}, segment_name) do
    do_chunk_by_segment([], [], segment_list, segment_name)
  end

  defp do_chunk_by_segment(segment_list, segment_name) when is_list(segment_list) do
    do_chunk_by_segment([], [], segment_list, segment_name)
  end

  defp do_chunk_by_segment([], [], [%{0 => segment_name} = segment | rest], segment_name) do
    do_chunk_by_segment([], [segment], rest, segment_name)
  end

  defp do_chunk_by_segment(
         acc,
         chunk_acc,
         [%{0 => segment_name} = segment | rest],
         segment_name
       ) do
    do_chunk_by_segment([chunk_acc | acc], [segment], rest, segment_name)
  end

  defp do_chunk_by_segment(acc, chunk_acc, [segment | rest], segment_name) do
    do_chunk_by_segment(acc, [segment | chunk_acc], rest, segment_name)
  end

  defp do_chunk_by_segment(acc, chunk_acc, [], _segment_name) do
    [chunk_acc | acc]
    |> Enum.map(&Enum.reverse/1)
    |> Enum.reverse()
  end

  defp get_value_at_index(nil, _) do
    nil
  end

  defp get_value_at_index(segment_data, 1) when is_binary(segment_data) do
    segment_data
  end

  defp get_value_at_index(segment_data, nil) when is_binary(segment_data) do
    segment_data
  end

  defp get_value_at_index(segment_data, _) when is_binary(segment_data) do
    nil
  end

  defp get_value_at_index(segment_data, i) do
    max_index = get_max_index(segment_data)
    if i > max_index, do: nil, else: Map.get(segment_data, i, "")
  end

  defp ensure_map(data) when is_binary(data) or is_nil(data) do
    %{1 => data || ""}
  end

  defp ensure_map(data) when is_map(data) do
    data
  end

  defp maybe_truncate(nil, %Path{truncate: true}) do
    nil
  end

  defp maybe_truncate(segment_data, %Path{truncate: true}) do
    truncate(segment_data)
  end

  defp maybe_truncate(segment_data, %Path{truncate: false}) do
    segment_data
  end

  defp truncate(segment_data) when is_binary(segment_data) or is_nil(segment_data) do
    segment_data
  end

  defp truncate(segment_data) when is_map(segment_data) do
    get_value_at_index(segment_data, 1)
    |> truncate()
  end

  defp truncate(segment_data) when is_list(segment_data) do
    Enum.map(segment_data, &truncate/1)
  end

  defp resolve_placement_value(_field_data = nil, {default, _fun}, path) do
    default |> format_final_value(path)
  end

  defp resolve_placement_value(_field_data = "", {default, _fun}, path) do
    default |> format_final_value(path)
  end

  defp resolve_placement_value(field_data, {_default, fun}, path) do
    fun.(field_data) |> format_final_value(path)
  end

  defp resolve_placement_value(_field_data = nil, {_fun}, path) do
    raise KeyError, message: "HL7.Path #{inspect(path)} could not be found"
  end

  defp resolve_placement_value(field_data, {fun}, path) do
    fun.(field_data) |> format_final_value(path)
  end

  defp resolve_placement_value(_field_data, value, path) do
    value |> format_final_value(path)
  end

  defp format_final_value(nil, _) do
    ""
  end

  defp format_final_value([], _) do
    ""
  end

  defp format_final_value(value, _) when is_binary(value) do
    value
  end

  defp format_final_value(value, %Path{field: nil}) when is_list(value) do
    to_segment_map(value)
  end

  defp format_final_value(value, _) when is_list(value) do
    to_map(value)
  end

  defp format_final_value(value, _) when is_map(value), do: value

  defp do_get(%HL7{} = hl7, %Path{} = path) do
    do_get(hl7.segments, path)
  end

  defp do_get([], %Path{} = _path) do
    []
  end

  # from multiple segments, return a result for each segment in a list
  defp do_get([%{0 => _} | _] = segments, %Path{segment: nil} = path) do
    Enum.map(segments, &do_get_in_segment(&1, path))
  end

  # from multiple segments, filter by name and return a result for each segment in a list
  defp do_get([%{0 => _} | _] = segments, %Path{segment: name, segment_number: "*"} = path) do
    segments
    |> Stream.filter(&(&1[0] == name))
    |> Enum.map(&do_get_in_segment(&1, path))
  end

  # from multiple segments, return a result for a specific segment
  defp do_get([%{0 => _} | _] = segments, %Path{segment: name, segment_number: n} = path) do
    segments
    |> Stream.filter(&(&1[0] == name))
    |> Stream.drop(n - 1)
    |> Enum.at(0)
    |> do_get_in_segment(path)
  end

  defp do_get(nil = _segment_data, path) do
    nil |> maybe_truncate(path)
  end

  # for a single segment
  defp do_get(%{0 => _} = segment_data, path) do
    do_get_in_segment(segment_data, path)
  end

  # for a list of repetitions (generally a repeating field), return a result for each
  defp do_get(repetition_list, %{field: nil, repetition: nil} = path)
       when is_list(repetition_list) do
    Enum.map(repetition_list, &do_get_in_repetition(&1, path))
  end

  # for a single repetition in a field
  defp do_get(repetition_data, %{field: nil, repetition: nil} = path) do
    do_get_in_repetition(repetition_data, path)
  end

  defp do_get(_repetition_data, path) do
    raise RuntimeError,
          "HL7.Path #{inspect(path)} could not work with the given data."
  end

  defp do_get_in_segment(segment_data, %{field: nil, component: nil} = path) do
    segment_data |> maybe_truncate(path)
  end

  defp do_get_in_segment(_segment_data, %{field: nil} = path) do
    raise RuntimeError,
          "HL7.Path #{inspect(path)} requires field number."
  end

  defp do_get_in_segment(segment_data, %{field: f} = path) do
    segment_data
    |> get_value_at_index(f)
    |> do_get_in_field(path)
  end

  defp do_get_in_field(field_data, %{repetition: "*"} = path) when is_map(field_data) do
    1..get_max_index(field_data)
    |> Enum.map(fn r ->
      field_data
      |> get_value_at_index(r)
      |> do_get_in_repetition(path)
    end)
  end

  defp do_get_in_field(field_data, %{repetition: "*"} = path) do
    [do_get_in_repetition(field_data, path) |> maybe_truncate(path)]
  end

  defp do_get_in_field(field_data, %{repetition: r} = path) do
    field_data
    |> get_value_at_index(r)
    |> do_get_in_repetition(path)
  end

  defp do_get_in_repetition(repetition_data, %{component: nil} = path) do
    repetition_data |> maybe_truncate(path)
  end

  defp do_get_in_repetition(repetition_data, %{component: c} = path) do
    repetition_data
    |> get_value_at_index(c)
    |> do_get_in_component(path)
  end

  defp do_get_in_component(component_data, %{subcomponent: nil} = path) do
    component_data |> maybe_truncate(path)
  end

  defp do_get_in_component(component_data, %{subcomponent: s} = path) do
    component_data
    |> get_value_at_index(s)
    |> maybe_truncate(path)
  end

  # put across multiple segments
  defp do_put([%{0 => _} | _] = segments, %Path{segment: name, segment_number: "*"} = path, value) do
    segments
    |> Enum.map(fn segment ->
      if segment[0] == name, do: do_put_in_segment(segment, value, path), else: segment
    end)
  end

  # put within a specific segment
  defp do_put([%{0 => _} | _] = segments, %Path{segment: name, segment_number: n} = path, value) do
    segments
    |> Stream.with_index()
    |> Stream.filter(&(elem(&1, 0)[0] == name))
    |> Stream.drop(n - 1)
    |> Enum.at(0)
    |> case do
      {segment_data, index} ->
        List.replace_at(segments, index, do_put_in_segment(segment_data, value, path))

      nil ->
        raise RuntimeError, "HL7.Path #{inspect(path)} has no matching segment."
    end
  end

  defp do_put(%{0 => _} = segment_data, path, value) do
    do_put_in_segment(segment_data, value, path)
  end

  defp do_put(repetition_list, %{field: nil, repetition: nil} = path, value)
       when is_list(repetition_list) do
    Enum.map(repetition_list, &do_put_in_repetition(&1, value, path))
  end

  defp do_put(repetition_data, %{field: nil, repetition: nil} = path, value) do
    do_put_in_repetition(repetition_data, value, path)
  end

  defp do_put(_repetition_data, path, _value) do
    raise RuntimeError,
          "HL7.Path #{inspect(path)} to update repetition data should be begin with `.`"
  end

  defp do_put_in_segment(segment_data, value, %{field: nil} = path) do
    resolve_placement_value(segment_data, value, path)
  end

  defp do_put_in_segment(segment_data, value, %{field: f} = path) do
    Map.put(segment_data, f, do_put_in_field(segment_data[f], value, path))
  end

  # data can contain either simple strings or maps by ordinal index
  # if a map contains only one string, it is reduced here to the string value alone

  defp simplify_string_fields(value) do
    do_simplify_string_fields(value, value)
  end

  defp do_simplify_string_fields(original, %{1 => value} = map) when map_size(map) == 1 do
    do_simplify_string_fields(original, value)
  end

  defp do_simplify_string_fields(_original, value) when is_binary(value) do
    value
  end

  defp do_simplify_string_fields(original, _value) do
    original
  end

  defp do_put_in_field(field_data, value, %{repetition: "*", component: nil} = path) do
    # update fields as a list of repetitions
    case field_data do
      [] -> [""]
      d when is_map(d) -> Map.values(d)
      d when is_binary(d) -> [d]
      d -> d
    end
    |> resolve_placement_value(value, path)
    |> simplify_string_fields()
  end

  defp do_put_in_field(field_data, value, %{repetition: "*"} = path) do
    field_map = ensure_map(field_data)

    1..get_max_index(field_map)
    |> Map.new(fn i ->
      {i, do_put_in_repetition(field_map[i], value, path)}
    end)
    |> simplify_string_fields()
  end

  defp do_put_in_field(field_data, value, %{repetition: r} = path) do
    field_map = ensure_map(field_data)

    Map.put(field_map, r, do_put_in_repetition(field_map[r], value, path))
    |> simplify_string_fields()
  end

  defp do_put_in_repetition(repetition_data, value, %{component: nil} = path) do
    resolve_placement_value(repetition_data, value, path)
    |> simplify_string_fields()
  end

  defp do_put_in_repetition(repetition_data, value, %{component: c} = path) do
    repetition_map = ensure_map(repetition_data)
    Map.put(repetition_map, c, do_put_in_component(repetition_map[c], value, path))
  end

  defp do_put_in_component(component_data, value, %{subcomponent: nil} = path) do
    resolve_placement_value(component_data, value, path)
    |> simplify_string_fields()
  end

  defp do_put_in_component(subcomponent_data, value, %{subcomponent: s} = path) do
    subcomponent_map = ensure_map(subcomponent_data)
    Map.put(subcomponent_map, s, resolve_placement_value(subcomponent_map[s], value, path))
  end

  defp do_label(segment_data, %Path{} = output_param) do
    get(segment_data, output_param)
  end

  defp do_label(segment_data, output_param) when is_map(output_param) do
    label(segment_data, output_param)
  end

  defp do_label(segment_data, list) when is_list(list) do
    Enum.map(list, &do_label(segment_data, &1))
  end

  defp do_label(segment_data, function) when is_function(function, 1) do
    function.(segment_data)
  end

  defp do_label(_segment_data, value) do
    value |> nil_for_empty()
  end

  defp maybe_keep_prefix_segments(segment_chunks, true) do
    segment_chunks
  end

  defp maybe_keep_prefix_segments([_prefix_segments | segment_chunks], false) do
    segment_chunks
  end

  defp nil_for_empty(""), do: nil
  defp nil_for_empty(value), do: value

  @spec apply_default_options(list() | String.t() | HL7.Message.t(), Keyword.t()) :: Keyword.t()
  defp apply_default_options(%HL7.Message{tag: tags} = _message_content, options) do
    [tags: tags, copy: options[:copy]]
  end

  defp apply_default_options(_message_content, options) do
    [tags: options[:tags] || %{}, copy: options[:copy]]
  end

  @spec infer_file_type(String.t()) :: {:ok, :line} | {:ok, :mllp} | {:error, atom()}
  defp infer_file_type(file_path) do
    File.open(file_path, [:read])
    |> case do
      {:ok, file_ref} ->
        first_three = IO.binread(file_ref, 3)
        _ = File.close(file_ref)

        case first_three do
          <<"MSH">> ->
            {:ok, :line}

          <<0x0B, "M", "S">> ->
            {:ok, :mllp}

          _ ->
            {:error, :unrecognized_file_type}
        end

      error ->
        error
    end
  end
end

defimpl Inspect, for: HL7 do
  def inspect(%HL7{segments: segments} = _hl7, _opts) do
    case Enum.count(segments) do
      1 -> "#HL7<with 1 segment>"
      c -> "#HL7<with #{c} segments>"
    end
  end
end

defimpl String.Chars, for: HL7 do
  @spec to_string(HL7.t()) :: String.t()
  def to_string(%HL7{} = hl7) do
    hl7 |> HL7.to_list() |> HL7.Message.raw() |> Map.get(:raw)
  end
end