from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable

from ...metamodel.metamodel import (
    Aggregate,
    Construct,
    Exists,
    Logical,
    Lookup,
    Loop,
    Match,
    Not,
    Require,
    Sequence,
    Task,
    Union,
    Update,
    Value,
    Var,
)
from ...metamodel.metamodel_analyzer import VarFinder
from .model_analysis import ModelAnalysis, TableKind


class SQL2EdgeType(Enum):
    READ_POSITIVE = "read_positive"
    READ_NEGATED = "read_negated"
    READ_EXISTS_SUPPORT = "read_exists_support"
    RECURSIVE = "recursive"


@dataclass(frozen=True)
class SQL2GraphEdge:
    source_node_id: int
    target_node_id: int
    edge_type: SQL2EdgeType
    via_var_ids: tuple[int, ...] = field(default_factory=tuple)


@dataclass(frozen=True)
class SQL2GraphNode:
    node_id: int
    update_task_id: int
    scope_id: int
    target_relation_id: int
    target_relation_name: str
    target_table_name: str
    key_var_ids: tuple[int, ...] = field(default_factory=tuple)
    multiplicity_task_ids: tuple[int, ...] = field(default_factory=tuple)
    existential_task_ids: tuple[int, ...] = field(default_factory=tuple)
    dependency_node_ids: tuple[int, ...] = field(default_factory=tuple)
    rewritten_rule: Task | None = None


@dataclass(frozen=True)
class SQL2GraphArtifacts:
    nodes: tuple[SQL2GraphNode, ...]
    edges: tuple[SQL2GraphEdge, ...]
    sccs: tuple[tuple[int, ...], ...]
    stratum_by_node: dict[int, int]
    recursive_node_ids: frozenset[int]
    program_by_stratum: dict[int, Task]
    program_root: Task | None


class SQL2NonStratifiableGraphError(ValueError):
    pass


@dataclass
class _TaskNode:
    task: Task
    node_key: int
    task_id: int
    scope_id: int
    scope_path: tuple[int, ...]
    task_kind: str
    order: int
    used_vars: set[Var]
    produced_vars: set[Var]
    barrier_path: tuple[int, ...] = ()
    branch_path: tuple[tuple[int, int], ...] = ()


