///|
/// A connected review set of photos joined by at least one qualifying visual
/// fingerprint edge. Members at the ends of a chain need not match directly.
pub(all) struct SimilarityGroup {
  paths : Array[String]
  pair_count : Int32
  minimum_distance : Int32
  maximum_distance : Int32
}

///|
pub(all) struct SimilarityGroupsSummary {
  groups : Int32
  members : Int32
  pairs : Int32
  largest_group : Int32
}

///|
fn add_neighbor(
  adjacency : Map[String, Array[String]],
  path : String,
  neighbor : String,
) -> Unit {
  match adjacency.get(path) {
    Some(neighbors) => neighbors.push(neighbor)
    None => adjacency.set(path, [neighbor])
  }
}

///|
/// Convert qualifying pairs into connected review groups. Group membership
/// is transitive by design and is not a claim that every pair in a group is
/// within the configured distance.
pub fn similarity_groups(
  photos : Array[Photo],
  limit : Int32,
) -> Array[SimilarityGroup] {
  let index = new_similarity_index(limit)
  for photo in photos {
    index.add(photo)
  }
  let pairs = index.all_pairs(limit)
  let adjacency : Map[String, Array[String]] = Map([])
  for pair in pairs {
    add_neighbor(adjacency, pair.0, pair.1)
    add_neighbor(adjacency, pair.1, pair.0)
  }
  let visited : Map[String, Bool] = Map([])
  let group_by_path : Map[String, Int32] = Map([])
  let groups : Array[SimilarityGroup] = []
  for pair in pairs {
    for seed in [pair.0, pair.1] {
      if visited.get(seed) is None {
        let pending = [seed]
        let members : Array[String] = []
        visited.set(seed, true)
        let mut cursor = 0
        while cursor < pending.length() {
          let current = pending[cursor]
          cursor = cursor + 1
          members.push(current)
          match adjacency.get(current) {
            Some(neighbors) =>
              for neighbor in neighbors {
                if visited.get(neighbor) is None {
                  visited.set(neighbor, true)
                  pending.push(neighbor)
                }
              }
            None => ()
          }
        }
        members.sort_by((left, right) => left.lexical_compare(right))
        let group_index = groups.length()
        for photo_path in members {
          group_by_path.set(photo_path, group_index)
        }
        groups.push({
          paths: members,
          pair_count: 0,
          minimum_distance: 177,
          maximum_distance: 0,
        })
      }
    }
  }
  for pair in pairs {
    match group_by_path.get(pair.0) {
      Some(group_index) => {
        let previous = groups[group_index]
        groups[group_index] = {
          paths: previous.paths,
          pair_count: previous.pair_count + 1,
          minimum_distance: previous.minimum_distance.min(pair.2),
          maximum_distance: previous.maximum_distance.max(pair.2),
        }
      }
      None => ()
    }
  }
  groups.sort_by((left, right) => left.paths[0].lexical_compare(right.paths[0]))
  groups
}

///|
/// Summarize connected candidate groups for a report header.
pub fn similarity_groups_summary(
  groups : Array[SimilarityGroup],
) -> SimilarityGroupsSummary {
  let mut members = 0
  let mut pairs = 0
  let mut largest_group = 0
  for group in groups {
    members = members + group.paths.length()
    pairs = pairs + group.pair_count
    largest_group = largest_group.max(group.paths.length())
  }
  { groups: groups.length(), members, pairs, largest_group, }
}

///|
/// Find the connected group containing a path, if it has a qualifying edge.
pub fn find_similarity_group(
  groups : Array[SimilarityGroup],
  path : String,
) -> SimilarityGroup? {
  for group in groups {
    for photo_path in group.paths {
      if photo_path == path {
        return Some(group)
      }
    }
  }
  None
}

///|
/// Export path-sorted connected groups. Consumers should display the rule
/// note because transitive membership does not mean every pair is close.
pub fn similarity_groups_json(groups : Array[SimilarityGroup]) -> String {
  let output = StringBuilder()
  output.write_char('[')
  for group_index = 0
      group_index < groups.length()
      group_index = group_index + 1 {
    if group_index > 0 {
      output.write_char(',')
    }
    let group = groups[group_index]
    output.write_string("{\"paths\":[")
    for path_index = 0
        path_index < group.paths.length()
        path_index = path_index + 1 {
      if path_index > 0 {
        output.write_char(',')
      }
      output.write_string(json_string(group.paths[path_index]))
    }
    output.write_string("],\"pairCount\":")
    output.write_string(group.pair_count.to_string())
    output.write_string(",\"minimumDistance\":")
    output.write_string(group.minimum_distance.to_string())
    output.write_string(",\"maximumDistance\":")
    output.write_string(group.maximum_distance.to_string())
    output.write_char('}')
  }
  output.write_char(']')
  output.to_string()
}

///|
/// Export each candidate photo together with its connected group summary.
pub fn similarity_groups_csv(groups : Array[SimilarityGroup]) -> String {
  let output = StringBuilder()
  output.write_string(
    "group,path,member_count,pair_count,min_distance,max_distance\r\n",
  )
  for group_index = 0
      group_index < groups.length()
      group_index = group_index + 1 {
    let group = groups[group_index]
    for path in group.paths {
      output.write_string((group_index + 1).to_string())
      output.write_char(',')
      output.write_string(csv_field(path))
      output.write_char(',')
      output.write_string(group.paths.length().to_string())
      output.write_char(',')
      output.write_string(group.pair_count.to_string())
      output.write_char(',')
      output.write_string(group.minimum_distance.to_string())
      output.write_char(',')
      output.write_string(group.maximum_distance.to_string())
      output.write_string("\r\n")
    }
  }
  output.to_string()
}

///|
/// Export direct qualifying edges as CSV for use in a spreadsheet.
pub fn similarity_pairs_csv(pairs : Array[(String, String, Int32)]) -> String {
  let output = StringBuilder()
  output.write_string("first_path,second_path,distance_bits\r\n")
  for pair in pairs {
    output.write_string(csv_field(pair.0))
    output.write_char(',')
    output.write_string(csv_field(pair.1))
    output.write_char(',')
    output.write_string(pair.2.to_string())
    output.write_string("\r\n")
  }
  output.to_string()
}