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,
  DeserializationMode,
  DeserializationResult,
  DeserializationIssue,
  DeserializeOptions,
} 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
}

/**
 * Validates the graph consistency before serialization.
 * Ensures that all required edges exist for properties and associations.
 * @param g The graph to validate
 * @param domainKey The domain key to validate
 * @throws Error if the graph is inconsistent
 */
function validateGraphConsistency(g: DataDomainGraph, domainKey: string): void {
  const validationErrors: string[] = []

  // Get all nodes that belong to this domain
  const domainNodes = new Set<string>()
  for (const nodeId of g.nodes()) {
    const node = g.node(nodeId)
    if (node && node.domain.key === domainKey) {
      domainNodes.add(nodeId)
    }
  }

  // Validate that properties and associations have parent edges
  for (const nodeId of domainNodes) {
    const node = g.node(nodeId)
    if (!node) continue

    if (node.kind === DomainPropertyKind || node.kind === DomainAssociationKind) {
      // These nodes must have exactly one incoming edge from their parent entity
      const incomingEdges = [...g.inEdges(nodeId)]
      const parentEdges = incomingEdges.filter((edge) => {
        const sourceNode = g.node(edge.v)
        return sourceNode && sourceNode.domain.key === domainKey
      })

      if (parentEdges.length === 0) {
        const nodeType = node.kind === DomainPropertyKind ? 'Property' : 'Association'
        validationErrors.push(
          `${nodeType} "${node.info.name}" (${nodeId}) has no parent entity edge. ` +
            `This will cause deserialization to fail.`
        )
      } else if (parentEdges.length > 1) {
        const nodeType = node.kind === DomainPropertyKind ? 'Property' : 'Association'
        const parentIds = parentEdges.map((e) => e.v).join(', ')
        validationErrors.push(
          `${nodeType} "${node.info.name}" (${nodeId}) has multiple parent edges: ${parentIds}. ` +
            `This may cause deserialization issues.`
        )
      } else {
        // Validate that the parent is actually an entity
        const parentEdge = parentEdges[0]
        const parentNode = g.node(parentEdge.v)
        if (!parentNode || parentNode.kind !== DomainEntityKind) {
          const nodeType = node.kind === DomainPropertyKind ? 'Property' : 'Association'
          validationErrors.push(
            `${nodeType} "${node.info.name}" (${nodeId}) has a parent that is not an entity. ` +
              `Parent: ${parentNode?.kind || 'unknown'} (${parentEdge.v})`
          )
        }
      }
    }

    // Validate that entities have model parents
    if (node.kind === DomainEntityKind) {
      const parents = [...g.parents(nodeId)]
      const hasModelParent = parents.some((parentId) => {
        const parent = g.node(parentId)
        return parent && parent.kind === DomainModelKind
      })

      if (!hasModelParent) {
        validationErrors.push(
          `Entity "${node.info.name}" (${nodeId}) has no model parent. ` + `Entities must belong to a model.`
        )
      }
    }

    // Validate that models have either namespace parents or are at root level
    if (node.kind === DomainModelKind) {
      const parents = [...g.parents(nodeId)]
      const hasNamespaceParent = parents.some((parentId) => {
        const parent = g.node(parentId)
        return parent && parent.kind === DomainNamespaceKind
      })

      // Models can either be at root level (no parents) or have a namespace parent
      // They should not have other types of parents
      if (parents.length > 0 && !hasNamespaceParent) {
        const invalidParents = parents
          .map((parentId) => {
            const parent = g.node(parentId)
            return `${parent?.kind || 'unknown'} (${parentId})`
          })
          .join(', ')
        validationErrors.push(
          `Model "${node.info.name}" (${nodeId}) has invalid parent types: ${invalidParents}. ` +
            `Models can only belong to namespaces or be at root level.`
        )
      }
    }
  }

  // Validate association targets exist
  for (const nodeId of domainNodes) {
    const node = g.node(nodeId)
    if (node && node.kind === DomainAssociationKind) {
      const association = node as DomainAssociation
      const outgoingEdges = [...g.outEdges(nodeId)]

      // Check that association has target edges
      const targetEdges = outgoingEdges.filter((edge) => {
        const edgeData = g.edge(edge)
        return edgeData && edgeData.type === 'association'
      })

      if (targetEdges.length === 0) {
        // This is general warning message, do not an error.
        // We can serialize and deserialize associations without targets.
      }

      // Validate that all target entities exist
      for (const targetEdge of targetEdges) {
        const targetNode = g.node(targetEdge.w)
        if (!targetNode) {
          validationErrors.push(
            `Association "${association.info.name}" (${nodeId}) references non-existent target: ${targetEdge.w}`
          )
        } else if (targetNode.kind !== DomainEntityKind) {
          validationErrors.push(
            `Association "${association.info.name}" (${nodeId}) references non-entity target: ` +
              `${targetNode.kind} (${targetEdge.w})`
          )
        }
      }
    }
  }

  if (validationErrors.length > 0) {
    throw new Error(
      `Graph consistency validation failed for domain "${domainKey}":\n` +
        validationErrors.map((error) => `  - ${error}`).join('\n')
    )
  }
}

