export type DependencyRelation = 'link' | 'expression' | 'topo-ref' | 'container' | 'view'

export type DependencyEdge = {
  sourceId: string
  targetId: string
  relation: DependencyRelation
  propertyName?: string
  reference?: string
}

export type RecomputePlan = {
  affected: string[]
  order: string[]
  levels: string[][]
  cycles: string[][]
}

export type RecomputeState = 'up-to-date' | 'touched' | 'recomputing' | 'suppressed' | 'upstream-suppressed' | 'error' | 'upstream-failed'

export type RecomputeSnapshot = {
  generation: number
  status: 'idle' | 'recomputing' | 'completed' | 'failed'
  objectStates: Record<string, RecomputeState>
  dirtyObjects: string[]
  order: string[]
  errors: Array<{ objectId: string; code: string; message: string }>
}

export class DependencyGraph {
  private readonly nodes = new Set<string>()
  private readonly outgoing = new Map<string, Set<string>>()
  private readonly incoming = new Map<string, Set<string>>()
  private readonly edgeList: DependencyEdge[] = []

  constructor(edges: DependencyEdge[] = [], nodeIds: string[] = []) {
    nodeIds.forEach((nodeId) => this.addNode(nodeId))
    edges.forEach((edge) => this.addEdge(edge))
  }

  addNode(nodeId: string) {
    this.nodes.add(nodeId)
    if (!this.outgoing.has(nodeId)) this.outgoing.set(nodeId, new Set())
    if (!this.incoming.has(nodeId)) this.incoming.set(nodeId, new Set())
  }

  addEdge(edge: DependencyEdge) {
    if (!edge.sourceId || !edge.targetId) throw new RangeError('Dependency edge requires sourceId and targetId.')
    this.addNode(edge.sourceId)
    this.addNode(edge.targetId)
    if (this.edgeList.some((candidate) => candidate.sourceId === edge.sourceId && candidate.targetId === edge.targetId && candidate.relation === edge.relation && candidate.propertyName === edge.propertyName)) return
    this.edgeList.push({ ...edge })
    this.outgoing.get(edge.sourceId)?.add(edge.targetId)
    this.incoming.get(edge.targetId)?.add(edge.sourceId)
  }

  removeEdgesForProperty(sourceId: string, propertyName: string) {
    const retained = this.edgeList.filter((edge) => edge.sourceId !== sourceId || edge.propertyName !== propertyName)
    this.edgeList.length = 0
    retained.forEach((edge) => this.edgeList.push(edge))
    this.rebuildIndexes()
  }

  edges(): DependencyEdge[] { return this.edgeList.map((edge) => ({ ...edge })) }

  dependenciesOf(sourceId: string): string[] { return [...(this.outgoing.get(sourceId) ?? [])] }

  dependentsOf(targetId: string): string[] { return [...(this.incoming.get(targetId) ?? [])] }

  plan(dirtyIds: Iterable<string>): RecomputePlan {
    const affected = new Set<string>()
    const visitDependents = (nodeId: string) => {
      if (affected.has(nodeId)) return
      affected.add(nodeId)
      for (const dependent of this.dependentsOf(nodeId)) visitDependents(dependent)
    }
    for (const dirtyId of dirtyIds) if (this.nodes.has(dirtyId)) visitDependents(dirtyId)

    const cycles = this.findCycles(affected)
    const cyclic = new Set(cycles.flat())
    const order: string[] = []
    const visited = new Set<string>()
    const visitDependencies = (nodeId: string) => {
      if (visited.has(nodeId)) return
      visited.add(nodeId)
      for (const dependency of this.dependenciesOf(nodeId)) if (affected.has(dependency) && !cyclic.has(dependency)) visitDependencies(dependency)
      if (!cyclic.has(nodeId)) order.push(nodeId)
    }
    for (const nodeId of affected) visitDependencies(nodeId)
    const levelByNode = new Map<string, number>()
    for (const nodeId of order) {
      const level = Math.max(0, ...this.dependenciesOf(nodeId).filter((dependency) => affected.has(dependency)).map((dependency) => (levelByNode.get(dependency) ?? 0) + 1))
      levelByNode.set(nodeId, level)
    }
    const levels: string[][] = []
    for (const nodeId of order) { const level = levelByNode.get(nodeId) ?? 0; (levels[level] ??= []).push(nodeId) }
    return { affected: [...affected], order, levels, cycles }
  }

  findCycles(scope: Set<string> = this.nodes): string[][] {
    const cycles: string[][] = []
    const indexByNode = new Map<string, number>()
    const lowLinkByNode = new Map<string, number>()
    const stack: string[] = []
    const onStack = new Set<string>()
    let index = 0
    const strongConnect = (nodeId: string) => {
      indexByNode.set(nodeId, index)
      lowLinkByNode.set(nodeId, index)
      index += 1
      stack.push(nodeId)
      onStack.add(nodeId)
      for (const dependency of this.dependenciesOf(nodeId)) {
        if (!scope.has(dependency)) continue
        if (!indexByNode.has(dependency)) {
          strongConnect(dependency)
          lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, lowLinkByNode.get(dependency) as number))
        } else if (onStack.has(dependency)) {
          lowLinkByNode.set(nodeId, Math.min(lowLinkByNode.get(nodeId) as number, indexByNode.get(dependency) as number))
        }
      }
      if (lowLinkByNode.get(nodeId) !== indexByNode.get(nodeId)) return
      const component: string[] = []
      let candidate = ''
      do {
        candidate = stack.pop() as string
        onStack.delete(candidate)
        component.push(candidate)
      } while (candidate !== nodeId)
      if (component.length > 1 || this.dependenciesOf(nodeId).includes(nodeId)) {
        const members = new Set(component)
        cycles.push([...scope].filter((candidate) => members.has(candidate)))
      }
    }
    for (const nodeId of scope) if (!indexByNode.has(nodeId)) strongConnect(nodeId)
    return cycles
  }

  private rebuildIndexes() {
    this.outgoing.clear()
    this.incoming.clear()
    for (const nodeId of this.nodes) { this.outgoing.set(nodeId, new Set()); this.incoming.set(nodeId, new Set()) }
    for (const edge of this.edgeList) { this.outgoing.get(edge.sourceId)?.add(edge.targetId); this.incoming.get(edge.targetId)?.add(edge.sourceId) }
  }
}

export const createRecomputeSnapshot = (objectIds: string[], generation = 0): RecomputeSnapshot => ({
  generation,
  status: 'idle',
  objectStates: Object.fromEntries(objectIds.map((objectId) => [objectId, 'up-to-date'])),
  dirtyObjects: [],
  order: [],
  errors: [],
})
