require 'csv'
require 'json'
require 'yaml'

module MB
  module Geometry
    # Methods for generating lists of points (random points, polygons, etc.).
    module Generators
      class << self
        # Returns an array of vertices (two-element arrays) representing a regular
        # polygon with +sides+ sides, having a radius of +radius+, with the first
        # point on the right side of the X axis (plus +:rotation+ radians) and
        # proceeding counterclockwise.
        def regular_polygon(sides, radius, rotation: 0)
          points = []

          sides.times do |s|
            angle = rotation + s * 2.0 * Math::PI / sides
            x = radius * Math.cos(angle)
            y = radius * Math.sin(angle)
            points << [x, y]
          end

          points
        end

        # Returns an array of +count+ points on a line segment between +p1+ and
        # +p2+, which should be two-element numeric arrays.  If +count+ is 1,
        # then the segment midpoint is returned.
        def segment(count, p1, p2)
          if count == 2
            return [p1, p2]
          end

          x1, y1 = p1
          x2, y2 = p2

          function(count) { |t|
            [ x2 * t + x1 * (1 - t), y2 * t + y1 * (1 - t) ]
          }
        end

        # Returns an array of points generated by passing +count+ values
        # ranging from +tmin+ to +tmax+ to a block.  If +count+ is 1, then the
        # function will be evaluated half-way between +tmin+ and +tmax+.
        #
        # Example:
        #     # Generate 10 points on a parabola
        #     function(10, tmax: 9) { |t| [t, t * t] }
        def function(count, tmin: 0.0, tmax: 1.0)
          raise "A block must be provided that returns x and y given t" unless block_given?

          if count == 1
            value = yield ((tmin + tmax) / 2.0)
            return [value]
          end

          count.times.map { |idx|
            yield idx * (tmax - tmin).to_f / (count - 1) + tmin
          }
        end

        # Returns an Array of randomly generated 2D points within a specified
        # region, using the given pseudorandom number generator.  If Integers
        # are given for both sides of a range, then that range will only
        # generate Integers.
        def random_points(count, xmin: -1.0, xmax: 1.0, ymin: -1.0, ymax: 1.0, random: Random)
          xrange = xmin..xmax
          yrange = ymin..ymax

          count.times.map {
            [ random.rand(xrange), random.rand(yrange) ]
          }
        end

        # Loads points or a generation spec from a given file, detecting file type
        # by extension.  Do not load untrusted YAML.
        #
        # Yields the loaded data before generating points, if a block is given.
        #
        # See #generate for examples of the Hash syntax.
        def generate_from_file(filename)
          case File.extname(filename)
          when '.yaml', '.yml', '.YAML', '.YML'
            data = YAML.load(File.read(filename), filename: filename, symbolize_names: true)

          when '.json', '.JSON'
            data = JSON.parse(File.read(filename), symbolize_names: true)

          when '.csv', '.CSV'
            data = CSV.read(filename, converters: :numeric)

          else
            raise "Unsupported extension on file #{filename.inspect}"
          end

          # TODO: Need a better way of getting more than just the Array of
          # points back from files that have extra data
          yield data if block_given?

          case data
          when Array
            generate(points: data)

          when Hash
            generate(data)

          else
            raise "Unsupported type #{data.class} loaded from #{filename.inspect}"
          end
        end

        # Returns an Array of points generated from the given +spec+, which is a
        # Hash describing how to generate points:
        #
        # {
        #   generator: :polygon, # see MB::Geometry::Generators.regular_polygon
        #   sides: 4,
        #   radius: 0.5, # optional, default is 0.5
        #   aspect: 1.0, # optional, width is multiplied by aspect
        #   rotate: 45,  # optional, degrees
        #   translate: [0, 0], # optional shift
        #   clockwise: false, # optional reversal
        #   colors: [      # optional, colors will be cycled if excess points are given
        #     [1, 1, 1, 1]
        #   ],
        #   names: [       # optional
        #     'A',
        #     'B',
        #     'C',
        #     'D'
        #   ],
        #   anneal: 0,     # optional, number of times to move points to polygon centers
        #   bounding_box:, # optional, area bounding box to use during annealing
        #   shuffle: false, # optional, shuffles points if true (changes generated colors)
        # }
        #
        # {
        #   generator: :segment, # See MB::Geometry::Generators.segment
        #   count: 5,
        #   from: [1, 1],
        #   to: [5, 9],
        # }
        #
        # {
        #   generator: :random, # see MB::Geometry::Generators.random_points
        #   count: 12,
        #   seed: 0,
        #   xmin: -1,      # optional, default is -1
        #   xmax: 1,       # optional, default is 1
        #   ymin: -1,      # optional, default is -1
        #   ymax: 1,       # optional, default is 1
        #   anneal: 0,     # optional, number of times to move points to polygon centers
        #   bounding_box:, # optional, area bounding box to use during annealing
        #   colors: [...], # optional
        #   names: [...],  # optional
        # }
        #
        # {
        #   generator: :points, # May be omitted
        #   points: [
        #     { x: 0, y: 1, name: 'A', color: [0, 1, 0, 1] },
        #     { x: 1, y: 0, name: 'B', color: [1, 0, 0, 1] },
        #     { x: 1, y: 0, name: 'C', color: [0, 1, 1, 0] },
        #     [ -1, -1, 'D' ]
        #   ],
        #   colors: [...], # optional; point color takes precedence
        #   names: [...], # optional; point name takes precedence
        # }
        #
        # {
        #   generator: :multi,
        #   generators: [
        #     { ... },
        #     { points: ... },
        #   ],
        # }
        #
        # {
        #   ...
        #   # Modifiers that apply to all generators
        #   scale: [1.5, 1.25] # can also just be a number
        #   rotate: {
        #     rotate: 45, # degrees
        #     center: [1, -1] # around
        #   },
        #   translate: [-2, 2]
        # }
        def generate(spec)
          raise "Spec must be a Hash" unless spec.is_a?(Hash)

          case spec[:generator]&.to_sym
          when :polygon
            sides = spec[:sides]
            raise "Number of sides must be a non-negative Integer for :polygon" unless sides.is_a?(Integer) && sides >= 0

            radius = spec[:radius] || 0.5
            raise "Radius must be a Numeric for :polygon, if given" unless radius.is_a?(Numeric)

            aspect = spec[:aspect] || 1.0
            raise "Aspect must be a Numeric for :polygon, if given" unless aspect.is_a?(Numeric)

            points = MB::Geometry::Generators.regular_polygon(sides, radius)
            points = points.reverse if spec[:clockwise]
            points = points.map { |p|
              { x: p[0] * aspect, y: p[1] }
            }

          when :segment
            count = spec[:count]
            raise "Segment point count must be a positive Integer for :segment" unless count.is_a?(Integer) && count > 0

            p1 = spec[:from]
            raise "From point must be a two-element Numeric array" unless p1.is_a?(Array) && p1[0..1].all?(Numeric)

            p2 = spec[:to]
            raise "To point must be a two-element Numeric array" unless p1.is_a?(Array) && p1[0..1].all?(Numeric)

            points = MB::Geometry::Generators.segment(count, p1, p2).map { |p|
              { x: p[0], y: p[1] }
            }

          when :grid
            columns = spec[:columns]
            raise "Number of columns must be a positive Integer for :grid" unless columns.is_a?(Integer) && columns > 0

            rows = spec[:rows]
            raise "Number of rows must be a positive Integer for :grid" unless rows.is_a?(Integer) && rows > 0

            odd_offset = spec[:odd_offset] || 0
            raise "Offset for odd rows must be a Numeric for :grid" unless odd_offset.is_a?(Numeric)

            odd_extra = spec[:odd_extra] || 0
            raise "Extra columns for odd rows must be an Integer for :grid" unless odd_extra.is_a?(Integer)

            # TODO: Maybe a declarative way to read and validate parameters that shares code across generators?
            xmin = spec[:xmin] || -1.0
            raise "Xmin must be a Numeric for :grid" unless xmin.is_a?(Numeric)

            ymin = spec[:ymin] || -1.0
            raise "Ymin must be a Numeric for :grid" unless ymin.is_a?(Numeric)

            xmax = spec[:xmax] || 1.0
            raise "Xmax must be a Numeric for :grid" unless xmax.is_a?(Numeric)

            ymax = spec[:ymax] || 1.0
            raise "Ymax must be a Numeric for :grid" unless ymax.is_a?(Numeric)

            points = []

            for row in 0...rows
              offset = row.odd? ? odd_offset : 0
              extra = row.odd? ? odd_extra : 0

              for col in 0...(columns + extra)
                x = MB::M.scale(col + offset, 0..(columns - 1), xmin..xmax)
                y = MB::M.scale(row, 0..(rows - 1), ymax..ymin)

                points << { x: x, y: y }
              end
            end

          when :random
            count = spec[:count]
            raise "Count must be an Integer for :random" unless count.is_a?(Integer)

            # Use an incrementing seed by default for deterministic generation
            seed = spec[:seed] || next_seed
            raise "Seed must be an Integer for :random" unless seed.is_a?(Integer)

            xmin = spec[:xmin] || -1.0
            raise "Xmin must be a Numeric for :random" unless xmin.is_a?(Numeric)

            ymin = spec[:ymin] || -1.0
            raise "Ymin must be a Numeric for :random" unless ymin.is_a?(Numeric)

            xmax = spec[:xmax] || 1.0
            raise "Xmax must be a Numeric for :random" unless xmax.is_a?(Numeric)

            ymax = spec[:ymax] || 1.0
            raise "Ymax must be a Numeric for :random" unless ymax.is_a?(Numeric)

            rnd = Random.new(seed)
            points = MB::Geometry::Generators.random_points(count, xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax, random: rnd).map { |p|
              { x: p[0], y: p[1] }
            }

          when :multi
            raise "Generators must be a list of generators or point arrays" unless spec[:generators].is_a?(Array)

            points = spec[:generators].flat_map { |s|
              if s.is_a?(Array)
                generate(points: s)
              elsif s.is_a?(Hash)
                generate(s)
              else
                raise "Invalid type in list of generators: #{s.class}"
              end
            }

          when nil, :points
            if spec[:points].is_a?(Array)
              points = spec[:points].map { |p|
                if p.is_a?(Array)
                  { x: p[0], y: p[1], name: p[2] }
                else
                  p
                end
              }

            else
              raise "Missing both :generator and :points"
            end
          end

          if spec[:colors]
            raise "Colors must be an Array, if given" unless spec[:colors].is_a?(Array)

            # TODO: Allow using generated colors past the end, instead of looping?
            points.each_with_index do |p, idx|
              p[:color] ||= spec[:colors][idx % spec[:colors].length]
            end
          end

          if spec[:names]
            raise "Names must be an Array, if given" unless spec[:names].is_a?(Array)

            points.each_with_index do |p, idx|
              break if idx >= spec[:names].length
              p[:name] ||= spec[:names][idx]
            end
          end

          if anneal = spec[:anneal]
            raise "Anneal must be an Integer, if given" unless anneal.is_a?(Integer)

            v = MB::Geometry::Voronoi.new(points)

            # TODO: Pass the bounding box into the final MB::Geometry::Voronoi if one is constructed?
            if box = spec[:bounding_box]
              raise "Bounding box must be a 4-element array, if given" unless box.is_a?(Array)

              v.set_area_bounding_box(*box)

              anneal.times do v.anneal end

              v.cells.each_with_index do |c, idx|
                points[idx][:x] = c.x
                points[idx][:y] = c.y
              end
            else
              # 0.64 was tweaked empirically to give a reasonable balance between
              # shrinking and growing graphs.  The #anneal function will ensure the
              # width and height stay the same if there is no user bounding box.
              # This just keeps the outer points from bunching in on the rest.
              xmin = v.x_center - v.width * 0.64
              xmax = v.x_center + v.width * 0.64
              ymin = v.y_center - v.height * 0.64
              ymax = v.y_center + v.height * 0.64

              v.set_area_bounding_box(xmin, ymin, xmax, ymax)
            end

            anneal.times do v.anneal(scale: !spec[:bounding_box]) end

            v.cells.each_with_index do |c, idx|
              points[idx][:x] = c.x
              points[idx][:y] = c.y
            end
          end

          if scale = spec[:scale]
            case scale
            when Numeric
              xscale = scale
              yscale = scale

            when Array
              raise "Scale must be numeric or a two-element numeric Array, if given" unless scale.length == 2 && scale.all?(Numeric)
              xscale, yscale = scale

            else
              raise "Scale must be numeric or a two-element numeric Array, if given"
            end

            points.each do |p|
              p[:x] *= xscale
              p[:y] *= yscale
            end
          end

          if rotate = spec[:rotate]
            case rotate
            when Numeric
              rotation = rotate
              xcenter = 0
              ycenter = 0

            when Hash
              rotation = rotate[:rotate] || 0
              xcenter, ycenter = rotate[:center]
              xcenter ||= 0
              ycenter ||= 0

            else
              raise "Rotate must be a number of degrees, or a Hash with :rotate (degrees) and :center (x, y)"
            end

            matrix = MB::Geometry.rotation_matrix(radians: rotation, xcenter: xcenter, ycenter: ycenter)

            points.each do |p|
              v = Vector[p[:x], p[:y], 1]
              v = matrix * v
              p[:x] = v[0]
              p[:y] = v[1]
            end
          end

          if translate = spec[:translate]
            unless translate.is_a?(Array) && translate.length == 2 && translate.all?(Numeric)
              raise "Translate must be a two-element numeric Array, if given"
            end

            x, y = translate

            points.each do |p|
              p[:x] += x
              p[:y] += y
            end
          end

          # TODO: apply scaling/rotation/translation in the order given in the file?

          if shuffle = spec[:shuffle]
            raise "Shuffle must be true or false, if given" unless shuffle == true || shuffle == false

            # Use an incrementing seed by default for deterministic generation
            seed = spec[:seed] || next_seed
            raise "Seed must be an Integer for :shuffle" unless seed.is_a?(Integer)

            random = Random.new(seed)

            prior_points = points.dup
            5.times do
              points.shuffle!(random: random)
              break if points.length <= 1 || points != prior_points
            end
          end

          points
        end

        def next_seed
          @@seed ||= 0
          @@seed += 1
        end
      end
    end

    Gen = Generators
  end
end
