# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

module VagrantPlugins
  module CommandServe
    class Mappers
      module Internal
        class Graph
          # Provides searching of a graph to determine if a
          # destination vertex is accessible from a given
          # root vertex.
          class Search
            class NoPathError < StandardError; end
            class InvalidVertex < StandardError
              attr_reader :vertex
              def initialize(v)
                super("Invalid vertex within path")
                @vertex = v
              end
            end

            include Util::HasLogger

            attr_reader :graph

            # Create a new Search instance
            #
            # @param graph [Graph] Graph used for searching
            def initialize(graph:)
              @m = Mutex.new
              @graph = graph
              @root = nil
            end

            # Generate a path from a given source vertex
            #
            # @param src [Vertex] Source vertex
            # @return [Array<Vertex>] path from source to destination
            # @raises [NoPathError] when no path can be determined
            def path(src, dst)
              @m.synchronize do
                logger.debug { "finding path #{src} -> #{dst}" }

                # Mark the root of our graph which will be used later
                @root = src

                # When searching for a path in the graph, we will be looking
                # for the shortest path. Since we know the desired final
                # destination, we want to use that as our starting point
                # and attempt to find a path from that destination vertex
                # to the source vertex. So we start by reversing the graph
                graph.reverse!

                # Perform an initial DFS to prune the graph down. This will remove any
                # vertices which are not connected to the root of the graph (which will
                # be the destination since we reversed the graph). The reason we do this
                # is to reduce the overall size of the graph before generating our path,
                # and to ensure that the both ends of the graph are still available after
                # the pruning.
                vertices = []
                descender = lambda { |v|
                  graph.depth_first_visit(v) { |vrt|
                    vertices << vrt
                  }
                  vertices.uniq!
                  vertices.each do |vrt|
                    if vrt.incoming_edges_required
                      graph.out_vertices(vrt).each do |ov|
                        if !vertices.include?(ov)
                          descender.call(ov)
                        end
                      end
                    end
                  end
                }

                # Since the destination is now the "root" of our reversed graph,
                # we want to start our search from there to generate the list
                # of verticies it can connect to.
                descender.call(dst)

                # Remove excess vertices from the graph
                (graph.vertices - vertices).each { |v|
                  graph.remove_vertex(v)
                }

                logger.trace { "graph after DFS reduction:\n#{graph.reverse.inspect}" }
                logger.trace { "generating list of required vertices for path #{src} -> #{dst}" }

                # If the graph no longer contains the source vertex (this is the root of the
                # non-reversed graph) then it is impossible to generate a valid path.
                if !graph.vertices.include?(src)
                  raise NoPathError,
                    "Graph no longer includes source vertex #{src} (#{src} -> #{dst})"
                end

                # This should not happen since we start our DFS from the destination vertex
                # but we keep this sanity check regardless.
                if !graph.vertices.include?(dst)
                  raise NoPathError,
                    "Graph no longer includes destination vertex #{dst} (#{src} -> #{dst})"
                end
                # Generate list of required vertices from the graph
                required_vertices = generate_path(dst, src)

                # If no vertices were returned, then the path generation failed
                if required_vertices.nil?
                  raise NoPathError,
                    "Path generation failed to reach destination (#{src} -> #{dst&.type&.inspect})"
                end

                logger.trace { "required vertices list generation complete for path #{src} -> #{dst}" }

                # Now that we have a path, remove all extraneous vertices
                # from the graph. We're going to use the graph to provide
                # us a proper ordering of the vertices below
                (graph.vertices - required_vertices).each do |vrt|
                  graph.remove_vertex(vrt)
                end

                # Reverse the graph so the direction of the graph is in its
                # original state.
                graph.reverse!

                # Check if the graph is acyclic. If it is not, break any cycles found in the graph.
                graph.break_cycles!(src) if !graph.acyclic?

                logger.debug { "graph after acyclic breakage:\n#{graph.reverse.inspect}" }

                # Apply topological sort to the graph so we have
                # a proper order for execution. This is required
                # to ensure that all inputs for a given vertex are
                # available before executing it.
                result = Array.new.tap do |path|
                  t = graph.topsort_iterator
                  until t.at_end?
                    path << t.forward
                  end
                end

                # If the first vertex of the path is not the expected source,
                # or the last vertex is not the expected destination, then
                # a valid path was not found. Even though we generated our
                # list of required vertices, this can still occur if the
                # resultant graph included any cycles, and breaking the cycle(s)
                # resulted in orphaned vertices.
                if result.first != src
                  raise NoPathError,
                    "Initial vertex is not source #{src} != #{result.first}"
                end

                if result.last != dst
                  raise NoPathError,
                    "Final vertex is not destination #{dst} != #{result.last}"
                end

                result
              end
            rescue NoPathError
              if ENV["VAGRANT_LOG_MAPPER"].to_s != ""
                begin
                  require 'rgl/dot'
                  graph.reverse.write_to_graphic_file('jpg', 'graph-no-path')
                rescue
                  #
                end
              end
              raise
            end

            protected

            # Generate a list of required vertices for making a
            # path from the given source vertex to the given
            # destination vertex.
            #
            # @param src [Vertex] source vertex
            # @param dst [Vertex] destination vertex
            # @return [Array<Vertex>, NilClass]
            def generate_path(src, dst)
              begin
                # Find the shortest path based edge weights. Since
                # we are generating a path against the reversed graph,
                # the source is our desired destination vertex and the
                # destination is the source vertex.
                path = graph.shortest_path(src, dst)
                logger.trace {
                  o = Array(path).reverse.map { |v|
                    "  #{v} ->"
                  }.join("\n")
                  "path generation #{dst} -> #{src}\n#{o}"
                }
                if path.nil?
                  raise NoPathError,
                    "Path generation failed to reach destination (#{dst} -> #{src})"
                end
                # Once we have the path, we need to expand the
                # path to ensure that all vertices in the path
                # are fully reachable, and if any vertices are
                # missing that they are added
                expand_path(path, dst, graph)
              rescue InvalidVertex => err
                # An invalid vertex will be flagged when path expansion exposes
                # a vertex which requires all incoming edges and all the edges
                # cannot reach the destination vertex. When this happens, we
                # remove that vertex from the graph and then retry the path
                # generation.
                logger.trace { "invalid vertex in path, removing (#{err.vertex})" }
                graph.remove_vertex(err.vertex)
                retry
              end
            end

            # Expand a given path of vertices by iterating through
            # the provided path and validating all required vertices
            # are in the path for any vertices which require all incoming
            # edges. The result will be the list of original vertices
            # plus all additional vertices found to be missing.
            #
            # @param path [Array<Vertex>] current path
            # @param dst [Vertex]
            def expand_path(path, dst, graph)
              new_path = path.dup
              path.each do |v|
                # Only inspect vertices that require all
                # incoming edges
                next if !v.incoming_edges_required
                logger.trace { "validating incoming edges for vertex #{v}" }
                # Since we are using a reversed graph, the incoming edges are now
                # outgoing edges, so we graph the out vertices
                outs = graph.out_vertices(v)
                # Make a clone of the graph so we can modify it without affecting
                # the original
                g = graph.clone
                # Remove the original vertex we are currently inspecting from
                # the cloned graph. This is done to prevent generating a cycle
                # where the vetex is required in the path to reach the destination
                g.remove_vertex(v)
                # Now we find the path from each vertex to the destination
                outs.each do |src|
                  # Since inputs can support named arguments, temporarily update
                  # weights of value vertices.
                  ipath = reweight_for(src, g) do |ig|
                    ig.shortest_path(src, dst)
                  end
                  # Remove any other incoming edges to this input
                  # (graph.out_vertices(src) - [ipath.first]).each do |dv|
                  #   graph.remove_edge(src, dv)
                  # end

                  # If no path was found an exception is raised that the vertex
                  # in the path (this is the original vertex from the first loop)
                  # is not valid within the path.
                  if ipath.nil? || ipath.empty?
                    logger.trace { "failed to find validating path from #{dst} -> #{src}" }
                    raise InvalidVertex.new(v)
                  else
                    logger.trace { "found validating path from #{dst} -> #{src}" }
                  end
                  # Remove any vertices that already exist in our final collection
                  # so we don't duplicate the inspection on them
                  ipath = ipath - new_path
                  if !ipath.empty?
                    # Since we have new vertices we need to expand them to
                    # ensure that they all have valid paths to the destination.
                    # The graph provided here is our modified clone to prevent
                    # a cycle where the original vertex is required
                    ipath = expand_path(ipath, dst,  g)
                    # And now we add any new vertices which were discovered to
                    # be required
                    new_path |= ipath
                  end
                end
                logger.trace { "incoming edge validation complete for vertex #{v}" }
              end
              new_path
            end

            # Modify the weights of value type vertices when the given
            # vertex is a Vertex::Input and has a name defined. All
            # value vertices will have their weight reset to the default
            # value weight, and if a Vertex::NamedValue exists with a
            # matching name, it will have the NAMED_VALUE_WEIGHT
            # applied. After the given block is executed, the value
            # vertices will have their original weights re-applied.
            #
            # Since value vertices are identified by type, only
            # one value of any type may exist in the graph. However,
            # if the value is named, then multiple values of the same
            # type may exist in the graph. For instance, with named
            # values two strings could be provided, one named "local_path"
            # and another named "remote_path". If a mapper function is
            # only interested in the value of the "remote_path", it
            # can include that name within its input definition. Then
            # this method can be used to prefer the name string argument
            # "remote_path" over the "local_path" named argument, or
            # just a regular string value.
            #
            # @param vertex [Vertex] source vertex
            # @param graph [Graph] graph to modify
            # @yieldparam [Graph] modified graph (passed instance, not a copy)
            # @yield block to execute
            # @return [Object] result of block
            def reweight_for(vertex, graph)
              original_weights = {}
              begin
                if vertex.is_a?(Vertex::Input) && vertex.name
                  graph.each_vertex do |v|
                    next if !v.is_a?(Vertex::Value)
                    original_weights[v] = v.weight
                    if v.name.to_s == vertex.name.to_s
                      v.weight = Mappers::NAMED_VALUE_WEIGHT
                    else
                      v.weight = Mappers::VALUE_WEIGHT
                    end
                  end
                end
                yield graph
              ensure
                # Set the weights of the value vertices back to
                # their original values
                original_weights.each do |v, w|
                  v.weight = w
                end
              end
            end
          end
        end
      end
    end
  end
end
