module NSWTopo

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

def add(archive, *layers, after: nil, before: nil, replace: nil, overwrite: false, strict: false, **options)
  create_options = {
    after: Layer.sanitise(after),
    before: Layer.sanitise(before),
    replace: Layer.sanitise(replace),
    overwrite: overwrite,
    strict: strict
  }
  map = Map.load archive
  Enumerator.new do |yielder|
    while layers.any?
      layer, basedir = layers.shift
      path = Pathname(layer).expand_path(*basedir)
      case layer
      when /^controls\.(gpx|kml)$/i
        yielder << [path.basename(path.extname).to_s, "type" => "Control", "path" => path]
      when /\.(gpx|kml)$/i
        yielder << [path.basename(path.extname).to_s, "type" => "Overlay", "path" => path]
      when /\.(tiff?|png|jpg)$/i
        yielder << [path.basename(path.extname).to_s, "type" => "Import", "path" => path]
      when "contours"
        yielder << [layer, "type" => "Contour"]
      when "spot-heights"
        yielder << [layer, "type" => "Spot"]
      when "relief"
        yielder << [layer, "type" => "Relief"]
      when "grid"
        yielder << [layer, "type" => "Grid"]
      when "declination"
        yielder << [layer, "type" => "Declination"]
      when "controls"
        yielder << [layer, "type" => "Control"]
      when /\.yml$/i
        basedir ||= path.parent
        raise "couldn't find '#{layer}'" unless path.file?
        case contents = YAML.load(path.read)
        when Array
          contents.reverse.map do |item|
            Pathname(item.to_s)
          end.each do |relative_path|
            raise "#{relative_path} is not a relative path" unless relative_path.relative?
            layers.prepend [Pathname(relative_path).expand_path(path.parent).relative_path_from(basedir).to_s, basedir]
          end
        when Hash
          name = path.sub_ext("").relative_path_from(basedir).descend.map(&:basename).join(?.)
          yielder << [name, contents.merge("source" => path)]
        else
          raise "couldn't parse #{path}"
        end
      else
        path = Pathname("#{layer}.yml")
        raise "#{layer} is not a relative path" unless path.relative?
        basedir ||= layer_dirs.find do |root|
          path.expand_path(root).file?
        end
        layers.prepend [path.to_s, basedir]
      end
    end
  rescue YAML::Exception
    raise "couldn't parse #{path}"
  end.map do |name, params|
    params.merge! options.transform_keys(&:to_s)
    params.merge! Config[name] if Config[name]
    Layer.new(name, map, params)
  end.tap do |layers|
    raise OptionParser::MissingArgument, "no layers specified" unless layers.any?
    unless layers.one?
      raise OptionParser::InvalidArgument, "can't specify opacity when adding multiple layers" if options[:opacity]
      raise OptionParser::InvalidArgument, "can't specify data path when adding multiple layers" if options[:path]
    end
    map.add *layers, **create_options
  end
end

def config(layer = nil, **options)

def config(layer = nil, **options)
  path, resolution = options[:path], options[:resolution]
  layer = Layer.sanitise layer
  case
  when !layer
    raise OptionParser::InvalidArgument, "no layer name specified for path" if path
    raise OptionParser::InvalidArgument, "no layer name specified for resolution" if resolution
  when path || resolution
    Config.store layer, "path", path.to_s if path
    Config.store layer, "resolution", resolution if resolution
  end
  options.each do |key, value|
    case key
    when :chrome
      raise "chrome path is not an executable" unless value.executable? && !value.directory?
      Config.store key.to_s, value.to_s
    when :"layer-dir"
      raise "not a directory: %s" % value unless value.directory?
      Config.store key.to_s, value.to_s
    when *%i[labelling debug gpu versioning zlib-level knockout]
      Config.store key.to_s, value
    when :delete
      Config.delete *layer, value
    end
  end
  if options.empty?
    puts Config.to_str.each_line.drop(1)
    log_neutral "no configuration yet" if Config.empty?
  else
    Config.save
    log_success "configuration updated"
  end
end

def contours(archive, dem_path, **options)

def contours(archive, dem_path, **options)
  add archive, "contours", **options, path: Pathname(dem_path)
end

def controls(archive, gps_path, **options)

def controls(archive, gps_path, **options)
  raise OptionParser::InvalidArgument, gps_path unless gps_path =~ /\.(gpx|kml)$/i
  add archive, "controls", **options, path: Pathname(gps_path)
end

def declination(archive, **options)

def declination(archive, **options)
  add archive, "declination", **options
end

def delete(archive, *names, **options)

