# frozen_string_literal: true

# Introvert is the internal handler for the friends script. It is designed to be
# able to be used directly within another Ruby program, without needing to call
# the command-line script explicitly.

require "set"
require "tty/pager"

require "friends/activity"
require "friends/note"
require "friends/friend"
require "friends/graph"
require "friends/location"
require "friends/friends_error"

module Friends
  class Introvert
    ACTIVITIES_HEADER = "### Activities:"
    NOTES_HEADER = "### Notes:"
    FRIENDS_HEADER = "### Friends:"
    LOCATIONS_HEADER = "### Locations:"

    # @param filename [String] the name of the friends Markdown file
    def initialize(filename:)
      @user_facing_filename = filename
      @expanded_filename = File.expand_path(filename)
      @output = []

      # Read in the input file. It's easier to do this now and optimize later
      # than try to overly be clever about what we read and write.
      read_file
    end

    attr_reader :output

    # Write out the friends file with cleaned/sorted data.
    # @param clean_command [Boolean] true iff the command the user
    #   executed is `friends clean`; false if this is called as the
    #   result of another command
    def clean(clean_command:)
      friend_names = Set.new(@friends.map(&:name))
      location_names = Set.new(@locations.map(&:name))

      # Iterate through all events and add missing friends and
      # locations.
      (@activities + @notes).each do |event|
        event.friend_names.each do |name|
          unless friend_names.include? name
            add_friend(name: name)
            friend_names << name
          end
        end

        event.description_location_names.each do |name|
          unless location_names.include? name
            add_location(name: name)
            location_names << name
          end
        end
      end

      File.open(@expanded_filename, "w") do |file|
        file.puts(ACTIVITIES_HEADER)
        stable_sort(@activities).each { |act| file.puts(act.serialize) }
        file.puts # Blank line separating activities from notes.
        file.puts(NOTES_HEADER)
        stable_sort(@notes).each { |note| file.puts(note.serialize) }
        file.puts # Blank line separating notes from friends.
        file.puts(FRIENDS_HEADER)
        @friends.sort.each { |friend| file.puts(friend.serialize) }
        file.puts # Blank line separating friends from locations.
        file.puts(LOCATIONS_HEADER)
        @locations.sort.each { |location| file.puts(location.serialize) }
      end

      # This is a special-case piece of code that lets us print a message that
      # includes the filename when `friends clean` is called.
      @output << "File cleaned: \"#{@user_facing_filename}\"" if clean_command
    end

    # Add a friend.
    # @param name [String] the name of the friend to add
    # @raise [FriendsError] when a friend with that name is already in the file
    def add_friend(name:)
      if @friends.any? { |friend| friend.name == name }
        raise FriendsError, "Friend named \"#{name}\" already exists"
      end

      friend = Friend.deserialize(name)

      @friends << friend

      @output << "Friend added: \"#{friend.name}\""
    end

    # Add an activity.
    # @param serialization [String] the serialized activity
    def add_activity(serialization:)
      Activity.deserialize(serialization).tap do |activity|
        # If there's no description, prompt the user for one.
        if activity.description.nil? || activity.description.empty?
          activity.description = Readline.readline(activity.to_s).to_s.strip

          raise FriendsError, "Blank activity not added" if activity.description.empty?
        end

        activity.highlight_description(introvert: self)

        @output << "Activity added: \"#{activity}\""

        @output << default_location_output(activity) if activity.default_location

        @activities.unshift(activity)
      end
    end

    # Add a note.
    # @param serialization [String] the serialized note
    def add_note(serialization:)
      Note.deserialize(serialization).tap do |note|
        # If there's no description, prompt the user for one.
        if note.description.nil? || note.description.empty?
          note.description = Readline.readline(note.to_s).to_s.strip

          raise FriendsError, "Blank note not added" if note.description.empty?
        end

        note.highlight_description(introvert: self)

        @notes.unshift(note)

        @output << "Note added: \"#{note}\""
      end
    end

    # Add a location.
    # @param name [String] the serialized location
    # @raise [FriendsError] if a location with that name already exists
    def add_location(name:)
      if @locations.any? { |location| location.name == name }
        raise FriendsError, "Location \"#{name}\" already exists"
      end

      location = Location.deserialize(name)

      @locations << location

      @output << "Location added: \"#{location.name}\"" # Return the added location.
    end

    # Set a friend's location.
    # @param name [String] the friend's name
    # @param location_name [String] the name of an existing location
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    # @raise [FriendsError] if 0 or 2+ locations match the given location name
    def set_location(name:, location_name:)
      friend = thing_with_name_in(:friend, name)
      location = thing_with_name_in(:location, location_name)
      friend.location_name = location.name

      @output << "#{friend.name}'s location set to: \"#{location.name}\""
    end

    # Rename an existing friend.
    # @param old_name [String] the name of the friend
    # @param new_name [String] the new name of the friend
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    def rename_friend(old_name:, new_name:)
      friend = thing_with_name_in(:friend, old_name)
      (@activities + @notes).each do |event|
        event.update_friend_name(old_name: friend.name, new_name: new_name)
      end
      friend.name = new_name

      @output << "Name changed: \"#{friend}\""
    end

    # Rename an existing location.
    # @param old_name [String] the name of the location
    # @param new_name [String] the new name of the location
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    def rename_location(old_name:, new_name:)
      loc = thing_with_name_in(:location, old_name)

      # Update locations in activities and notes.
      (@activities + @notes).each do |event|
        event.update_location_name(old_name: loc.name, new_name: new_name)
      end

      # Update locations of friends.
      @friends.select { |f| f.location_name == loc.name }.each do |friend|
        friend.location_name = new_name
      end

      loc.name = new_name # Update location itself.

      @output << "Location renamed: \"#{loc.name}\""
    end

    # Add a nickname to an existing friend.
    # @param name [String] the name of the friend
    # @param nickname [String] the nickname to add to the friend
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    def add_nickname(name:, nickname:)
      raise FriendsError, "Expected \"[Friend Name]\" \"[Nickname]\"" if name.empty?
      raise FriendsError, "Nickname cannot be blank" if nickname.empty?

      friend = thing_with_name_in(:friend, name)
      friend.add_nickname(nickname)

      @output << "Nickname added: \"#{friend}\""
    end

    # Add an alias to an existing location.
    # @param name [String] the name of the location
    # @param nickname [String] the alias to add to the location
    # @raise [FriendsError] if 0 or 2+ locations match the given name
    # @raise [FriendsError] if the alias is already taken
    def add_alias(name:, nickname:)
      raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
      raise FriendsError, "Alias cannot be blank" if nickname.empty?

      collision = @locations.find do |loc|
        loc.name.casecmp(nickname).zero? || loc.aliases.any? { |a| a.casecmp(nickname).zero? }
      end

      if collision
        raise FriendsError,
              "The location alias \"#{nickname}\" is already taken by "\
              "\"#{collision}\""
      end

      location = thing_with_name_in(:location, name)
      location.add_alias(nickname)

      @output << "Alias added: \"#{location}\""
    end

    # Add a tag to an existing friend.
    # @param name [String] the name of the friend
    # @param tag [String] the tag to add to the friend, of the form: "@tag"
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    def add_tag(name:, tag:)
      raise FriendsError, "Expected \"[Friend Name]\" \"[Tag]\"" if name.empty?
      raise FriendsError, "Tag cannot be blank" if tag == "@"

      friend = thing_with_name_in(:friend, name)
      friend.add_tag(tag)

      @output << "Tag added to friend: \"#{friend}\""
    end

    # Remove a tag from an existing friend.
    # @param name [String] the name of the friend
    # @param tag [String] the tag to remove from the friend, of the form: "@tag"
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    # @raise [FriendsError] if the friend does not have the given nickname
    def remove_tag(name:, tag:)
      friend = thing_with_name_in(:friend, name)
      friend.remove_tag(tag)

      @output << "Tag removed from friend: \"#{friend}\""
    end

    # Remove a nickname from an existing friend.
    # @param name [String] the name of the friend
    # @param nickname [String] the nickname to remove from the friend
    # @raise [FriendsError] if 0 or 2+ friends match the given name
    # @raise [FriendsError] if the friend does not have the given nickname
    def remove_nickname(name:, nickname:)
      friend = thing_with_name_in(:friend, name)
      friend.remove_nickname(nickname)

      @output << "Nickname removed: \"#{friend}\""
    end

    # Remove an alias from an existing location.
    # @param name [String] the name of the location
    # @param nickname [String] the alias to remove from the location
    # @raise [FriendsError] if 0 or 2+ locations match the given name
    # @raise [FriendsError] if the location does not have the given alias
    def remove_alias(name:, nickname:)
      raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
      raise FriendsError, "Alias cannot be blank" if nickname.empty?

      location = thing_with_name_in(:location, name)
      location.remove_alias(nickname)

      @output << "Alias removed: \"#{location}\""
    end

    # List all friend names in the friends file.
    # @param location_name [String] the name of a location to filter by, or nil
    #   for unfiltered
    # @param tagged [Array<String>] the names of tags to filter by, or empty for
    #   unfiltered
    # @param verbose [Boolean] true iff we should output friend names with
    #   nicknames, locations, and tags; false for names only
    # @param sort [String] one of:
    #   ["alphabetical", "n-activities", "recency"]
    # @param reverse [Boolean] true iff we should reverse the sorted order of
    #   our output
    def list_friends(location_name:, tagged:, verbose:, sort:, reverse:)
      fs = @friends

      # Filter by location if a name is passed.
      if location_name
        location = thing_with_name_in(:location, location_name)
        fs = fs.select { |friend| friend.location_name == location.name }
      end

      # Filter by tag if param is passed.
      unless tagged.empty?
        fs = fs.select do |friend|
          tagged.all? { |tag| friend.tags.map(&:downcase).include? tag.downcase }
        end
      end

      list_things(type: :friend, arr: fs, verbose: verbose, sort: sort, reverse: reverse)
    end

    NA_STR = "N/A"
    private_constant :NA_STR

    # See `list_events` for all of the parameters we can pass.
    def list_activities(**args)
      list_events(events: @activities, **args)
    end

    # See `list_events` for all of the parameters we can pass.
    def list_notes(**args)
      list_events(events: @notes, **args)
    end

    # List all location names in the friends file.
    # @param verbose [Boolean] true iff we should output location names with
    #   aliases; false for names only
    # @param sort [String] one of:
    #   ["alphabetical", "n-activities", "recency"]
    # @param reverse [Boolean] true iff we should reverse the sorted order of
    #   our output
    def list_locations(verbose:, sort:, reverse:)
      list_things(type: :location, verbose: verbose, sort: sort, reverse: reverse)
    end

    # @param from [Array] containing any of: ["activities", "friends", "notes"]
    #   If not empty, limits the tags returned to only those from either
    #   activities, notes, or friends.
    def list_tags(from:)
      tags(from: from).sort_by(&:downcase).each { |tag| @output << tag }
    end

    # Graph activities over time.
    # Optionally filter by friend, location and tag
    #
    # The graph displays all of the months (inclusive) between the first
    # and last month in which activities have been recorded.
    #
    # @param with [Array<String>] the names of friends to filter by, or empty for
    #   unfiltered
    # @param location_name [String] the name of a location to filter by, or
    #   nil for unfiltered
    # @param tagged [Array<String>] the names of tags to filter by, or empty for
    #   unfiltered
    # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
    # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
    # @param unscaled [Boolean] true iff we should show the absolute size of bars in the graph
    #   rather than a scaled version
    # @raise [FriendsError] if friend, location or tag cannot be found or
    #   is ambiguous
    def graph(with:, location_name:, tagged:, since_date:, until_date:, unscaled:)
      filtered_activities_to_graph = filtered_events(
        events: @activities,
        with: with,
        location_name: location_name,
        tagged: tagged,
        since_date: since_date,
        until_date: until_date
      )

      # If the user wants to graph in a specific date range, we explicitly
      # limit our output to that date range. We don't just use the date range
      # of the first and last `filtered_activities_to_graph` because those
      # activities might not include others in the full range (for instance,
      # if only one filtered activity matches a query, we don't want to only
      # show unfiltered activities that occurred on that specific day).
      all_activities_to_graph = filtered_events(
        events: @activities,
        with: [],
        location_name: nil,
        tagged: [],

        # By including all activities for the "fencepost" months in our totals,
        # we prevent those months from being always "full" in the graph
        # because all filtered events will match the criteria.
        since_date: (since_date.prev_day(since_date.day - 1) if since_date),
        until_date: (until_date.prev_day(until_date.day - 1).next_month.prev_day if until_date)
      )

      Graph.new(
        filtered_activities: filtered_activities_to_graph,
        all_activities: all_activities_to_graph,
        unscaled: unscaled
      ).output.each { |line| @output << line }
    end

    # Suggest friends to do something with.
    #
    # The returned hash uses the following format:
    #   {
    #     distant: ["Distant Friend 1 Name", "Distant Friend 2 Name", ...],
    #     moderate: ["Moderate Friend 1 Name", "Moderate Friend 2 Name", ...],
    #     close: ["Close Friend 1 Name", "Close Friend 2 Name", ...]
    #   }
    #
    # @param location_name [String] the name of a location to filter by, or nil
    #   for unfiltered
    def suggest(location_name:)
      # Filter our friends by location if necessary.
      fs = @friends
      fs = fs.select { |f| f.location_name == location_name } if location_name

      # Sort our friends, with the least favorite friend first.
      sorted_friends = fs.sort_by(&:n_activities)

      # Set initial value in case there are no friends and the while loop is
      # never entered.
      distant_friend_names = []

      # First, get not-so-good friends.
      while !sorted_friends.empty? && sorted_friends.first.n_activities < 2
        distant_friend_names << sorted_friends.shift.name
      end

      moderate_friend_names = sorted_friends.slice!(0, sorted_friends.size * 3 / 4).
                              map!(&:name)
      close_friend_names = sorted_friends.map!(&:name)

      @output << "Distant friend: "\
                 "#{Paint[distant_friend_names.sample || 'None found', :bold, :magenta]}"
      @output << "Moderate friend: "\
                 "#{Paint[moderate_friend_names.sample || 'None found', :bold, :magenta]}"
      @output << "Close friend: "\
                 "#{Paint[close_friend_names.sample || 'None found', :bold, :magenta]}"
    end

    ###################################################################
    # Methods below this are only used internally and are not tested. #
    ###################################################################

    # Get a regex friend map.
    #
    # The returned hash uses the following format:
    #   {
    #     /regex/ => [list of friends matching regex]
    #   }
    #
    # This hash is sorted (because Ruby's hashes are ordered) by decreasing
    # regex key length, so the key /Jacob Evelyn/ appears before /Jacob/.
    #
    # @return [Hash{Regexp => Array<Friends::Friend>}]
    def regex_friend_map
      @friends.each_with_object(Hash.new { |h, k| h[k] = [] }) do |friend, hash|
        friend.regexes_for_name.each do |regex|
          hash[regex] << friend
        end
      end.sort_by { |k, _| -k.to_s.size }.to_h
    end

    # Get a regex location map.
    #
    # The returned hash uses the following format:
    #   {
    #     /regex/ => location
    #   }
    #
    # This hash is sorted (because Ruby's hashes are ordered) by decreasing
    # regex key length, so the key /Paris, France/ appears before /Paris/.
    #
    # @return [Hash{Regexp => location}]
    def regex_location_map
      @locations.each_with_object({}) do |location, hash|
        location.regexes_for_name.each { |regex| hash[regex] = location }
      end.sort_by { |k, _| -k.to_s.size }.to_h
    end

    # Sets the likelihood_score field on each friend in `possible_matches`. This
    # score represents how likely it is that an activity containing the friends
    # in `matches` and containing a friend from each group in `possible_matches`
    # contains that given friend.
    # @param matches [Array<Friend>] the friends in a specific activity
    # @param possible_matches [Array<Array<Friend>>] an array of groups of
    #   possible matches, for example:
    #   [
    #     [Friend.new(name: "John Doe"), Friend.new(name: "John Deere")],
    #     [Friend.new(name: "Aunt Mae"), Friend.new(name: "Aunt Sue")]
    #   ]
    #   These groups will all contain friends with similar names; the purpose of
    #   this method is to give us a likelihood that a "John" in an activity
    #   description, for instance, is "John Deere" vs. "John Doe"
    def set_likelihood_score!(matches:, possible_matches:)
      combinations = (matches + possible_matches.flatten).
                     combination(2).
                     reject do |friend1, friend2|
                       (matches & [friend1, friend2]).size == 2 ||
                         possible_matches.any? do |group|
                           (group & [friend1, friend2]).size == 2
                         end
                     end

      @activities.each do |activity|
        names = activity.friend_names

        combinations.each do |group|
          if (names & group.map(&:name)).size == 2
            group.each { |friend| friend.likelihood_score += 1 }
          end
        end
      end
    end

    def stats
      events = @activities + @notes

      elapsed_days = if events.size < 2
                       0
                     else
                       sorted_events = events.sort
                       (sorted_events.first.date - sorted_events.last.date).to_i
                     end

      @output << "Total activities: #{@activities.size}"
      @output << "Total friends: #{@friends.size}"
      @output << "Total locations: #{@locations.size}"
      @output << "Total notes: #{@notes.size}"
      @output << "Total tags: #{tags.size}"
      @output << "Total time elapsed: #{elapsed_days} day#{'s' if elapsed_days != 1}"
    end

    private

    # List either friends or activities
    # @param arr [Array<Friend|Activity>] a filtered list to print
    # @param verbose [Boolean] true iff we should output names with
    #   aliases/nicknames/etc.; false for names only
    # @param sort [String] one of:
    #   ["alphabetical", "n-activities", "recency"]
    # @param reverse [Boolean] true iff we should reverse the sorted order of
    #   our output
    def list_things(type:, arr: instance_variable_get("@#{type}s"), verbose:, sort:, reverse:)
      case sort
      when "alphabetical"
        arr = stable_sort(arr) # In case the input file was not already sorted.
      when "n-activities"
        arr = stable_sort_by(arr) { |thing| -thing.n_activities }
      when "recency"
        today = Date.today

        most_recent_activity_by_thing = @activities.each_with_object({}) do |activity, output|
          activity.send("#{type}_names").each do |thing_name|
            output[thing_name] = (today - activity.date).to_i unless output.key?(thing_name)
          end
        end

        arr = stable_sort_by(arr) do |thing|
          most_recent_activity_by_thing[thing.name] || -Float::INFINITY
        end
      end

      (reverse ? arr.reverse : arr).each do |thing|
        case sort
        when "n-activities"
          prefix = "#{Paint[thing.n_activities, :bold, :red]} "\
                   "activit#{thing.n_activities == 1 ? 'y' : 'ies'}: "
        when "recency"
          n_days = most_recent_activity_by_thing[thing.name] || NA_STR
          prefix = "#{Paint[n_days, :bold, :red]} "\
                   "day#{'s' unless n_days == 1} ago: "
        end

        @output << "#{prefix}#{verbose ? thing.to_s : thing.name}"
      end
    end

    # @param from [Array] containing any of: ["activities", "friends", "notes"]
    #   If not empty, limits the tags returned to only those from either
    #   activities, notes, or friends.
    # @return [Set] the set of tags present in the given things
    def tags(from: [])
      Set.new.tap do |output|
        if from.empty? || from.include?("activities")
          @activities.each { |activity| output.merge(activity.tags) }
        end

        @notes.each { |note| output.merge(note.tags) } if from.empty? || from.include?("notes")

        if from.empty? || from.include?("friends")
          @friends.each { |friend| output.merge(friend.tags) }
        end
      end
    end

    # List all event details.
    # @param events [Array<Event>] the base events to list, either @activities or @notes
    # @param with [Array<String>] the names of friends to filter by, or empty for
    #   unfiltered
    # @param location_name [String] the name of a location to filter by, or
    #   nil for unfiltered
    # @param tagged [Array<String>] the names of tags to filter by, or empty for
    #   unfiltered
    # @param since_date [Date] a date on or after which to find events, or nil for unfiltered
    # @param until_date [Date] a date before or on which to find events, or nil for unfiltered
    # @raise [FriendsError] if friend, location or tag cannot be found or
    #   is ambiguous
    def list_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
      events = filtered_events(
        events: events,
        with: with,
        location_name: location_name,
        tagged: tagged,
        since_date: since_date,
        until_date: until_date
      )

      events.each { |event| @output << event.to_s }
    end

    # @param arr [Array] an unsorted array
    # @return [Array] a stably-sorted array
    def stable_sort(arr)
      arr.sort_by.with_index { |x, idx| [x, idx] }
    end

    # @param arr [Array] an unsorted array
    # @param &block [block] used to return a value for each element's sort position
    # @return [Array] a stably-sorted array
    def stable_sort_by(arr)
      arr.sort_by.with_index { |x, idx| [yield(x), idx] }
    end

    # Filter activities by friend, location and tag
    # @param events [Array<Event>] the base events to list, either @activities or @notes
    # @param with [Array<String>] the names of friends to filter by, or empty for
    #   unfiltered
    # @param location_name [String] the name of a location to filter by, or
    #   nil for unfiltered
    # @param tagged [Array<String>] the names of tags to filter by, or empty for
    #   unfiltered
    # @param since_date [Date] a date on or after which to find activities, or nil for unfiltered
    # @param until_date [Date] a date before or on which to find activities, or nil for unfiltered
    # @return [Array] an array of activities or notes
    # @raise [FriendsError] if friend, location or tag cannot be found or
    #   is ambiguous
    def filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
      # Filter by friend name if argument is passed.
      unless with.empty?
        friends = with.map { |name| thing_with_name_in(:friend, name) }
        events = events.select do |event|
          friends.all? { |friend| event.includes_friend?(friend) }
        end
      end

      # Filter by location name if argument is passed.
      unless location_name.nil?
        location = thing_with_name_in(:location, location_name)
        events = events.select { |event| event.includes_location?(location) }
      end

      # Filter by tag if argument is passed.
      unless tagged.empty?
        events = events.select do |event|
          tagged.all? { |tag| event.includes_tag?(tag) }
        end
      end

      # Filter by date if arguments are passed.
      events = events.select { |event| event.date >= since_date } unless since_date.nil?
      events = events.select { |event| event.date <= until_date } unless until_date.nil?

      events
    end

    # Sets the n_activities field on each thing.
    # @param type [Symbol] one of: [:friend, :location]
    # @raise [ArgumentError] if `type` is not one of: [:friend, :location]
    def set_n_activities!(type)
      unless [:friend, :location].include? type
        raise ArgumentError, "Type must be either :friend or :location"
      end

      # Construct a hash of location name to frequency of appearance.
      freq_table = Hash.new { |h, k| h[k] = 0 }
      @activities.each do |activity|
        activity.send("#{type}_names").each do |thing_name|
          freq_table[thing_name] += 1
        end
      end

      # Remove names that are not in the locations list.
      freq_table.each do |name, count|
        things = instance_variable_get("@#{type}s").select do |thing|
          thing.name == name
        end

        # Do nothing if no matches found.
        if things.size == 1
          things.first.n_activities = count
        elsif things.size > 1
          raise FriendsError, "More than one #{type} named \"#{name}\""
        end
      end
    end

    def set_implicit_locations!
      implicit_location = nil
      # reverse_each here moves through the activities in chronological order
      @activities.reverse_each do |activity|
        implicit_location = activity.default_location if activity.default_location
        activity.implicit_location = implicit_location if activity.description_location_names.empty?
      end
    end

    # Process the friends.md file and store its contents in internal data
    # structures.
    def read_file
      @friends = []
      @activities = []
      @notes = []
      @locations = []

      return unless File.exist?(@expanded_filename)

      state = :unknown

      # Loop through all lines in the file and process them.
      File.foreach(@expanded_filename).with_index(1) do |line, line_num|
        line.chomp! # Remove trailing newline from each line.

        # Parse the line and update the parsing state.
        state = parse_line!(line, line_num: line_num, state: state)
      end
      # sort the activities from earliest to latest, in case friends.md has been corrupted
      @activities = stable_sort(@activities)

      set_implicit_locations!

      set_n_activities!(:friend)
      set_n_activities!(:location)
    end

    # Parse the given line, adding to the various internal data structures as
    # necessary.
    # @param line [String]
    # @param line_num [Integer] the 1-indexed file line number we're parsing
    # @param state [Symbol] the state of the parsing, one of:
    #   [:unknown, :reading_activities, :reading_friends, :reading_locations]
    # @return [Symbol] the updated state after parsing the given line
    def parse_line!(line, line_num:, state:)
      return :unknown if line == ""

      # If we're in an unknown state, look for a header to tell us what we're
      # parsing next.
      if state == :unknown
        PARSING_STAGES.each do |stage|
          if line == self.class.const_get("#{stage.id.to_s.upcase}_HEADER")
            return "reading_#{stage.id}".to_sym
          end
        end

        # If we made it here, we couldn't recognize a header.
        bad_line("a valid header", line_num)
      end

      # If we made it this far, we're parsing objects in a class.
      stage = PARSING_STAGES.find { |s| state == "reading_#{s.id}".to_sym }

      begin
        instance_variable_get("@#{stage.id}") << stage.klass.deserialize(line)
      rescue StandardError => e
        bad_line(e, line_num)
      end

      state
    end

    # Used internally by the parse_line! method above to associate stages with
    # the class to create.
    ParsingStage = Struct.new(:id, :klass)
    PARSING_STAGES = [
      ParsingStage.new(:activities, Activity),
      ParsingStage.new(:notes, Note),
      ParsingStage.new(:friends, Friend),
      ParsingStage.new(:locations, Location)
    ].freeze

    # @param type [Symbol] one of: [:friend, :location]
    # @param text [String] the name (or substring) of the friend or location to
    #   search for
    # @return [Friend/Location] the friend or location that matches
    # @raise [FriendsError] if 0 or 2+ friends match the given text
    def thing_with_name_in(type, text)
      things = instance_variable_get("@#{type}s").select do |thing|
        thing.regexes_for_name.any? { |regex| regex.match(text) }
      end

      # If there's more than one match with fuzzy regexes but exactly one thing
      # with that exact name, match it.
      if things.size > 1
        exact_things = things.select do |thing|
          thing.name.casecmp(text).zero? # We ignore case for an "exact" match.
        end

        things = exact_things if exact_things.size == 1
      end

      case things.size
      when 1 then things.first # If exactly one thing matches, use that thing.
      when 0 then raise FriendsError, "No #{type} found for \"#{text}\""
      else
        raise FriendsError,
              "More than one #{type} found for \"#{text}\": "\
                "#{things.map(&:name).join(', ')}"
      end
    end

    # Raise an error that a line in the friends file is malformed.
    # @param expected [String] the expected contents of the line
    # @param line_num [Integer] the line number
    # @raise [FriendsError] with a constructed message
    def bad_line(expected, line_num)
      raise FriendsError, "Expected \"#{expected}\" on line #{line_num}"
    end

    # @param [Activity] the activity that was added by the user
    # @return [String] specifying default location and its time range
    def default_location_output(activity)
      str = "Default location"

      earlier_activities, later_activities = @activities.partition { |a| a.date <= activity.date }

      earlier_activity_with_default_location = activity

      earlier_activities.each do |a|
        next unless a.default_location

        break unless a.default_location == activity.default_location

        earlier_activity_with_default_location = a
      end

      unless later_activities.empty?
        str += " from #{Paint[earlier_activity_with_default_location.date, :bold]}"

        later_activity = later_activities.find do |a|
          a.default_location && a.default_location != activity.default_location
        end

        str += " to #{Paint[later_activity&.date || 'present', :bold]}"
      end

      str += " already" if earlier_activity_with_default_location != activity

      "#{str} set to: \"#{activity.default_location}\""
    end
  end
end
