defmodule XlsxReader.PackageLoader do
  @moduledoc false

  # Loads the content of an XLSX file.
  #
  # An XLSX file is ZIP archive containing XML files linked to each other
  # using relationships defined in `_rels/*.xml.rels` files.

  alias XlsxReader.Parsers.{
    RelationshipsParser,
    SharedStringsParser,
    StylesParser,
    Utils,
    WorkbookParser,
    WorksheetParser
  }

  alias XlsxReader.ZipArchive

  @typedoc """
  Source for the XLSX file: file system (`:path`) or in-memory (`:binary`)
  """
  @type source :: :path | :binary

  @typedoc """
  Option to specify the XLSX file source
  """
  @type open_option ::
          {:exclude_hidden_sheets?, boolean()}
          | {:source, source()}
          | {:supported_custom_formats, [{String.t() | Regex.t(), atom()}]}

  @doc """

  Opens an XLSX package.

  It verifies the contents of the archive and preloads the workbook sheet list
  and relationships as well as the shared strings and style information required
  to load the sheet data.

  To load the actual sheet data, see `load_sheet_by_rid/3` and `load_sheet_by_name/3`.

  """
  @spec open(XlsxReader.ZipArchive.zip_handle()) ::
          {:ok, XlsxReader.Package.t()} | XlsxReader.error()
  def open(zip_handle, options \\ []) do
    with :ok <- check_contents(zip_handle),
         {:ok, workbook} <- load_workbook_xml(zip_handle, options),
         {:ok, workbook_rels} <- load_workbook_xml_rels(zip_handle) do
      package =
        %XlsxReader.Package{
          zip_handle: zip_handle,
          workbook: %{workbook | rels: workbook_rels}
        }
        |> load_shared_strings
        |> load_styles(Keyword.get(options, :supported_custom_formats, []))

      {:ok, package}
    end
  end

  @doc """
  Loads a single sheet identified by relationship id (`rId`)

  ## Options

  See `XlsxReader.sheet/2`.

  """
  @spec load_sheet_by_rid(XlsxReader.Package.t(), String.t(), Keyword.t()) ::
          {:ok, XlsxReader.row()} | XlsxReader.error()
  def load_sheet_by_rid(package, rid, options \\ []) do
    case fetch_rel_target(package.workbook.rels, :sheets, rid) do
      {:ok, target} ->
        load_worksheet_xml(package, xl_path(target), options)

      :error ->
        {:error, "sheet relationship not found"}
    end
  end

  @doc """
  Loads a single sheet identified by name

  ## Options

  See `XlsxReader.sheet/2`.

  """
  @spec load_sheet_by_name(XlsxReader.Package.t(), String.t(), Keyword.t()) ::
          {:ok, XlsxReader.row()} | XlsxReader.error()
  def load_sheet_by_name(package, name, options \\ []) do
    case find_sheet_by_name(package, name) do
      %{rid: rid} ->
        load_sheet_by_rid(package, rid, options)

      nil ->
        {:error, "sheet #{inspect(name)} not found"}
    end
  end

  ##

  @workbook_xml "xl/workbook.xml"
  @workbook_xml_rels "xl/_rels/workbook.xml.rels"

  @required_files [
    "[Content_Types].xml",
    @workbook_xml,
    @workbook_xml_rels
  ]

  defp check_contents(zip_handle) do
    with {:ok, files} <- ZipArchive.list(zip_handle) do
      if Enum.all?(@required_files, &Enum.member?(files, &1)),
        do: :ok,
        else: {:error, "invalid xlsx file"}
    end
  end

  defp load_workbook_xml(zip_handle, options) do
    options = Keyword.take(options, [:exclude_hidden_sheets?])

    with {:ok, xml} <- extract_xml(zip_handle, @workbook_xml) do
      WorkbookParser.parse(xml, options)
    end
  end

  defp load_workbook_xml_rels(zip_handle) do
    with {:ok, xml} <- extract_xml(zip_handle, @workbook_xml_rels) do
      RelationshipsParser.parse(xml)
    end
  end

  defp load_shared_strings(package) do
    with {:ok, file} <- single_rel_target(package.workbook.rels.shared_strings),
         {:ok, xml} <- extract_xml(package.zip_handle, file),
         {:ok, shared_strings} <- SharedStringsParser.parse(xml) do
      %{package | workbook: %{package.workbook | shared_strings: shared_strings}}
    else
      {:error, :no_rel_target} ->
        package
    end
  end

  defp load_styles(package, supported_custom_formats) do
    with {:ok, file} <- single_rel_target(package.workbook.rels.styles),
         {:ok, xml} <- extract_xml(package.zip_handle, file),
         {:ok, style_types, custom_formats} <- StylesParser.parse(xml, supported_custom_formats) do
      %{
        package
        | workbook: %{package.workbook | style_types: style_types, custom_formats: custom_formats}
      }
    else
      {:error, :no_rel_target} ->
        package
    end
  end

  defp single_rel_target(rels) do
    case Map.values(rels) do
      [target] ->
        {:ok, xl_path(target)}

      [] ->
        {:error, :no_rel_target}

      targets ->
        {:error, "expected a single rel target, got #{inspect(targets)}"}
    end
  end

  defp load_worksheet_xml(package, file, options) do
    with {:ok, xml} <- extract_xml(package.zip_handle, file) do
      WorksheetParser.parse(xml, package.workbook, options)
    end
  end

  defp extract_xml(zip_handle, file) do
    with {:ok, xml} <- ZipArchive.extract(zip_handle, file) do
      Utils.ensure_utf8(xml)
    end
  end

  defp xl_path(relative_path), do: Path.join("xl", relative_path)

  defp find_sheet_by_name(package, name) do
    Enum.find(package.workbook.sheets, fn %{name: n} -> name == n end)
  end

  defp fetch_rel_target(rels, type, rid) do
    with {:ok, paths} <- Map.fetch(rels, type) do
      Map.fetch(paths, rid)
    end
  end
end