def delete(archive, *names, **options)
  map = Map.load archive
  names.map do |name|
    Layer.sanitise name
  end.uniq.map do |name|
    name[?*] ? %r[^#{name.gsub(?., '\.').gsub(?*, '.*')}$] : name
  end.tap do |names|
    map.delete *names
  end
end

def grid(archive, **options)

def grid(archive, **options)
  add archive, "grid", **options
end

def info(archive, **options)

def info(archive, **options)
  raise OptionParser::InvalidArgument, "one output option only" if options.slice(:json, :proj).length > 1
  puts Map.load(archive).info(**options)
end

def init(archive, **options)

def init(archive, **options)
  puts Map.init(archive, **options)
end

def inspect(url_or_path, layer: nil, coords: nil, codes: nil, countwise: nil, **options)

def inspect(url_or_path, layer: nil, coords: nil, codes: nil, countwise: nil, **options)
  options[:geometry] = GeoJSON.multipoint(coords).bbox if coords
  case url_or_path
  when ArcGIS::Service
    source = ArcGIS::Service.new(url_or_path)
  when Shapefile::Source
    raise OptionParser::InvalidOption, "--id only applies to ArcGIS layers" if options[:id]
    raise OptionParser::InvalidOption, "--decode only applies to ArcGIS layers" if options[:decode]
    raise OptionParser::InvalidOption, "--codes only applies to ArcGIS layers" if codes
    source = Shapefile::Source.new(url_or_path)
    layer ||= source.only_layer
  else
    raise OptionParser::InvalidArgument, url_or_path
  end
  layer = source.layer(layer: layer, **options)
  case
  when codes
    TreeIndenter.new(layer.codes) do |level|
      level.map do |key, values|
        case key
        when Array
          code, value = key
          display_value = value.nil? || /[\t\n\r]/ === value ? value.inspect : value
          ["#{code} → #{display_value}", values]
        else
          ["#{key}:", values]
        end
      end
    end.each do |indents, info|
      puts indents.join << info
    end
  when fields = options[:fields]
    template = "%%%is │ %%%is │ %%s"
    TreeIndenter.new(layer.counts) do |counts|
      counts.group_by do |attributes, count|
        attributes.shift
      end.entries.select(&:first).map do |(name, value), counts|
        [[name, counts.sum(&:last), value], counts]
      end.sort do |((name1, count1, value1), counts1), ((name2, count2, value2), counts2)|
        next count2 <=> count1 if countwise
        value1 && value2 ? value1 <=> value2 : value1 ? 1 : value2 ? -1 : 0
      end
    end.map do |indents, (name, count, value)|
      next name, count.to_s, indents.join << (value.nil? || /[\t\n\r]/ === value ? value.inspect : value.to_s)
    end.transpose.tap do |names, counts, lines|
      template %= [names.map(&:size).max, counts.map(&:size).max] if names
    end.transpose.each do |row|
      puts template % row
    end
  else
    TreeIndenter.new(layer.info) do |hash|
      hash.map do |key, value|
        Hash === value ? ["#{key}:", value] : "#{key}: #{value}"
      end
    end.each do |indents, info|
      puts indents.join << info
    end
  end
rescue ArcGIS::Layer::NoLayerError, Shapefile::Layer::NoLayerError => error
  raise OptionParser::MissingArgument, error.message if codes || countwise || options.any?
  puts "layers:"
  TreeIndenter.new(source.layer_info, []).each do |indents, info|
    puts indents.join << info
  end
rescue ArcGIS::Renderer::TooManyFieldsError
  raise OptionParser::InvalidOption, "use less fields with --fields"
end

def layer_dirs

def layer_dirs
  @layer_dirs ||= Array(Config["layer-dir"]).map(&Pathname.method(:new)) << Pathname.pwd
end

def layers(state: nil)

def layers(state: nil)
  paths = layer_dirs.grep_v(Pathname.pwd).flat_map do |directory|
    Array(state).inject(directory, &:/).glob("*")
  end.sort
  log_warn "no layers installed" if paths.none?
  TreeIndenter.new(paths) do |paths|
    paths.filter_map do |path|
      case
      when path.glob("**/*.yml").any?
        [path.basename.sub_ext(""), path.children.sort]
      when path.sub_ext("").directory?
      when path.extname == ".yml"
        path.basename.sub_ext("")
      end
    end
  end.each do |indents, name|
    puts [*indents, name].join
  end
end

def move(archive, name, **options)

def move(archive, name, **options)
  raise OptionParser::InvalidArgument, "only one of --before and --after allowed" if options[:after] && options[:before]
  raise OptionParser::MissingArgument, "--before or --after required" unless options[:after] || options[:before]
  Map.load(archive).move(name, **options)
end

def overlay(archive, gps_path, **options)

def overlay(archive, gps_path, **options)
  raise OptionParser::InvalidArgument, gps_path unless gps_path =~ /\.(gpx|kml)$/i
  add archive, gps_path, **options, path: Pathname(gps_path)
end

def relief(archive, dem_path, **options)

def relief(archive, dem_path, **options)
  add archive, "relief", **options, path: Pathname(dem_path)
end

def render(archive, basename, *formats, overwrite: false, svg_path: nil, **options)

def render(archive, basename, *formats, overwrite: false, svg_path: nil, **options)
  case
  when formats.any?
  when svg_path
    raise OptionParser::MissingArgument, "no output format specified"
  else
    formats << "svg"
  end
  formats.map do |format|
    Pathname(Formats === format ? "#{basename}.#{format}" : format)
  end.uniq.each do |path|
    format = path.extname.delete_prefix(?.)
    raise "unrecognised format: #{path}" if format.empty?
    raise "unrecognised format: #{format}" unless Formats === format
    raise "already a directory: #{path}" if path.directory?
    raise "file already exists: #{path}" if path.exist? && !overwrite
    raise "no such directory: #{path.parent}" unless path.parent.directory?
  end.tap do |paths|
    map = svg_path ? Map.from_svg(archive, svg_path) : Map.load(archive)
    map.render *paths, **options
  end
end

def scrape(url, path, coords: nil, name: nil, epsg: nil, paginate: nil, concat: nil, **options)

def scrape(url, path, coords: nil, name: nil, epsg: nil, paginate: nil, concat: nil, **options)
  flags  = %w[-skipfailures]
  flags += %W[-t_srs epsg:#{epsg}] if epsg
  flags += %W[-nln #{name}] if name
  format_flags = case path.to_s
  when Shapefile::Source then %w[-update -overwrite]
  when /\.sqlite3?$/     then %w[-f SQLite -dsco SPATIALITE=YES]
  when /\.db$/           then %w[-f SQLite -dsco SPATIALITE=YES]
  when /\.gpkg$/         then %w[-f GPKG]
  when /\.tab$/          then ["-f", "MapInfo File"]
  else                        ["-f", "ESRI Shapefile", "-lco", "ENCODING=UTF-8"]
  end
  options.merge! case path.to_s
  when /\.sqlite3?$/ then { mixed: concat, launder: true }
  when /\.db$/       then { mixed: concat, launder: true }
  when /\.gpkg$/     then { mixed: concat, launder: true }
  when /\.tab$/      then { }
  else                    { truncate: 10 }
  end
  options[:geometry] = GeoJSON.multipoint(coords).bbox if coords
  log_update "nswtopo: contacting server"
  layer = ArcGIS::Service.new(url).layer(**options)
  queue = Queue.new
  thread = Thread.new do
    while page = queue.pop
      *, status = Open3.capture3 *%W[ogr2ogr #{path} /vsistdin/], *flags, *format_flags, stdin_data: page.to_json
      format_flags = %w[-update -append]
      queue.close unless status.success?
    end
    status
  end
  total_features, percent = "%i feature%s", "%%.%if%%%%"
  Enumerator.new do |yielder|
    hold, ok, count = [], nil, 0
    layer.paged(per_page: paginate).tap do
      total_features %= [layer.count, (?s unless layer.count == 1)]
      percent %= layer.count < 1000 ? 0 : layer.count < 10000 ? 1 : 2
      log_update "nswtopo: retrieving #{total_features}"
    end.each do |page|
      log_update "nswtopo: retrieving #{percent} of #{total_features}" % [100.0 * (count += page.count) / layer.count]
      next hold << page if concat
      next yielder << page if ok
      next hold << page if page.all? do |feature|
        feature.properties.values.any?(&:nil?)
      end
      yielder << page
      ok = true
    end
    next hold.inject(yielder, &:<<) if ok && !concat
    next yielder << hold.inject(&:merge!) if hold.any?
  end.inject(queue) do |queue, page|
    queue << page
  rescue ClosedQueueError
    break queue
  end.close
  log_update "nswtop: saving #{total_features}"
  raise "error while saving features" unless thread.value&.success?
  log_success "saved #{total_features}"
rescue ArcGIS::Layer::NoLayerError
  raise OptionParser::InvalidArgument, "specify an ArcGIS layer in URL or with --layer"
rescue ArcGIS::Map::NoUniqueFieldError
  raise OptionParser::InvalidOption, "--unique required for this layer"
rescue ArcGIS::Renderer::NoGeometryError
  raise OptionParser::InvalidOption, "--coords not available for this layer"
rescue ArcGIS::Query::UniqueFieldError
  raise OptionParser::InvalidOption, "--unique not available for this layer"
rescue ArcGIS::Service::InvalidURLError
  raise OptionParser::InvalidArgument, url
end

def spot_heights(archive, dem_path, **options)

def spot_heights(archive, dem_path, **options)
  add archive, "spot-heights", **options, path: Pathname(dem_path)
end