lib/naiveical/freebusy.ex

defmodule Naiveical.FreeBusy do
  @moduledoc """
  This module provides functions for extracting free/busy time information from iCalendar data.

  It handles timezone conversions, duration calculations, and busy period extraction from VEVENT components.
  """

  require Logger

  @doc """
  Extracts busy periods from an iCalendar event within a given time range.

  Returns a list of busy periods as maps with `:start` and `:end` keys in ISO 8601 basic format.

  ## Parameters

    * `ical_data` - The iCalendar data as a string
    * `time_range` - A map with `:start` and `:end` keys in ISO 8601 basic format (e.g., "20251105T100000Z")

  ## Examples

      iex> ical = "BEGIN:VEVENT\\r\\nDTSTART:20251105T100000Z\\r\\nDTEND:20251105T110000Z\\r\\nEND:VEVENT"
      iex> time_range = %{start: "20251105T000000Z", end: "20251106T000000Z"}
      iex> Naiveical.FreeBusy.extract_busy_period_from_event(ical, time_range)
      [%{start: "20251105T100000Z", end: "20251105T110000Z"}]

  """
  @spec extract_busy_period_from_event(String.t(), %{start: String.t(), end: String.t()}) ::
          [%{start: String.t(), end: String.t()}]
  def extract_busy_period_from_event(ical_data, time_range) when is_binary(ical_data) do
    Logger.debug(fn ->
      """
      Extracting busy period from event:
        iCal preview: #{String.slice(ical_data, 0, 200)}
      """
    end)

    # Extract DTSTART - handle multiple formats:
    # 1. UTC: 20251105T100000Z
    # 2. Local/Timezone: 20251105T090000 (with TZID parameter)
    # 3. Date only: 20251105 (with VALUE=DATE parameter)
    dtstart =
      cond do
        # Try UTC format first (YYYYMMDDTHHMMSSZ)
        match = Regex.run(~r/DTSTART(?:;[^:]*)?:(\d{8}T\d{6}Z)/i, ical_data) ->
          [_, dt] = match
          Logger.debug("Matched UTC format: #{dt}")
          dt

        # Try local time format with TZID (YYYYMMDDTHHMMSS without Z)
        match = Regex.run(~r/DTSTART;[^:]*TZID=([^:;]+)[^:]*:(\d{8}T\d{6})\b/i, ical_data) ->
          [_, tzid, dt] = match
          Logger.debug("Matched format with TZID=#{tzid}: #{dt}")
          # Convert from local timezone to UTC
          convert_to_utc(dt, tzid, ical_data)

        # Try local time format without explicit TZID
        match = Regex.run(~r/DTSTART(?:;[^:]*)?:(\d{8}T\d{6})\b/i, ical_data) ->
          [_, dt] = match
          result = "#{dt}Z"
          Logger.debug("Matched local format without TZID: #{dt} -> #{result} (assuming UTC)")
          result

        # Try date-only format (YYYYMMDD)
        match = Regex.run(~r/DTSTART(?:;VALUE=DATE)?:(\d{8})\b/i, ical_data) ->
          [_, date] = match
          result = "#{date}T000000Z"
          Logger.debug("Matched date-only format: #{date} -> #{result}")
          # All-day event: treat as midnight to midnight UTC
          result

        true ->
          Logger.debug("NO DTSTART MATCH!")
          nil
      end

    # Extract DTEND or calculate from DURATION
    dtend =
      cond do
        # Try DTEND UTC format
        match = Regex.run(~r/DTEND(?:;[^:]*)?:(\d{8}T\d{6}Z)/i, ical_data) ->
          [_, dt] = match
          dt

        # Try DTEND with TZID
        match = Regex.run(~r/DTEND;[^:]*TZID=([^:;]+)[^:]*:(\d{8}T\d{6})\b/i, ical_data) ->
          [_, tzid, dt] = match
          Logger.debug("Matched DTEND with TZID=#{tzid}: #{dt}")
          convert_to_utc(dt, tzid, ical_data)

        # Try DTEND local time format without TZID
        match = Regex.run(~r/DTEND(?:;[^:]*)?:(\d{8}T\d{6})\b/i, ical_data) ->
          [_, dt] = match
          "#{dt}Z"

        # Try DTEND date-only format
        match = Regex.run(~r/DTEND(?:;VALUE=DATE)?:(\d{8})\b/i, ical_data) ->
          [_, date] = match
          "#{date}T000000Z"

        # Try DURATION property
        match = Regex.run(~r/DURATION:(P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?)/i, ical_data) ->
          [_, duration] = match
          calculate_end_from_duration(dtstart, duration)

        true ->
          # Default: use dtstart as fallback (instant event)
          dtstart
      end

    if dtstart && dtend && overlaps_time_range?(dtstart, dtend, time_range) do
      [%{start: dtstart, end: dtend}]
    else
      []
    end
  end

  # ============================================================================
  # Timezone Conversion Functions
  # ============================================================================

  @doc false
  defp convert_to_utc(local_time, tzid, ical_data) do
    with {:ok, naive} <- parse_local_naive_datetime(local_time),
         {:ok, formatted} <- convert_with_timex(naive, tzid) do
      formatted
    else
      _ -> convert_with_offset(local_time, tzid, ical_data)
    end
  end

  @doc false
  defp parse_local_naive_datetime(local_time) do
    with <<year::binary-size(4), month::binary-size(2), day::binary-size(2), "T",
           hour::binary-size(2), minute::binary-size(2), second::binary-size(2)>> <- local_time,
         {y, ""} <- Integer.parse(year),
         {m, ""} <- Integer.parse(month),
         {d, ""} <- Integer.parse(day),
         {h, ""} <- Integer.parse(hour),
         {min, ""} <- Integer.parse(minute),
         {s, ""} <- Integer.parse(second),
         {:ok, naive} <- NaiveDateTime.new(y, m, d, h, min, s) do
      {:ok, naive}
    else
      _ -> :error
    end
  end

  @doc false
  defp convert_with_timex(naive, tzid) do
    try do
      case Timex.to_datetime(naive, tzid) do
        %DateTime{} = dt ->
          dt
          |> DateTime.shift_zone!("Etc/UTC")
          |> DateTime.to_iso8601(:basic)
          |> String.replace(":", "")
          |> then(&{:ok, &1})

        {:error, _} ->
          :error
      end
    rescue
      _ -> :error
    end
  end

  @doc false
  defp convert_with_offset(local_time, tzid, ical_data) do
    <<year::binary-size(4), month::binary-size(2), day::binary-size(2), "T", hour::binary-size(2),
      minute::binary-size(2), second::binary-size(2)>> = local_time

    {standard_offset, daylight_offset, has_dst} = extract_timezone_offsets(tzid, ical_data)
    month_int = String.to_integer(month)

    offset =
      cond do
        has_dst and month_int >= 3 and month_int <= 10 -> daylight_offset
        true -> standard_offset
      end

    case Regex.run(~r/^([+-])(\d{2})(\d{2})$/, offset) do
      [_, sign, hours, minutes] ->
        offset_hours = String.to_integer(hours)
        offset_minutes = String.to_integer(minutes)
        total_minutes = offset_hours * 60 + offset_minutes
        total_minutes = if sign == "-", do: total_minutes, else: -total_minutes

        case DateTime.from_iso8601("#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}Z") do
          {:ok, local_dt, 0} ->
            utc_dt = DateTime.add(local_dt, total_minutes * 60, :second)

            utc_dt
            |> DateTime.to_iso8601(:basic)
            |> String.replace(":", "")

          _ ->
            "#{local_time}Z"
        end

      _ ->
        "#{local_time}Z"
    end
  end

  @doc false
  defp extract_timezone_offsets(tzid, ical_data) do
    # Extract the VTIMEZONE component for this TZID
    vtimezone_pattern = ~r/BEGIN:VTIMEZONE\s+TZID:#{Regex.escape(tzid)}.*?END:VTIMEZONE/is

    case Regex.run(vtimezone_pattern, ical_data) do
      [vtimezone] ->
        # Extract TZOFFSETTO from DAYLIGHT component (preferred)
        daylight_offset =
          case Regex.run(~r/BEGIN:DAYLIGHT.*?TZOFFSETTO:([+-]\d{4}).*?END:DAYLIGHT/is, vtimezone) do
            [_, offset] -> offset
            _ -> "+0000"
          end

        # Extract TZOFFSETTO from STANDARD component
        standard_offset =
          case Regex.run(~r/BEGIN:STANDARD.*?TZOFFSETTO:([+-]\d{4}).*?END:STANDARD/is, vtimezone) do
            [_, offset] -> offset
            # Fall back to daylight if no standard
            _ -> daylight_offset
          end

        has_dst = daylight_offset != standard_offset
        {standard_offset, daylight_offset, has_dst}

      _ ->
        Logger.debug("Could not find VTIMEZONE for TZID: #{tzid}")
        {"+0000", "+0000", false}
    end
  end

  # ============================================================================
  # Duration and Time Calculation
  # ============================================================================

  @doc false
  defp calculate_end_from_duration(dtstart, duration)
       when is_binary(dtstart) and is_binary(duration) do
    # Parse ISO 8601 duration (simplified)
    # Format: P[nD][T[nH][nM][nS]] or PnW
    cond do
      # P1D - 1 day
      match = Regex.run(~r/P(\d+)D/i, duration) ->
        [_, days] = match
        add_days_to_timestamp(dtstart, String.to_integer(days))

      # PT1H - 1 hour
      match = Regex.run(~r/PT(\d+)H/i, duration) ->
        [_, hours] = match
        add_hours_to_timestamp(dtstart, String.to_integer(hours))

      # Just use dtstart as fallback
      true ->
        dtstart
    end
  end

  @doc false
  defp calculate_end_from_duration(dtstart, _), do: dtstart

  @doc false
  defp add_days_to_timestamp(timestamp, days) do
    # Parse: 20251105T000000Z
    <<year::binary-size(4), month::binary-size(2), day::binary-size(2), rest::binary>> =
      timestamp

    date = Date.from_iso8601!("#{year}-#{month}-#{day}")
    new_date = Date.add(date, days)
    "#{Date.to_iso8601(new_date) |> String.replace("-", "")}#{String.slice(rest, 0..-1//1)}"
  end

  @doc false
  defp add_hours_to_timestamp(timestamp, hours) do
    # Parse: 20251105T090000Z -> 2025-11-05T09:00:00Z
    <<year::binary-size(4), month::binary-size(2), day::binary-size(2), "T", hour::binary-size(2),
      minute::binary-size(2), second::binary-size(2), "Z">> = timestamp

    {:ok, dt, 0} =
      DateTime.from_iso8601("#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}Z")

    new_dt = DateTime.add(dt, hours * 3600, :second)

    new_dt
    |> DateTime.to_iso8601(:basic)
    |> String.replace("-", "")
    |> String.replace(":", "")
  end

  @doc false
  defp overlaps_time_range?(event_start, event_end, %{start: range_start, end: range_end}) do
    # Simple string comparison works for ISO 8601 dates
    event_start < range_end && event_end > range_start
  end
end