///|
/// Policy used when canonicalizing duplicate generated positions.
pub(all) enum DuplicateMappingPolicy {
  KeepFirst
  KeepLast
  KeepAll
} derive(Eq, Debug)

///|
/// Result metadata for map normalization.
pub(all) struct TransformReport {
  mappings_before : Int
  mappings_after : Int
  sources_before : Int
  sources_after : Int
  duplicates_removed : Int
  sources_removed : Int
}

///|
/// A transformed map together with auditable change counts.
pub(all) struct TransformResult {
  map : DecodedSourceMap
  report : TransformReport
}

///|
fn mapping_with_source_index(mapping : Mapping, source_index : Int) -> Mapping {
  match mapping.original {
    Some(original) =>
      Mapping::mapped(
        generated=mapping.generated,
        original=OriginalPosition::new(
          source_index~,
          line=original.line,
          column=original.column,
        ),
        name?=mapping.name,
      )
    None => Mapping::generated_only(generated=mapping.generated)
  }
}

///|
fn deduplicate_mappings(
  mappings : ArrayView[Mapping],
  policy : DuplicateMappingPolicy,
) -> Array[Mapping] {
  if policy == KeepAll {
    return mappings.to_owned()
  }
  let result : Array[Mapping] = []
  let mut index = 0
  while index < mappings.length() {
    let start = index
    let position = mappings[index].generated
    index = index + 1
    while index < mappings.length() && mappings[index].generated == position {
      index = index + 1
    }
    match policy {
      KeepFirst => result.push(mappings[start])
      KeepLast => result.push(mappings[index - 1])
      KeepAll => ()
    }
  }
  result
}

///|
fn used_source_indices(mappings : ArrayView[Mapping]) -> Map[Int, Unit] {
  let result : Map[Int, Unit] = Map([])
  for mapping in mappings {
    match mapping.original {
      Some(original) => result[original.source_index] = ()
      None => ()
    }
  }
  result
}

///|
fn prune_sources(
  sources : ArrayView[SourceEntry],
  mappings : ArrayView[Mapping],
) -> (Array[SourceEntry], Array[Mapping]) {
  let used = used_source_indices(mappings)
  let remap : Map[Int, Int] = Map([])
  let retained : Array[SourceEntry] = []
  for index, source in sources {
    if used.contains(index) {
      remap[index] = retained.length()
      retained.push(source)
    }
  }
  let rewritten : Array[Mapping] = []
  for mapping in mappings {
    match mapping.original {
      Some(original) =>
        match remap.get(original.source_index) {
          Some(index) =>
            rewritten.push(mapping_with_source_index(mapping, index))
          None =>
            rewritten.push(Mapping::generated_only(generated=mapping.generated))
        }
      None => rewritten.push(mapping)
    }
  }
  (retained, rewritten)
}

///|
/// Sort mappings, resolve duplicates and optionally remove unused sources.
pub fn canonicalize(
  map : DecodedSourceMap,
  duplicate_policy? : DuplicateMappingPolicy = KeepLast,
  remove_unused_sources? : Bool = true,
) -> TransformResult {
  let sorted = sort_mappings(map.mappings)
  let unique = deduplicate_mappings(sorted, duplicate_policy)
  let (sources, mappings) = if remove_unused_sources {
    prune_sources(map.sources, unique)
  } else {
    (map.sources.copy(), unique)
  }
  {
    map: DecodedSourceMap::new(file?=map.file, sources~, mappings~),
    report: {
      mappings_before: map.mappings.length(),
      mappings_after: mappings.length(),
      sources_before: map.sources.length(),
      sources_after: sources.length(),
      duplicates_removed: map.mappings.length() - mappings.length(),
      sources_removed: map.sources.length() - sources.length(),
    },
  }
}

///|
fn position_in_half_open_range(
  value : Position,
  start : Position,
  end : Position,
) -> Bool {
  value.compare(start) >= 0 && value.compare(end) < 0
}

