import type { JsonEdge, Node } from '@api-client/graph/graph/types.js'
import { Graph } from '@api-client/graph/graph/Graph.js'
import type { DataDomainGraph, DomainGraphEdge, DomainGraphNodeType, SerializedGraph } from './types.js'
import {
  DomainAssociationKind,
  DomainEntityKind,
  DomainModelKind,
  DomainNamespaceKind,
  DomainPropertyKind,
} from '../models/kinds.js'
import { DomainNamespace, type DomainNamespaceSchema } from './DomainNamespace.js'
import type { DataDomain } from './DataDomain.js'
import { DomainModel, type DomainModelSchema } from './DomainModel.js'
import { DomainEntity, DomainEntitySchema } from './DomainEntity.js'
import { DomainProperty, DomainPropertySchema } from './DomainProperty.js'
import { DomainAssociation, DomainAssociationSchema } from './DomainAssociation.js'

interface GraphMergeResult {
  /**
   * The set of node ids generated for the foreign nodes.
   */
  nodes: Set<string>
  /**
   * The set of edge ids generated for the foreign edges.
   */
  edges: Set<string>
}

function writeNodes(g: DataDomainGraph, domainKey: string): Node<object>[] {
  const result: Node<object>[] = []
  for (const v of g.nodes()) {
    const nodeValue = g.node(v)
    if (!nodeValue || nodeValue.domain.key !== domainKey) {
      // Filter out foreign nodes
      continue
    }
    // We do only use single parent for the graph. Potentially this could be
    // replaced with the `g.parent(v)` method.
    const parents = [...g.parents(v)]
    const node: Node<object> = { v }
    node.value = nodeValue.toJSON()
    if (parents.length) {
      node.parents = parents
    }
    result.push(node)
  }
  return result
}

function writeEdges(g: DataDomainGraph, domainKey: string): JsonEdge<DomainGraphEdge>[] {
  const result: JsonEdge<DomainGraphEdge>[] = []
  for (const e of g.edges()) {
    const edgeValue = g.edge(e)
    if (!edgeValue) {
      continue
    }
    const sourceNode = g.node(e.v)
    const targetNode = g.node(e.w)
    if (!sourceNode || !targetNode) {
      continue
    }
    if (sourceNode.domain.key !== domainKey) {
      // Filter out edges starting from foreign nodes. These are foreign associations
      // that have nothing to do with the current domain.
      continue
    }
    if (targetNode.domain.key !== domainKey && edgeValue.type !== 'association' && edgeValue.type !== 'parent') {
      // Filter out edges to foreign nodes except for associations and parent-child relations
      continue
    }
    const edge: JsonEdge<DomainGraphEdge> = { v: e.v, w: e.w }
    if (e.name) {
      edge.name = e.name
    }
    edge.value = structuredClone(edgeValue)
    result.push(edge)
  }
  return result
}

export function serialize(g: DataDomainGraph, domainKey: string): SerializedGraph {
  const json: SerializedGraph = {
    options: {
      directed: g.isDirected(),
      multigraph: g.isMultigraph(),
      compound: g.isCompound(),
    },
    nodes: writeNodes(g, domainKey),
    edges: writeEdges(g, domainKey),
  }
  return json
}

/**
 * Removes all foreign nodes, edges, and parents from the graph.
 * @param g The graph to remove the foreign nodes from
 * @param domainKey The key of the domain to remove
 */
export function removeForeignGraph(g: DataDomainGraph, domainKey: string): void {
  for (const node of g.nodes()) {
    const nodeValue = g.node(node)
    if (!nodeValue) {
      continue
    }
    if (nodeValue.domain.key === domainKey) {
      // This is a foreign node, remove it from the graph.
      g.removeNode(node)
      // When a node is removed, all edges and parents are removed as well.
      // No need to iterate over them.
    }
  }
}

/**
 * Merges a foreign domain graph into the current domain graph.
 * @param own Our domain graph
 * @param foreign The foreign domain graph
 * @param domain The key of the foreign domain
 */
export function mergeGraph(own: DataDomainGraph, foreign: DataDomainGraph, domain: string): GraphMergeResult {
  const nodes = new Set<string>()
  const edges = new Set<string>()
  // Copy nodes
  for (const node of foreign.nodes()) {
    const foreignNode = foreign.node(node)
    if (!foreignNode) {
      continue
    }
    const foreignKey = `${domain}:${node}`
    own.setNode(foreignKey, foreignNode)
    nodes.add(foreignKey)
  }
  // Copy edges
  for (const edge of foreign.edges()) {
    const label = foreign.edge(edge)
    if (!label) {
      continue
    }
    const foreignSource = `${domain}:${edge.v}`
    const foreignTarget = `${domain}:${edge.w}`
    // const foreignEdge = foreign.edge(edge)
    own.setEdge(foreignSource, foreignTarget, {
      foreign: true,
      domain,
      ...label,
    })
    edges.add(`${foreignSource}|${foreignTarget}`)
  }
  const notRootEntities = new Set<string>()
  // Copy parent-child relationships
  for (const node of foreign.nodes()) {
    const foreignNodeKey = `${domain}:${node}`
    for (const parent of foreign.parents(node)) {
      const foreignParentKey = `${domain}:${parent}`
      const srcNode = own.node(foreignNodeKey) as DomainGraphNodeType
      const targetNode = own.node(foreignParentKey) as DomainGraphNodeType
      if (srcNode.kind === DomainEntityKind && targetNode.kind === DomainEntityKind) {
        // entities can have multiple parents between them. We need to respect that.
        if (!notRootEntities.has(foreignNodeKey)) {
          // We use `setParent` in the first run to remove the entity from the root
          // of the graph.
          notRootEntities.add(foreignNodeKey)
          own.setParent(foreignNodeKey, foreignParentKey)
        } else {
          own.addParent(foreignNodeKey, foreignParentKey)
        }
      } else {
        // other nodes can only have a single parent.
        own.setParent(foreignNodeKey, foreignParentKey)
      }
    }
  }
  return {
    nodes,
    edges,
  }
}