export function serialize(g: DataDomainGraph, domainKey: string): SerializedGraph {
  // Validate graph consistency before serialization
  validateGraphConsistency(g, domainKey)

  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
 * @param mode The deserialization mode
 * @param issues Array to collect issues in lenient mode
 * @returns The restored node instance or undefined if failed in lenient mode
 * @throws Error if the entry is malformed or the kind is unknown in strict mode
 */
function prepareNode(
  root: DataDomain,
  entry: unknown,
  edges: JsonEdge<DomainGraphEdge>[],
  mode: DeserializationMode,
  issues: DeserializationIssue[]
): DomainGraphNodeType | undefined {
  if (!entry) {
    const issue: DeserializationIssue = {
      type: 'malformed_entry',
      severity: 'error',
      message: 'Unable to restore data domain graph. Malformed node entry',
    }
    issues.push(issue)

    if (mode === 'strict') {
      throw new Error(issue.message)
    }
    return undefined
  }

  const domainElement = entry as { kind?: string; key?: string }
  if (!domainElement.kind) {
    const issue: DeserializationIssue = {
      type: 'malformed_entry',
      severity: 'error',
      message: 'Unable to restore data domain graph. Node entry missing kind property',
      affectedKey: domainElement.key,
    }
    issues.push(issue)

    if (mode === 'strict') {
      throw new Error(issue.message)
    }
    return undefined
  }

  try {
    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) {
        const issue: DeserializationIssue = {
          type: 'missing_edge',
          severity: 'error',
          message: `Property "${typed.info?.name || typed.key}" has no parent entity edge`,
          affectedKey: typed.key,
          resolution: mode === 'lenient' ? 'Property will be skipped' : undefined,
        }
        issues.push(issue)

        if (mode === 'strict') {
          throw new Error(`Unable to restore data domain graph. Malformed node entry`)
        }
        return undefined
      }
      return new DomainProperty(root, parent, typed)
    }
    if (domainElement.kind === DomainAssociationKind) {
      const typed = domainElement as DomainAssociationSchema
      const parent = findEdgeParent(typed.key, edges)
      if (!parent) {
        const issue: DeserializationIssue = {
          type: 'missing_edge',
          severity: 'error',
          message: `Association "${typed.info?.name || typed.key}" has no parent entity edge`,
          affectedKey: typed.key,
          resolution: mode === 'lenient' ? 'Association will be skipped' : undefined,
        }
        issues.push(issue)
        if (mode === 'strict') {
          throw new Error(`Unable to restore data domain graph. Malformed node entry`)
        }
        return undefined
      }
      return new DomainAssociation(root, parent, typed)
    }

    const issue: DeserializationIssue = {
      type: 'unknown_kind',
      severity: 'error',
      message: `Unknown node kind: ${domainElement.kind}`,
      affectedKey: domainElement.key,
    }
    issues.push(issue)

    if (mode === 'strict') {
      throw new Error(`Unable to restore data domain graph. Unknown node kind ${domainElement.kind}`)
    }
    return undefined
  } catch (error) {
    const issue: DeserializationIssue = {
      type: 'malformed_entry',
      severity: 'error',
      message: `Failed to create node: ${error instanceof Error ? error.message : String(error)}`,
      affectedKey: domainElement.key,
    }
    issues.push(issue)

    if (mode === 'strict') {
      throw error
    }
    return undefined
  }
}

/**
 * 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
 * @param dependencies An array of foreign data domains to register with this domain
 * @param mode The deserialization mode - 'strict' throws errors, 'lenient' collects issues
 * @returns DeserializationResult with graph, issues, and success status
 * @throws Error in strict mode when any issue is encountered
 */
