class NSWTopo::Map

def self.declination(longitude, latitude)

def self.declination(longitude, latitude)
  today = Date.today
  query = { latd: latitude, lond: longitude, latm: 0, lonm: 0, lats: 0, lons: 0, elev: 0, year: today.year, month: today.month, day: today.day, Ein: "Dtrue" }
  uri = URI::HTTPS.build host: "api.geomagnetism.ga.gov.au", path: "/agrf", query: URI.encode_www_form(query)
  json = Net::HTTP.get uri
  Float(JSON.parse(json).dig("magneticFields", "D").to_s.sub(/ .*/, ""))
rescue JSON::ParserError, ArgumentError, TypeError, SystemCallError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
  raise "couldn't get magnetic declination value"
end

def self.from_svg(archive, svg_path)

def self.from_svg(archive, svg_path)
  xml = REXML::Document.new(svg_path.read)
  unless false == Config["versioning"]
    creator_tool = xml.elements["svg/metadata/rdf:RDF/rdf:Description[@xmp:CreatorTool]/@xmp:CreatorTool"]&.value
    version = Version[creator_tool]
    raise "SVG nswtopo version too old: %s" % svg_path unless version >= MIN_VERSION
    raise "SVG nswtopo version too new: %s" % svg_path unless version <= VERSION
  end
  /^0\s+0\s+(?<width>\S+)\s+(?<height>\S+)$/ =~ xml.elements["svg[@viewBox]/@viewBox"]&.value
   width && xml.elements["svg[ @width='#{ width}mm']"] || raise(Version::Error)
  height && xml.elements["svg[@height='#{height}mm']"] || raise(Version::Error)
  dimensions = [width, height].map(&:to_f)
  metadata = xml.elements["svg/metadata/nswtopo:map[@projection][@centre][@scale][@rotation]"] || raise(Version::Error)
  projection = Projection.new metadata.attributes["projection"]
  neatline = GeoJSON.polygon JSON.parse(metadata.attributes["neatline"]), projection: projection
  centre = JSON.parse metadata.attributes["centre"]
  scale = metadata.attributes["scale"].to_i
  rotation = metadata.attributes["rotation"].to_f
  new(archive, neatline: neatline, centre: centre, dimensions: dimensions, scale: scale, rotation: rotation).save.tap do |map|
    map.write "map.svg", svg_path.read
  end
rescue Version::Error, JSON::ParserError
  raise "not an nswtopo SVG file: %s" % svg_path
rescue SystemCallError
  raise "couldn't read file: %s" % svg_path
rescue REXML::ParseException
  raise "unrecognised map file: %s" % svg_path
end

def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, neatline: nil, dimensions: nil, margins: nil, **neatline_options)