/**
 * Properties and associations are stored as nodes with edges to the parent entity in the graph.
 * At the time of restoring these nodes, the graph has no edges yet.
 * This function finds the parent of the child node in the edges list.
 * @param child The DomainProperty or DomainAssociation key
 * @param edges The list of serialized graph edges
 * @returns The parent key of the child or undefined if not found
 */
function findEdgeParent(child: string, edges: JsonEdge<DomainGraphEdge>[]): string | undefined {
  for (const edge of edges) {
    if (edge.w === child) {
      return edge.v
    }
  }
  return undefined
}

/**
 * Restores the previously serialized node entry.
 * @param root The DataDomain instance to use as the root for the graph
 * @param entry The node entry to restore
 * @param edges The list of serialized graph edges
 * @returns The restored node instance
 * @throws Error if the entry is malformed or the kind is unknown
 */
function prepareNode(root: DataDomain, entry: unknown, edges: JsonEdge<DomainGraphEdge>[]): DomainGraphNodeType {
  if (!entry) {
    throw new Error(`Unable to restore data domain graph. Malformed node entry`)
  }
  const domainElement = entry as { kind?: string }
  if (!domainElement.kind) {
    throw new Error(`Unable to restore data domain graph. Malformed node entry`)
  }
  if (domainElement.kind === DomainNamespaceKind) {
    return new DomainNamespace(root, domainElement as DomainNamespaceSchema)
  }
  if (domainElement.kind === DomainModelKind) {
    return new DomainModel(root, domainElement as DomainModelSchema)
  }
  if (domainElement.kind === DomainEntityKind) {
    return new DomainEntity(root, domainElement as DomainEntitySchema)
  }
  if (domainElement.kind === DomainPropertyKind) {
    const typed = domainElement as DomainPropertySchema
    const parent = findEdgeParent(typed.key, edges)
    if (!parent) {
      throw new Error(`Unable to restore data domain graph. Malformed node entry`)
    }
    return new DomainProperty(root, parent, typed)
  }
  if (domainElement.kind === DomainAssociationKind) {
    const typed = domainElement as DomainAssociationSchema
    const parent = findEdgeParent(typed.key, edges)
    if (!parent) {
      throw new Error(`Unable to restore data domain graph. Malformed node entry`)
    }
    return new DomainAssociation(root, parent, typed)
  }
  throw new Error(`Unable to restore data domain graph. Unknown node kind ${domainElement.kind}`)
}

/**
 * To deserialize a graph:
 *
 * - Create a new DataDomain instance
 * - Restore all foreign nodes and edges
 * - Call the deserialize function
 *
 * @param root The DataDomain instance to use as the root for the graph
 * @param json The previously serialized graph
 * @returns Deserialized graph instance
 */
export function deserialize(root: DataDomain, json?: SerializedGraph, dependencies?: DataDomain[]): DataDomainGraph {
  const g = new Graph<unknown, DomainGraphNodeType, DomainGraphEdge>({
    compound: true,
    multigraph: true,
    directed: true,
  })
  let foreignNodes = new Set<string>()
  let foreignEdges = new Set<string>()
  if (dependencies) {
    for (const dependency of dependencies) {
      const result = mergeGraph(g, dependency.graph, dependency.key)
      if (result.edges.size) {
        foreignEdges = new Set([...foreignEdges, ...result.edges])
      }
      if (result.nodes.size) {
        foreignNodes = new Set([...foreignNodes, ...result.nodes])
      }
      root.dependencies.set(dependency.key, dependency)
    }
  }
  if (!json) {
    return g
  }
  if (Array.isArray(json.nodes)) {
    for (const entry of json.nodes) {
      g.setNode(entry.v, prepareNode(root, entry.value, json.edges))
      if (entry.parents) {
        for (const parent of entry.parents) {
          // In data domain graph, all nodes that can have parents can only have a single parent.
          // It's the business logic of the library.
          // Parent-child relationships:
          // - Namespace -> Namespace
          // - Namespace -> Model
          // - Model -> Entity
          // Entities and Association are associated with the parent entity through edges.
          g.setParent(entry.v, parent)
        }
      }
    }
  }
  if (Array.isArray(json.edges)) {
    for (const entry of json.edges) {
      if (!entry.value) {
        throw new Error(`Unable to restore data domain graph. Malformed edge entry`)
      }
      if (entry.value.foreign) {
        // The `v` has to be local to the graph. The `w` must be foreign.
        if (!foreignNodes.has(entry.w)) {
          // console.warn(`Missing foreign node: ${entry.w}. Skipping edge.`)
          continue
        }
      }
      if (foreignNodes.has(entry.v) || foreignNodes.has(entry.w)) {
        if (!g.hasNode(entry.v) || !g.hasNode(entry.w)) {
          // console.warn(`Missing foreign node: ${entry.v} or ${entry.w}. Skipping edge.`)
          continue
        }
      }
      g.setEdge({ v: entry.v, w: entry.w, name: entry.name }, entry.value)
    }
  }
  return g
}