export function deserialize(root: DataDomain, options: DeserializeOptions = {}): DeserializationResult {
  const { json, dependencies, mode = 'strict' } = options
  const g = new Graph<unknown, DomainGraphNodeType, DomainGraphEdge>({
    compound: true,
    multigraph: true,
    directed: true,
  })
  const issues: DeserializationIssue[] = []
  let foreignNodes = new Set<string>()
  let foreignEdges = new Set<string>()

  // Process dependencies
  if (dependencies) {
    for (const dependency of dependencies) {
      try {
        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)
      } catch (error) {
        const message = `Failed to merge dependency "${dependency.key}": ${error instanceof Error ? error.message : String(error)}`

        if (mode === 'strict') {
          throw new Error(message)
        }

        const issue: DeserializationIssue = {
          type: 'missing_dependency',
          severity: 'error',
          message,
          affectedKey: dependency.key,
          resolution: 'Dependency will be skipped',
        }
        issues.push(issue)
      }
    }
  }

  if (!json) {
    return {
      graph: g,
      issues,
      success: true,
    }
  }

  // Process nodes
  if (Array.isArray(json.nodes)) {
    // 1st pass - set up nodes
    const parentInfo = new Map<string, string[]>()
    for (const entry of json.nodes) {
      const node = prepareNode(root, entry.value, json.edges, mode, issues)
      if (node) {
        g.setNode(entry.v, node)
        if (entry.parents) {
          parentInfo.set(entry.v, entry.parents)
        }
      }
      // Note: prepareNode throws in strict mode, so we won't reach here if there's an error
    }

    // 2nd pass - set up parents
    for (const [key, parents] of parentInfo) {
      // Verify the node still exists (might have been skipped in lenient mode)
      if (!g.hasNode(key)) {
        const issue: DeserializationIssue = {
          type: 'missing_node',
          severity: 'warning',
          message: `Cannot set parents for missing node: ${key}`,
          affectedKey: key,
          resolution: 'Parent relationship will be skipped',
        }
        issues.push(issue)
        continue
      }

      for (const parent of parents) {
        // Verify parent exists
        if (!g.hasNode(parent)) {
          const issue: DeserializationIssue = {
            type: 'missing_node',
            severity: 'warning',
            message: `Parent node "${parent}" not found for child "${key}"`,
            affectedKey: key,
            context: { parentKey: parent },
            resolution: 'Parent relationship will be skipped',
          }
          issues.push(issue)
          continue
        }

        try {
          g.setParent(key, parent)
        } catch (error) {
          const message = `Failed to set parent "${parent}" for child "${key}": ${error instanceof Error ? error.message : String(error)}`
          const issue: DeserializationIssue = {
            type: 'invalid_parent',
            severity: 'warning',
            message,
            affectedKey: key,
            context: { parentKey: parent },
            resolution: 'Parent relationship will be skipped',
          }
          issues.push(issue)
        }
      }
    }
  }

  // Process edges
  if (Array.isArray(json.edges)) {
    for (const entry of json.edges) {
      if (!entry.value) {
        const message = 'Edge entry missing value'

        if (mode === 'strict') {
          throw new Error(message)
        }

        const issue: DeserializationIssue = {
          type: 'malformed_entry',
          severity: 'error',
          message,
          context: { edge: entry },
        }
        issues.push(issue)
        continue
      }

      // Handle foreign edges
      if (entry.value.foreign) {
        // The `v` has to be local to the graph. The `w` must be foreign.
        if (!foreignNodes.has(entry.w)) {
          // Note, we don't consider this an error, just a warning.
          // Because of that, we don't throw an error here.
          const issue: DeserializationIssue = {
            type: 'missing_node',
            severity: 'warning',
            message: `Missing foreign node: ${entry.w}`,
            affectedKey: entry.w,
            resolution: 'Edge will be skipped',
          }
          issues.push(issue)
          continue
        }
      }

      // Check if nodes exist for the edge
      if (foreignNodes.has(entry.v) || foreignNodes.has(entry.w)) {
        if (!g.hasNode(entry.v) || !g.hasNode(entry.w)) {
          const issue: DeserializationIssue = {
            type: 'missing_node',
            severity: 'warning',
            message: `Missing foreign node for edge: ${entry.v} -> ${entry.w}`,
            context: { sourceNode: entry.v, targetNode: entry.w },
            resolution: 'Edge will be skipped',
          }
          issues.push(issue)
          continue
        }
      } else {
        // Both nodes should be local - verify they exist
        if (!g.hasNode(entry.v)) {
          const issue: DeserializationIssue = {
            type: 'missing_node',
            severity: 'warning',
            message: `Source node not found for edge: ${entry.v}`,
            affectedKey: entry.v,
            resolution: 'Edge will be skipped',
          }
          issues.push(issue)
          continue
        }

        if (!g.hasNode(entry.w)) {
          const issue: DeserializationIssue = {
            type: 'missing_node',
            severity: 'warning',
            message: `Target node not found for edge: ${entry.w}`,
            affectedKey: entry.w,
            resolution: 'Edge will be skipped',
          }
          issues.push(issue)
          continue
        }
      }

      try {
        g.setEdge({ v: entry.v, w: entry.w, name: entry.name }, entry.value)
      } catch (error) {
        const message = `Failed to create edge ${entry.v} -> ${entry.w}: ${error instanceof Error ? error.message : String(error)}`
        const issue: DeserializationIssue = {
          type: 'malformed_entry',
          severity: 'warning',
          message,
          context: { sourceNode: entry.v, targetNode: entry.w },
          resolution: 'Edge will be skipped',
        }
        issues.push(issue)
      }
    }
  }

  const hasErrors = issues.some((issue) => issue.severity === 'error')

  return {
    graph: g,
    issues,
    success: !hasErrors,
  }
}