def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, neatline: nil, dimensions: nil, margins: nil, **neatline_options)
  points = case
  when dimensions && margins
    raise "can't specify both margins and map dimensions"
  when dimensions && neatline
    raise "can't specify both neatline and map dimensions"
  when bounds && neatline
    raise "can't specify both bounds and neatline"
  when coords && neatline
    raise "can't specify both neatline and map coordinates"
  when coords && bounds
    raise "can't specify both bounds and map coordinates"
  when coords
    GeoJSON.multipoint(coords)
  when bounds
    GPS.load(bounds).explode.tap do |gps|
      margins ||= [15, 15] unless dimensions || gps.polygons.any?
      raise "no features found in %s" % bounds if gps.none?
    end
  when neatline
    neatline = GPS.load(neatline)
    raise "neatline must be a single polygon" unless neatline.polygon?
    neatline
  else
    raise "no bounds file or map coordinates specified"
  end.dissolve_points
  centre = *points.bbox_centre.coordinates
  equidistant = Projection.azimuthal_equidistant *centre
  margins ||= [0, 0]
  case rotation
  when "auto"
    raise "can't specify both map dimensions and auto-rotation" if dimensions
    local_points = points.reproject_to equidistant
    rotation = -180 * local_points.minimum_bbox_angle(*margins) / Math::PI
  when "magnetic"
    rotation = declination *centre
  else
    raise "map rotation must be between ±45°" unless rotation.abs <= 45
  end
  unless dimensions
    local_points ||= points.reproject_to equidistant
    local_points.rotate_by_degrees! rotation
    local_extents, local_centre = local_points.bbox_extents, local_points.bbox_centre
    local_centre.rotate_by_degrees! -rotation
    dimensions = local_extents.zip(margins).map do |extent, margin|
      extent * 1000.0 / scale + 2 * margin
    end
    centre = *local_centre.reproject_to_wgs84.coordinates
  end
  params = { units: "mm", axis: "esu", k_0: 1.0 / scale, x_0: 0.0005 * dimensions[0], y_0: -0.0005 * dimensions[1] }
  projection = rotation.zero? ?
    Projection.transverse_mercator(*centre, **params) :
    Projection.oblique_mercator(*centre, alpha: rotation, **params)
  case
  when dimensions.all?(&:positive?)
  when coords
    raise "not enough information to calculate map size – add more coordinates, or specify map dimensions or margins"
  when bounds
    raise "not enough information to calculate map size – check bounds file, or specify map dimensions or margins"
  end
  if neatline
    neatline = neatline.reproject_to projection
  else
    ring = [0, 0].zip(dimensions).inject(&:product).values_at(0,2,3,1,0)
    neatline = GeoJSON.polygon [ring], projection: projection, name: "neatline"
  end
  neatline_options.each do |key, value|
    case key
    when :radius
      radius, segments = [*value, 9]
      neatline = neatline.with_sql(<<~SQL, name: "neatline").explode
        SELECT ST_Buffer(ST_Buffer(ST_Buffer(geometry, #{-radius}, #{segments}), #{2*radius}, #{segments}), #{-radius}, #{segments})
        FROM neatline
      SQL
      raise OptionParser::InvalidArgument, "radius too big" unless neatline.one?
    when :inset
      value.map do |corners|
        corners.each_slice(2).entries.transpose.map(&:sort)
      end.flat_map do |bounds|
        dimensions.zip(bounds)
      end.each do |dimension, (min, max)|
        raise OptionParser::InvalidArgument, "inset falls outside map area" unless max > 0 && min < dimension
      end
      values = value.map do |corners|
        %Q[(BuildMBR(#{corners.join ?,}))]
      end
      neatline = neatline.with_sql(<<~SQL, name: "neatline").explode
        WITH insets(geometry) AS (VALUES #{values.join ?,})
        SELECT ST_Difference(neatline.geometry, ST_Union(insets.geometry)) AS geometry
        FROM neatline JOIN insets
      SQL
      raise OptionParser::InvalidArgument, "inset too big" if neatline.none?
      raise OptionParser::InvalidArgument, "inset creates non-contiguous map" unless neatline.one?
    end
  end
  new(archive, neatline: neatline, centre: centre, dimensions: dimensions, scale: scale, rotation: rotation).save
end

def self.load(archive)

def self.load(archive)
  properties = YAML.load(archive.read "map.yml")
  neatline = GeoJSON::Collection.load(archive.read "map.json")
  new archive, neatline: neatline, **properties
rescue ArgumentError, YAML::Exception, GeoJSON::Error
  raise NSWTopo::Archive::Invalid
end

def add(*layers, after: nil, before: nil, replace: nil, overwrite: false, strict: false)

def add(*layers, after: nil, before: nil, replace: nil, overwrite: false, strict: false)
  [%w[before after replace], [before, after, replace]].transpose.select(&:last).each do |option, name|
    next if self.layers.any? { |other| other.name == name }
    raise "no such layer: %s" % name
  end.map(&:first).combination(2).each do |options|
    raise OptionParser::AmbiguousOption,  "can't specify --%s and --%s simultaneously" % options
  end
  strict ||= layers.one?
  layers.inject [self.layers, false, replace || after, []] do |(layers, changed, follow, errors), layer|
    index = layers.index layer unless replace || after || before
    if overwrite || !layer.uptodate?
      layer.create
      log_success "%s layer: %s" % [layer.empty? ? "empty" : "added", layer.name]
    else
      log_neutral "kept existing layer: %s" % layer.name
      next layers, changed, layer.name, errors if index
    end
    layers.delete layer
    case
    when index
    when follow
      index = layers.index { |other| other.name == follow }
      index += 1
    when before
      index = layers.index { |other| other.name == before }
    else
      index = layers.index { |other| (other <=> layer) > 0 } || -1
    end
    next layers.insert(index, layer), true, layer.name, errors
  rescue ArcGIS::Connection::Error => error
    log_warn "couldn't download layer: #{layer.name}"
    next layers, changed, follow, errors << error
  rescue RuntimeError => error
    errors << error
    break layers, changed, follow, errors if strict
    log_warn error.message
    next layers, changed, follow, errors
  end.tap do |ordered_layers, changed, follow, errors|
    if changed
      @layers.replace Hash[ordered_layers.map(&:pair)]
      replace ? delete(replace) : save
    end
    case
    when errors.none?
    when strict
      raise errors.first
    when errors.one?
      raise PartialFailureError, "failed to create layer"
    else
      raise PartialFailureError, "failed to create #{errors.length} layers"
    end
  end
end

def cutline(mm: nil)

def cutline(mm: nil)
  mm ? @cutline.buffer(mm).explode : @cutline
end

def declination

def declination
  Map.declination *@centre
end

def delete(*names)

def delete(*names)
  raise OptionParser::MissingArgument, "no layers specified" unless names.any?
  names.inject Set[] do |matched, name|
    matches = @layers.keys.grep(name)
    raise "no such layer: #{name}" if String === name && matches.none?
    matched.merge matches
  end.tap do |names|
    raise "no matching layers found" unless names.any?
  end.each do |name|
    params = @layers.delete name
    @archive.delete Layer.new(name, self, params).filename
    log_success "deleted layer: %s" % name
  end
  save
end

def geotransform(resolution: nil, ppi: nil)

def geotransform(resolution: nil, ppi: nil)
  mm_per_px = ppi ? 25.4 / ppi : to_mm(resolution)
  [0.0, mm_per_px, 0.0, @dimensions[1], 0.0, -mm_per_px]
end

def info(empty: nil, json: false, proj: false, wkt: false)

def info(empty: nil, json: false, proj: false, wkt: false)
  case
  when json
    properties = { dimensions: @dimensions, scale: @scale, rotation: @rotation, layers: layers.map(&:name) }
    JSON.pretty_generate @neatline.reproject_to_wgs84.first.with_properties(properties)
  when proj
    OS.gdalsrsinfo("-o", "proj4", "--single-line", @projection)
  when wkt
    OS.gdalsrsinfo("-o", "wkt2", @projection).gsub(/\n\n+|\A\n+/, "")
  else
    area_km2 = @neatline.area * (0.000001 * @scale)**2
    extents_km = @dimensions.map { |dimension| dimension * 0.000001 * @scale }
    StringIO.new.tap do |io|
      io.puts "%-11s 1:%i" %            ["scale:",      @scale]
      io.puts "%-11s %imm × %imm" %     ["dimensions:", *@dimensions.map(&:round)]
      io.puts "%-11s %.1fkm × %.1fkm" % ["extent:",     *extents_km]
      io.puts "%-11s %.1fkm²" %         ["area:",       area_km2]
      io.puts "%-11s %.1f°" %           ["rotation:",   @rotation]
      layers.reject(&empty ? :nil? : :empty?).inject("layers:") do |heading, layer|
        io.puts "%-11s %s" % [heading, layer]
        nil
      end
    end.string
  end
end

def initialize(archive, neatline:, centre:, dimensions:, scale:, rotation:, layers: {})

def initialize(archive, neatline:, centre:, dimensions:, scale:, rotation:, layers: {})
  @archive, @neatline, @centre, @dimensions, @scale, @rotation, @layers = archive, neatline, centre, dimensions, scale, rotation, layers
  params = { k_0: 1.0 / @scale, units: "mm", x_0: 0.0005 * @dimensions[0], y_0: 0.0005 * @dimensions[1] }
  @projection = rotation.zero? ?
    Projection.transverse_mercator(*centre, **params) :
    Projection.oblique_mercator(*centre, alpha: rotation, **params)
  @cutline = @neatline.reproject_to(@projection)
end

def layers

def layers
  @layers.map do |name, params|
    Layer.new(name, self, params)
  end
end

def move(name, before: nil, after: nil)

def move(name, before: nil, after: nil)
  name, target = [name, before || after].map do |name|
    Layer.sanitise name
  end.each do |name|
    raise OptionParser::InvalidArgument, "no such layer: #{name}" unless @layers.key? name
  end
  raise OptionParser::InvalidArgument, "layers must be different" if name == target
  insert = [name, @layers.delete(name)]
  @layers.each.with_object [] do |(name, layer), layers|
    layers << insert if before && name == target
    layers << [name, layer]
    layers << insert if after && name == target
  end.tap do |layers|
    @layers.replace layers.to_h
  end
  save
end

def neatline(mm: nil)

def neatline(mm: nil)
  mm ? @neatline.buffer(mm).explode : @neatline
end

def render(*paths, worldfile: false, force: false, background: nil, **options)

def render(*paths, worldfile: false, force: false, background: nil, **options)
  @archive.delete "map.svg" if force
  Dir.mktmppath do |temp_dir|
    rasters = Hash.new do |rasters, opts|
      png_path = temp_dir / "raster.#{rasters.size}.png"
      pam_path = temp_dir / "raster.#{rasters.size}.png.aux.xml"
      rasterise png_path, background: background, **opts
      write_pam_file pam_path, **opts
      rasters[opts] = png_path
    end
    dithers = Hash.new do |dithers, opts|
      png_path = temp_dir / "dither.#{dithers.size}.png"
      pam_path = temp_dir / "dither.#{dithers.size}.png.aux.xml"
      FileUtils.cp rasters[opts], png_path
      dither png_path
      write_pam_file pam_path, **opts
      dithers[opts] = png_path
    end
    outputs = paths.map.with_index do |path, index|
      ext = path.extname.delete_prefix ?.
      name = path.basename(path.extname)
      out_path = temp_dir / "output.#{index}.#{ext}"
      send "render_#{ext}", out_path, name: name, background: background, **options do |dither: false, **opts|
        (dither ? dithers : rasters)[opts]
      end
      next out_path, path
    end
    safely "saving, please wait..." do
      outputs.each do |out_path, path|
        FileUtils.cp out_path, path
        log_success "created %s" % path
      rescue SystemCallError
        raise "couldn't save #{path}"
      end
      paths.select do |path|
        %w[.png .tif .jpg].include? path.extname
      end.group_by do |path|
        path.parent / path.basename(path.extname)
      end.keys.each do |base|
        write_world_file Pathname("#{base}.wld"), ppi: options.fetch(:ppi, Formats::PPI)
        Pathname("#{base}.prj").write "#{@projection}\n"
      end if worldfile
    end
  end
end

def save

def save
  tap do
    write "map.json", @neatline.to_json
    write "map.yml", YAML.dump(centre: @centre, dimensions: @dimensions, scale: @scale, rotation: @rotation, layers: @layers)
  end
end

def te

def te
  [0, 0, *@dimensions]
end

def to_metres(mm)

def to_metres(mm)
  mm * @scale / 1000.0
end

def to_mm(metres)

def to_mm(metres)
  metres * 1000.0 / @scale
end

def write_pam_file(path, **opts)

def write_pam_file(path, **opts)
  REXML::Document.new("", raw: %w[SRS], attribute_quote: :quote).add_element("PAMDataset").tap do |pam|
    pam.add_element("SRS", "dataAxisToSRSAxisMapping" => "1,2").add_text @projection.wkt2
    pam.add_element("GeoTransform").add_text geotransform(**opts).join(?,)
    path.write pam
  end
end

def write_world_file(path, **opts)

def write_world_file(path, **opts)
  ulx, mm_per_px, _, uly, _, _ = geotransform(**opts)
  path.open("w") do |file|
    file.puts mm_per_px, 0, 0, -mm_per_px
    file.puts ulx + 0.5 * mm_per_px
    file.puts uly - 0.5 * mm_per_px
  end
end