class SQL2SingleUpdateGraphBuilder:
    """Walker-driven single-update graph builder for the SQL2 pipeline."""

    def __init__(self) -> None:
        self._finder = VarFinder()
        self._order = 0
        self._all_tasks_by_id: dict[int, Task] = {}
        self._parent_task_id: dict[int, int] = {}

    def build(self, analysis: ModelAnalysis) -> SQL2GraphArtifacts:
        root = analysis.analysis_root if analysis.analysis_root is not None else analysis.model.root
        self._order = 0
        self._all_tasks_by_id = {}
        self._parent_task_id = {}

        task_nodes = self._collect_task_nodes(root)
        task_by_key = {node.node_key: node for node in task_nodes}
        update_anchors = [node for node in task_nodes if node.task_kind == "update"]

        nodes: dict[int, SQL2GraphNode] = {}
        for anchor in update_anchors:
            assert isinstance(anchor.task, Update)
            seed_vars = self._vars_from_values(anchor.task.args)
            multiplicity_ids, existential_ids = self._slice_for_update(
                anchor=anchor,
                seed_vars=seed_vars,
                task_nodes=task_nodes,
            )
            rewritten_rule = self._build_rewritten_rule(
                anchor=anchor,
                seed_vars=seed_vars,
                multiplicity_ids=multiplicity_ids,
                existential_ids=existential_ids,
                task_by_key=task_by_key,
            )

            try:
                target_table = analysis.get_table_info(anchor.task.relation)
            except Exception:
                target_table = None
            if target_table is None:
                try:
                    target_column = analysis.get_column_info(anchor.task.relation)
                except Exception:
                    target_column = None
                target_table = target_column.table if target_column is not None else None
            target_table_name = target_table.name if target_table is not None else anchor.task.relation.name
            target_kind = target_table.kind if target_table is not None else TableKind.RELATIONSHIP

            nodes[anchor.task_id] = SQL2GraphNode(
                node_id=anchor.task_id,
                update_task_id=anchor.task_id,
                scope_id=anchor.scope_id,
                target_relation_id=anchor.task.relation.id,
                target_relation_name=anchor.task.relation.name,
                target_table_name=target_table_name,
                key_var_ids=self._key_var_ids(anchor.task, target_kind),
                multiplicity_task_ids=tuple(
                    node.task_id for node in task_nodes if node.node_key in multiplicity_ids
                ),
                existential_task_ids=tuple(
                    node.task_id for node in task_nodes if node.node_key in existential_ids
                ),
                rewritten_rule=rewritten_rule,
            )

        edges = self._build_edges(nodes)
        stratum_by_node, sccs, recursive_node_ids = self._compute_sccs_and_strata(set(nodes), edges)

        edges_by_target: dict[int, set[int]] = defaultdict(set)
        for edge in edges:
            edges_by_target[edge.target_node_id].add(edge.source_node_id)
        final_nodes = tuple(
            SQL2GraphNode(
                **{
                    **node.__dict__,
                    "dependency_node_ids": tuple(sorted(edges_by_target.get(node.node_id, set()))),
                }
            )
            for node in sorted(nodes.values(), key=lambda n: n.node_id)
        )

        node_ids_by_stratum: dict[int, list[int]] = defaultdict(list)
        for node_id, stratum_id in stratum_by_node.items():
            node_ids_by_stratum[stratum_id].append(node_id)
        nodes_by_id = {node.node_id: node for node in final_nodes}
        program_by_stratum: dict[int, Task] = {}
        for stratum_id in sorted(node_ids_by_stratum):
            rules: list[Task] = []
            for node_id in sorted(node_ids_by_stratum[stratum_id]):
                rule = nodes_by_id[node_id].rewritten_rule
                if rule is not None:
                    rules.append(rule)
            program_by_stratum[stratum_id] = Logical(body=tuple(rules), scope=True)
        program_root = (
            Logical(body=tuple(program_by_stratum[stratum_id] for stratum_id in sorted(program_by_stratum)), scope=True)
            if program_by_stratum
            else None
        )

        return SQL2GraphArtifacts(
            nodes=final_nodes,
            edges=edges,
            sccs=sccs,
            stratum_by_node=stratum_by_node,
            recursive_node_ids=recursive_node_ids,
            program_by_stratum=program_by_stratum,
            program_root=program_root,
        )

    def _collect_task_nodes(self, root: Task) -> list[_TaskNode]:
        nodes: list[_TaskNode] = []
        self._walk_task(
            root,
            nodes=nodes,
            scope_stack=[],
            barrier_path=(),
            branch_path=(),
        )
        return nodes

    def _walk_task(
        self,
        task: Task,
        *,
        nodes: list[_TaskNode],
        scope_stack: list[int],
        barrier_path: tuple[int, ...],
        branch_path: tuple[tuple[int, int], ...],
        parent_task_id: int | None = None,
    ) -> None:
        self._all_tasks_by_id[task.id] = task
        if parent_task_id is not None:
            self._parent_task_id[task.id] = parent_task_id

        scope_id = scope_stack[-1] if scope_stack else int(task.id)
        if isinstance(task, Logical):
            next_scope_stack = [*scope_stack, task.id]
            for child in task.body:
                self._walk_task(
                    child,
                    nodes=nodes,
                    scope_stack=next_scope_stack,
                    barrier_path=barrier_path,
                    branch_path=branch_path,
                    parent_task_id=task.id,
                )
            return
        if isinstance(task, Sequence):
            next_scope_stack = [*scope_stack, task.id]
            for child in task.tasks:
                self._walk_task(
                    child,
                    nodes=nodes,
                    scope_stack=next_scope_stack,
                    barrier_path=barrier_path,
                    branch_path=branch_path,
                    parent_task_id=task.id,
                )
            return
        if isinstance(task, Loop):
            self._walk_task(
                task.body,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=barrier_path,
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            return
        if isinstance(task, Require):
            self._walk_task(
                task.domain,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=barrier_path,
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            self._walk_task(
                task.check,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=barrier_path,
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            if task.error is not None:
                self._walk_task(
                    task.error,
                    nodes=nodes,
                    scope_stack=[*scope_stack, task.id],
                    barrier_path=barrier_path,
                    branch_path=branch_path,
                    parent_task_id=task.id,
                )
            return

        if isinstance(task, Not):
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="not",
                used_vars=self._task_vars(task.task),
                produced_vars=set(),
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            self._walk_task(
                task.task,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=(*barrier_path, task.id),
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            return

        if isinstance(task, Exists):
            quantified = {var for var in task.vars if isinstance(var, Var)}
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="exists",
                used_vars=self._task_vars(task.task) - quantified,
                produced_vars=set(),
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            self._walk_task(
                task.task,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=(*barrier_path, task.id),
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            return

        if isinstance(task, Aggregate):
            produced = {task.args[-1]} if task.args and isinstance(task.args[-1], Var) else set()
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="aggregate",
                used_vars=self._aggregate_input_tuple_vars(task) | self._task_vars(task.body),
                produced_vars=produced,
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            self._walk_task(
                task.body,
                nodes=nodes,
                scope_stack=[*scope_stack, task.id],
                barrier_path=barrier_path,
                branch_path=branch_path,
                parent_task_id=task.id,
            )
            return

        if isinstance(task, Lookup):
            used_vars = self._vars_from_values(task.args)
            produced_vars: set[Var] = set()
            has_input_fields = bool(task.relation.fields) and any(field.input for field in task.relation.fields)
            if task.relation.fields and len(task.relation.fields) == len(task.args):
                if has_input_fields:
                    used_vars = set()
                    for field, arg in zip(task.relation.fields, task.args):
                        arg_vars = self._vars_from_values((arg,))
                        if field.input:
                            used_vars.update(arg_vars)
                        else:
                            produced_vars.update(arg_vars)
                else:
                    used_vars = set()
                    produced_vars = self._vars_from_values(task.args)
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="lookup",
                used_vars=used_vars,
                produced_vars=produced_vars,
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            return

        if isinstance(task, Update):
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="update",
                used_vars=self._vars_from_values(task.args),
                produced_vars=set(),
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            return

        if isinstance(task, Construct):
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="construct",
                used_vars=self._vars_from_values(task.values),
                produced_vars={task.id_var},
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            return

        if isinstance(task, Match):
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="match",
                used_vars=self._task_vars(task),
                produced_vars={value for value in task.outputs if isinstance(value, Var)},
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            for ix, child in enumerate(task.tasks):
                self._walk_task(
                    child,
                    nodes=nodes,
                    scope_stack=[*scope_stack, task.id],
                    barrier_path=barrier_path,
                    branch_path=(*branch_path, (task.id, ix)),
                    parent_task_id=task.id,
                )
            return

        if isinstance(task, Union):
            self._record_node(
                nodes=nodes,
                task=task,
                scope_id=scope_id,
                scope_path=tuple(scope_stack),
                task_kind="union",
                used_vars=self._task_vars(task),
                produced_vars={value for value in task.outputs if isinstance(value, Var)},
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
            for ix, child in enumerate(task.tasks):
                self._walk_task(
                    child,
                    nodes=nodes,
                    scope_stack=[*scope_stack, task.id],
                    barrier_path=barrier_path,
                    branch_path=(*branch_path, (task.id, ix)),
                    parent_task_id=task.id,
                )
            return

    def _record_node(
        self,
        *,
        nodes: list[_TaskNode],
        task: Task,
        scope_id: int,
        scope_path: tuple[int, ...],
        task_kind: str,
        used_vars: set[Var],
        produced_vars: set[Var],
        barrier_path: tuple[int, ...],
        branch_path: tuple[tuple[int, int], ...],
    ) -> None:
        self._order += 1
        nodes.append(
            _TaskNode(
                task=task,
                node_key=self._order,
                task_id=task.id,
                scope_id=scope_id,
                scope_path=scope_path,
                task_kind=task_kind,
                order=self._order,
                used_vars=used_vars,
                produced_vars=produced_vars,
                barrier_path=barrier_path,
                branch_path=branch_path,
            )
        )

    def _slice_for_update(
        self,
        *,
        anchor: _TaskNode,
        seed_vars: set[Var],
        task_nodes: list[_TaskNode],
    ) -> tuple[set[int], set[int]]:
        frontier = set(seed_vars)
        multiplicity_ids: set[int] = set()
        existential_ids: set[int] = set()

        candidates = [node for node in task_nodes if node.node_key != anchor.node_key]

        def compatible(candidate: _TaskNode) -> bool:
            if candidate.barrier_path != anchor.barrier_path:
                return False
            cp = candidate.scope_path
            ap = anchor.scope_path
            if len(cp) > len(ap) or ap[: len(cp)] != cp:
                return False
            cb = candidate.branch_path
            ab = anchor.branch_path
            if len(cb) > len(ab) or ab[: len(cb)] != cb:
                return False
            return candidate.task_kind != "update"

        changed = True
        while changed:
            changed = False
            for node in reversed(candidates):
                if not compatible(node):
                    continue
                touches_frontier = bool((node.used_vars | node.produced_vars) & frontier)
                if not touches_frontier:
                    continue
                lookup_has_input_fields = (
                    isinstance(node.task, Lookup)
                    and bool(node.task.relation.fields)
                    and any(field.input for field in node.task.relation.fields)
                )
                newly_produced_vars = node.produced_vars - frontier
                newly_produced_reused = any(
                    bool(newly_produced_vars & (other.used_vars | other.produced_vars))
                    for other in candidates
                    if other.node_key != node.node_key and compatible(other)
                )
                if (
                    node.task_kind == "lookup"
                    and not lookup_has_input_fields
                    and node.used_vars.issubset(frontier)
                    and not (node.produced_vars & seed_vars)
                    and (node.produced_vars & frontier).issubset(seed_vars)
                    and newly_produced_vars
                    and not newly_produced_reused
                ):
                    if node.node_key not in existential_ids:
                        existential_ids.add(node.node_key)
                        changed = True
                    continue
                if node.task_kind in {"not", "exists"} and node.used_vars and not node.used_vars.issubset(frontier):
                    if node.node_key not in existential_ids:
                        existential_ids.add(node.node_key)
                        changed = True
                    continue
                if node.node_key not in multiplicity_ids:
                    multiplicity_ids.add(node.node_key)
                    existential_ids.discard(node.node_key)
                    changed = True
                before = len(frontier)
                frontier.update(node.used_vars)
                frontier.update(node.produced_vars)
                if len(frontier) != before:
                    changed = True

        while True:
            included_ids = multiplicity_ids | existential_ids
            required_vars = set(seed_vars)
            bound_vars = set(seed_vars)
            for node in task_nodes:
                if node.node_key not in included_ids:
                    continue
                required_vars.update(node.used_vars)
                bound_vars.update(node.produced_vars)
            missing = {var.id for var in required_vars - bound_vars}
            if not missing:
                break
            added = False
            for node in reversed(candidates):
                if not compatible(node):
                    continue
                if node.node_key in included_ids:
                    continue
                if not any(var.id in missing for var in node.produced_vars):
                    continue
                multiplicity_ids.add(node.node_key)
                frontier.update(node.used_vars)
                frontier.update(node.produced_vars)
                added = True
            if not added:
                break

        # Preserve disconnected boolean guards (for example global Not/Exists
        # predicates) as existential constraints for the update slice.
        included_ids = multiplicity_ids | existential_ids
        for node in reversed(candidates):
            if not compatible(node):
                continue
            if node.node_key in included_ids:
                continue
            if node.task_kind not in {"not", "exists"}:
                continue
            if (node.used_vars | node.produced_vars) & frontier:
                continue
            existential_ids.add(node.node_key)

        return multiplicity_ids, existential_ids - multiplicity_ids

    def _build_rewritten_rule(
        self,
        *,
        anchor: _TaskNode,
        seed_vars: set[Var],
        multiplicity_ids: set[int],
        existential_ids: set[int],
        task_by_key: dict[int, _TaskNode],
    ) -> Task:
        seed_var_ids = {var.id for var in seed_vars}
        body: list[Task] = []
        for task_key in sorted(multiplicity_ids, key=lambda tid: task_by_key[tid].order):
            body.append(task_by_key[task_key].task)

        if existential_ids:
            existential_tasks = [
                task_by_key[task_key].task
                for task_key in sorted(existential_ids, key=lambda tid: task_by_key[tid].order)
            ]
            quantified: set[Var] = set()
            for task_key in existential_ids:
                quantified.update(
                    var
                    for var in task_by_key[task_key].produced_vars
                    if var.id not in seed_var_ids
                )
            body.append(
                Exists(
                    vars=tuple(sorted(quantified, key=lambda var: var.id)),
                    task=Logical(body=tuple(existential_tasks), scope=True),
                    scope=True,
                )
            )

        body.append(anchor.task)
        wrapped = self._wrap_with_context(anchor=anchor, inner=Logical(body=tuple(body), scope=True))
        return wrapped

    def _wrap_with_context(self, *, anchor: _TaskNode, inner: Task) -> Task:
        wrapped = inner
        branch_ix_by_container = {container_id: ix for container_id, ix in anchor.branch_path}
        barrier_ids = set(anchor.barrier_path)

        current_id = anchor.task_id
        while current_id in self._parent_task_id:
            parent_id = self._parent_task_id[current_id]
            parent = self._all_tasks_by_id.get(parent_id)
            if parent is None:
                current_id = parent_id
                continue

            if parent_id in barrier_ids:
                if isinstance(parent, Not):
                    wrapped = Not(task=wrapped, optional=parent.optional, scope=parent.scope)
                elif isinstance(parent, Exists):
                    wrapped = Exists(vars=parent.vars, task=wrapped, optional=parent.optional, scope=parent.scope)

            if parent_id in branch_ix_by_container:
                if isinstance(parent, Union):
                    wrapped = Union(tasks=(wrapped,), outputs=parent.outputs, optional=parent.optional, scope=parent.scope)
                elif isinstance(parent, Match):
                    wrapped = Match(tasks=(wrapped,), outputs=parent.outputs, optional=parent.optional, scope=parent.scope)

            if isinstance(parent, Logical) and parent.optional:
                wrapped = Logical(body=(wrapped,), optional=True, scope=parent.scope)

            current_id = parent_id

        return wrapped

    def _build_edges(self, nodes: dict[int, SQL2GraphNode]) -> tuple[SQL2GraphEdge, ...]:
        producers_by_relation_id: dict[int, list[int]] = defaultdict(list)
        for node in nodes.values():
            producers_by_relation_id[node.target_relation_id].append(node.node_id)
        for node_ids in producers_by_relation_id.values():
            node_ids.sort()

        nodes_by_id = {node.node_id: node for node in nodes.values()}
        edge_key_to_payload: dict[tuple[int, int, SQL2EdgeType], tuple[set[int], SQL2EdgeType]] = {}

        for target in nodes.values():
            relation_reads = self._collect_relation_reads(target.rewritten_rule)
            for relation_id, edge_type in relation_reads:
                for source_node_id in producers_by_relation_id.get(relation_id, []):
                    source = nodes_by_id[source_node_id]
                    resolved_edge_type = edge_type
                    if source_node_id == target.node_id:
                        resolved_edge_type = SQL2EdgeType.RECURSIVE
                    key = (source_node_id, target.node_id, resolved_edge_type)
                    via_var_ids = set(source.key_var_ids).intersection(target.key_var_ids)
                    payload = edge_key_to_payload.get(key)
                    if payload is None:
                        edge_key_to_payload[key] = (set(via_var_ids), resolved_edge_type)
                    else:
                        payload[0].update(via_var_ids)

        edges = tuple(
            SQL2GraphEdge(
                source_node_id=source_node_id,
                target_node_id=target_node_id,
                edge_type=edge_type,
                via_var_ids=tuple(sorted(via_vars)),
            )
            for (source_node_id, target_node_id, edge_type), (via_vars, _) in sorted(
                edge_key_to_payload.items(),
                key=lambda item: (item[0][0], item[0][1], item[0][2].value),
            )
        )
        return edges

    def _collect_relation_reads(self, task: Task | None) -> list[tuple[int, SQL2EdgeType]]:
        if task is None:
            return []

        out: list[tuple[int, SQL2EdgeType]] = []

        def visit(node: Task, *, negated: bool = False, existential: bool = False) -> None:
            if isinstance(node, Lookup):
                if negated:
                    out.append((node.relation.id, SQL2EdgeType.READ_NEGATED))
                elif existential:
                    out.append((node.relation.id, SQL2EdgeType.READ_EXISTS_SUPPORT))
                else:
                    out.append((node.relation.id, SQL2EdgeType.READ_POSITIVE))
                return
            if isinstance(node, Logical):
                for child in node.body:
                    visit(child, negated=negated, existential=existential)
                return
            if isinstance(node, Sequence):
                for child in node.tasks:
                    visit(child, negated=negated, existential=existential)
                return
            if isinstance(node, Loop):
                visit(node.body, negated=negated, existential=existential)
                return
            if isinstance(node, Require):
                visit(node.domain, negated=negated, existential=existential)
                visit(node.check, negated=negated, existential=existential)
                if node.error is not None:
                    visit(node.error, negated=negated, existential=existential)
                return
            if isinstance(node, Not):
                visit(node.task, negated=True, existential=existential)
                return
            if isinstance(node, Exists):
                visit(node.task, negated=negated, existential=True)
                return
            if isinstance(node, Aggregate):
                visit(node.body, negated=negated, existential=existential)
                return
            if isinstance(node, Match):
                for child in node.tasks:
                    visit(child, negated=negated, existential=existential)
                return
            if isinstance(node, Union):
                for child in node.tasks:
                    visit(child, negated=negated, existential=existential)
                return

        visit(task)
        return out

    def _compute_sccs_and_strata(
        self,
        node_ids: set[int],
        edges: tuple[SQL2GraphEdge, ...],
    ) -> tuple[dict[int, int], tuple[tuple[int, ...], ...], frozenset[int]]:
        if not node_ids:
            return {}, (), frozenset()

        adjacency: dict[int, set[int]] = {node_id: set() for node_id in node_ids}
        self_edges: set[int] = set()
        for edge in edges:
            adjacency.setdefault(edge.source_node_id, set()).add(edge.target_node_id)
            adjacency.setdefault(edge.target_node_id, set())
            if edge.source_node_id == edge.target_node_id:
                self_edges.add(edge.source_node_id)

        index_counter = [0]
        stack: list[int] = []
        lowlinks: dict[int, int] = {}
        index: dict[int, int] = {}
        on_stack: set[int] = set()
        sccs: list[list[int]] = []

        def strongconnect(v: int) -> None:
            index[v] = index_counter[0]
            lowlinks[v] = index_counter[0]
            index_counter[0] += 1
            stack.append(v)
            on_stack.add(v)

            for w in sorted(adjacency[v]):
                if w not in index:
                    strongconnect(w)
                    lowlinks[v] = min(lowlinks[v], lowlinks[w])
                elif w in on_stack:
                    lowlinks[v] = min(lowlinks[v], index[w])

            if lowlinks[v] == index[v]:
                scc: list[int] = []
                while True:
                    w = stack.pop()
                    on_stack.remove(w)
                    scc.append(w)
                    if w == v:
                        break
                sccs.append(sorted(scc))

        for v in sorted(node_ids):
            if v not in index:
                strongconnect(v)

        node_to_scc: dict[int, int] = {}
        for scc_ix, scc in enumerate(sccs):
            for node_id in scc:
                node_to_scc[node_id] = scc_ix

        recursive_node_ids: set[int] = set()
        for scc in sccs:
            if len(scc) > 1:
                recursive_node_ids.update(scc)
        recursive_node_ids.update(self_edges)

        weighted_edges: dict[tuple[int, int], int] = {}
        for edge in edges:
            source_scc = node_to_scc[edge.source_node_id]
            target_scc = node_to_scc[edge.target_node_id]
            if source_scc == target_scc:
                if edge.edge_type == SQL2EdgeType.READ_NEGATED:
                    raise SQL2NonStratifiableGraphError(
                        "Found negated dependency cycle in SQL2 demand graph."
                    )
                continue
            delta = 1 if edge.edge_type == SQL2EdgeType.READ_NEGATED else 0
            key = (source_scc, target_scc)
            weighted_edges[key] = max(weighted_edges.get(key, 0), delta)

        outgoing: dict[int, dict[int, int]] = defaultdict(dict)
        indegree: dict[int, int] = {scc_ix: 0 for scc_ix in range(len(sccs))}
        for (source_scc, target_scc), delta in weighted_edges.items():
            outgoing[source_scc][target_scc] = delta
            indegree[target_scc] += 1

        min_node_id = {scc_ix: min(scc) for scc_ix, scc in enumerate(sccs)}
        ready = sorted([scc_ix for scc_ix, deg in indegree.items() if deg == 0], key=lambda ix: min_node_id[ix])
        topo_order: list[int] = []
        while ready:
            current = ready.pop(0)
            topo_order.append(current)
            for nxt in sorted(outgoing.get(current, {}), key=lambda ix: min_node_id[ix]):
                indegree[nxt] -= 1
                if indegree[nxt] == 0:
                    ready.append(nxt)
                    ready.sort(key=lambda ix: min_node_id[ix])

        scc_level = {scc_ix: 0 for scc_ix in range(len(sccs))}
        for scc_ix in topo_order:
            base_level = scc_level[scc_ix]
            for nxt_scc, delta in outgoing.get(scc_ix, {}).items():
                scc_level[nxt_scc] = max(scc_level[nxt_scc], base_level + delta)

        stratum_by_node: dict[int, int] = {}
        for scc_ix, scc in enumerate(sccs):
            level = scc_level[scc_ix]
            for node_id in scc:
                stratum_by_node[node_id] = level

        ordered_sccs = tuple(tuple(sccs[scc_ix]) for scc_ix in topo_order)
        return stratum_by_node, ordered_sccs, frozenset(recursive_node_ids)

    def _key_var_ids(self, update: Update, target_kind: TableKind) -> tuple[int, ...]:
        if not update.args:
            return ()
        if target_kind == TableKind.RELATIONSHIP:
            args = update.args
        elif len(update.args) <= 1:
            args = update.args
        else:
            args = update.args[:-1]
        return tuple(sorted({arg.id for arg in args if isinstance(arg, Var)}))

    def _vars_from_values(self, values: Iterable[Value]) -> set[Var]:
        out: set[Var] = set()
        for value in values:
            if isinstance(value, Var):
                out.add(value)
            elif isinstance(value, (tuple, list)):
                out.update(self._vars_from_values(value))
        return out

    def _task_vars(self, task: Task) -> set[Var]:
        return set(self._finder.find_vars(task))

    def _aggregate_input_tuple_vars(self, agg: Aggregate) -> set[Var]:
        return self._vars_from_values([*agg.group, *agg.projection, *agg.args[:-1]])
