// SPDX-License-Identifier: Apache-2.0

package firrtl.graph

import scala.collection.mutable

/**
  * Implement a really simple graphviz dot renderer for a digraph
  * There are three main renderers currently
  * -
  *
  * @param diGraph   The DiGraph to be rendered
  * @param graphName Name of the graph, when shown in viewer
  * @param rankDir   Graph orientation, default is LR (left to right), most common alternative is TB (top to bottom)
  * @tparam T        The type of the Node.
  */
class RenderDiGraph[T <: Any](diGraph: DiGraph[T], graphName: String = "", rankDir: String = "LR") {

  /**
    * override this to change the default way a node is displayed. Default is toString surrounded by double quotes
    * This example changes the double quotes to brackets
    * {{{
    *   override def renderNode(node: String): String = { "[" + node + "]" }
    * }}}
    */
  def renderNode(node: T): String = {
    s""""${node.toString}""""
  }

  /**
    * This finds a loop in a DiGraph if one exists and returns nodes
    * @note there is no way to currently to specify a particular loop
    * @return
    */
  def findOneLoop: Set[T] = {
    var path = Seq.empty[T]

    try {
      diGraph.linearize
    } catch {
      case cyclicException: CyclicException =>
        val node = cyclicException.node.asInstanceOf[T]
        path = diGraph.findLoopAtNode(node)

      case t: Throwable =>
        throw t

    }
    path.toSet
  }

  /**
    * Searches a DiGraph for a cycle. The first one found will be rendered as a graph that contains
    * only the nodes in the cycle plus the neighbors of those nodes.
    * @return a string that can be used as input to the dot command, string is empty if no loop
    */
  def showOnlyTheLoopAsDot: String = {
    // finds the loop

    val loop = findOneLoop

    if (loop.nonEmpty) {

      // Find all the children of the nodes in the loop
      val childrenFound = diGraph.getEdgeMap.flatMap {
        case (node, children) if loop.contains(node) => children
        case _                                       => Seq.empty
      }.toSet

      // Create a new DiGraph containing only loop and direct children or parents
      val edgeData = diGraph.getEdgeMap
      val newEdgeData = edgeData.flatMap {
        case (node, children) =>
          if (loop.contains(node)) {
            Some(node -> children)
          } else if (childrenFound.contains(node)) {
            Some(node -> children.intersect(loop))
          } else {
            val newChildren = children.intersect(loop)
            if (newChildren.nonEmpty) {
              Some(node -> newChildren)
            } else {
              None
            }
          }
      }

      val justLoop = DiGraph(newEdgeData)
      val newRenderer = new RenderDiGraph(justLoop, graphName, rankDir) {
        override def renderNode(node: T): String = {
          super.renderNode(node)
        }
      }
      newRenderer.toDotWithLoops(loop, getRankedNodes)
    } else {
      ""
    }
  }

  /**
    * Convert this graph into input for the graphviz dot program
    * @return A string representation of the digraph in dot notation
    */
  def toDot: String = {
    val s = new mutable.StringBuilder()

    s.append(s"digraph $graphName {\n")
    s.append(s""" rankdir="$rankDir";""" + "\n")

    val edges = diGraph.getEdgeMap

    edges.foreach {
      case (parent, children) =>
        children.foreach { child =>
          s.append(s"""  ${renderNode(parent)} -> ${renderNode(child)};""" + "\n")
        }
    }
    s.append("}\n")
    s.toString
  }

  /**
    * Convert this graph into input for the graphviz dot program, but with  a
    * loop,if present, highlighted in red.
    * @return string that is a graphviz digraph, but with loops highlighted
    */
  def toDotWithLoops(loopedNodes: Set[T], rankedNodes: mutable.ArrayBuffer[Seq[T]]): String = {
    val s = new mutable.StringBuilder()
    val allNodes = new mutable.HashSet[T]

    s.append(s"digraph $graphName {\n")
    s.append(s""" rankdir="$rankDir";""" + "\n")

    val edges = diGraph.getEdgeMap

    edges.foreach {
      case (parent, children) =>
        allNodes += parent
        allNodes ++= children

        children.foreach { child =>
          val highlight = if (loopedNodes.contains(parent) && loopedNodes.contains(child)) {
            "[color=red,penwidth=3.0]"
          } else {
            ""
          }
          s.append(s"""  ${renderNode(parent)} -> ${renderNode(child)}$highlight;""" + "\n")
        }
    }

    val paredRankedNodes = rankedNodes.flatMap { nodes =>
      val newNodes = nodes.filter(allNodes.contains)
      if (newNodes.nonEmpty) { Some(newNodes) }
      else { None }
    }

    paredRankedNodes.foreach { nodesAtRank =>
      s.append(s"""  { rank=same; ${nodesAtRank.map(renderNode).mkString(" ")} };""" + "\n")
    }

    s.append("}\n")
    s.toString
  }

  /**
    * Creates a series of Seq of nodes for each minimum depth that those
    * are from the sources of this graph.
    * @return
    */
  private def getRankedNodes: mutable.ArrayBuffer[Seq[T]] = {
    val alreadyVisited = new mutable.HashSet[T]()
    val rankNodes = new mutable.ArrayBuffer[Seq[T]]()

    def walkByRank(nodes: Seq[T], rankNumber: Int = 0): Unit = {
      rankNodes.append(nodes)

      alreadyVisited ++= nodes

      val nextNodes = nodes.flatMap { node =>
        diGraph.getEdges(node)
      }.filterNot(alreadyVisited.contains).distinct

      if (nextNodes.nonEmpty) {
        walkByRank(nextNodes, rankNumber + 1)
      }
    }

    walkByRank(diGraph.findSources.toSeq)
    rankNodes
  }

  /**
    * Convert this graph into input for the graphviz dot program.
    * It tries to align nodes in columns based
    * on their minimum distance to a source.
    * Can also be faster and better behaved on large graphs
    * @return string that is a graphviz digraph
    */
  def toDotRanked: String = {
    val s = new mutable.StringBuilder()
    val alreadyVisited = new mutable.HashSet[T]()
    val rankNodes = new mutable.ArrayBuffer[Seq[T]]()

    def walkByRank(nodes: Seq[T], rankNumber: Int = 0): Unit = {
      rankNodes.append(nodes)

      alreadyVisited ++= nodes

      val nextNodes = nodes.flatMap { node =>
        val children = diGraph.getEdges(node)

        s.append(s"""  ${renderNode(node)} -> { ${children.map(renderNode).mkString(" ")} };""" + "\n")

        children
      }.filterNot(alreadyVisited.contains).distinct

      if (nextNodes.nonEmpty) {
        walkByRank(nextNodes, rankNumber + 1)
      }
    }

    s.append(s"digraph {\n")
    s.append(s""" rankdir="$rankDir";""" + "\n")

    walkByRank(diGraph.findSources.toSeq)
    rankNodes.foreach { nodesAtRank =>
      s.append(s"""  { rank=same; ${nodesAtRank.map(renderNode).mkString(" ")} };""" + "\n")
    }
    s.append("}\n")
    s.toString
  }
}