///|
/// Keep mappings in a half-open generated range.
pub fn slice_generated(
  map : DecodedSourceMap,
  start~ : Position,
  end~ : Position,
) -> DecodedSourceMap raise SourceMapError {
  if !start.is_valid() || !end.is_valid() || start.compare(end) > 0 {
    raise InvalidDocument(message="invalid generated slice range")
  }
  let mappings = map.mappings.filter(mapping => {
    position_in_half_open_range(mapping.generated, start, end)
  })
  canonicalize(
    DecodedSourceMap::new(file?=map.file, sources=map.sources.copy(), mappings~),
  ).map
}

///|
fn shift_position(
  position : Position,
  line_delta : Int,
  first_line_column_delta : Int,
) -> Position raise SourceMapError {
  let line = position.line + line_delta
  let column = if position.line == 0 {
    position.column + first_line_column_delta
  } else {
    position.column
  }
  let shifted = Position::new(line~, column~)
  if !shifted.is_valid() {
    raise InvalidDocument(
      message="generated shift produced a negative position",
    )
  }
  shifted
}

///|
/// Shift generated positions using the ECMA-426 section-offset rule.
pub fn shift_generated(
  map : DecodedSourceMap,
  line_delta? : Int = 0,
  first_line_column_delta? : Int = 0,
) -> DecodedSourceMap raise SourceMapError {
  let mappings = map.mappings.map(mapping => {
    generated: shift_position(
      mapping.generated,
      line_delta,
      first_line_column_delta,
    ),
    original: mapping.original,
    name: mapping.name,
  })
  DecodedSourceMap::new(file?=map.file, sources=map.sources.copy(), mappings~)
}

///|
fn remap_appended_mapping(mapping : Mapping, source_base : Int) -> Mapping {
  match mapping.original {
    Some(original) =>
      mapping_with_source_index(mapping, original.source_index + source_base)
    None => mapping
  }
}

///|
/// Concatenate decoded maps at explicit generated offsets.
///
/// This is the programmatic counterpart of constructing an index map and then
/// flattening it, useful for bundlers that already have decoded child maps.
pub fn concatenate(
  parts : ArrayView[(Position, DecodedSourceMap)],
  file? : String,
) -> DecodedSourceMap raise SourceMapError {
  let sources : Array[SourceEntry] = []
  let mappings : Array[Mapping] = []
  let mut previous : Position? = None
  for entry in parts {
    let (offset, part) = entry
    if !offset.is_valid() {
      raise InvalidDocument(message="concatenation offset must be non-negative")
    }
    match previous {
      Some(previous) =>
        if previous.compare(offset) > 0 {
          raise InvalidDocument(message="concatenation offsets must be sorted")
        }
      None => ()
    }
    let source_base = sources.length()
    for source in part.sources {
      sources.push(source)
    }
    for mapping in part.mappings {
      let remapped = remap_appended_mapping(mapping, source_base)
      mappings.push({
        generated: section_offset_position(remapped.generated, offset),
        original: remapped.original,
        name: remapped.name,
      })
    }
    previous = Some(offset)
  }
  let result = DecodedSourceMap::new(file?, sources~, mappings~)
  if !mappings_are_sorted(result.mappings) {
    raise InvalidDocument(
      message="concatenated map sections overlap or are not ordered",
    )
  }
  result
}

///|
/// Convert a transform report to JSON.
pub fn TransformReport::to_json(self : TransformReport) -> Json {
  Json::object({
    "mappings_before": Json::number(self.mappings_before.to_double()),
    "mappings_after": Json::number(self.mappings_after.to_double()),
    "sources_before": Json::number(self.sources_before.to_double()),
    "sources_after": Json::number(self.sources_after.to_double()),
    "duplicates_removed": Json::number(self.duplicates_removed.to_double()),
    "sources_removed": Json::number(self.sources_removed.to_double()),
  })
}