"""Pure-Python hierarchy operations for `OrganizationNode`.

We deliberately keep these as a service (not model methods) so they:
  * stay testable without DB hits when needed,
  * can be swapped for a CTE/ltree implementation under PostgreSQL later
    without touching call sites (Rule #22).
"""

from __future__ import annotations

from collections.abc import Iterable

from django.db import transaction
from django.db.models import QuerySet

from simorgh.apps.organizations.models import OrganizationNode


class HierarchyError(Exception):
    """Raised on illegal hierarchy operations (cycle, cross-tenant, etc.)."""


def _compute_path(parent: OrganizationNode | None, node_pk: int) -> tuple[str, int]:
    if parent is None:
        return f"/{node_pk}/", 0
    return f"{parent.path_string}{node_pk}/", parent.depth + 1


@transaction.atomic
def create_node(
    *,
    tenant_id: int,
    name: str,
    type: str = "organization",
    parent: OrganizationNode | None = None,
    code: str = "",
) -> OrganizationNode:
    if parent is not None and parent.tenant_id != tenant_id:
        raise HierarchyError("parent belongs to a different tenant")
    node = OrganizationNode.objects.create(
        tenant_id=tenant_id,
        parent=parent,
        type=type,
        name=name,
        code=code,
        path_string="",
        depth=0,
    )
    node.path_string, node.depth = _compute_path(parent, node.pk)
    node.save(update_fields=("path_string", "depth"))
    return node


def descendants_of(
    node: OrganizationNode, *, include_self: bool = False
) -> QuerySet[OrganizationNode]:
    qs = OrganizationNode.objects.filter(
        tenant_id=node.tenant_id,
        path_string__startswith=node.path_string,
    )
    if not include_self:
        qs = qs.exclude(pk=node.pk)
    return qs


def ancestors_of(
    node: OrganizationNode, *, include_self: bool = False
) -> QuerySet[OrganizationNode]:
    # path_string like '/1/4/12/' -> ancestor ids = [1, 4] (and 12 if include_self)
    parts = [p for p in node.path_string.split("/") if p]
    ids = [int(p) for p in parts]
    if not include_self and ids:
        ids = ids[:-1]
    if not ids:
        return OrganizationNode.objects.none()
    return OrganizationNode.objects.filter(tenant_id=node.tenant_id, pk__in=ids).order_by("depth")


def descendant_ids(nodes: Iterable[OrganizationNode]) -> set[int]:
    """Return ``{node.id} UNION descendants`` across all given nodes in one query.

    Uses the materialized path so it works on SQLite without recursive CTEs.
    """

    nodes = list(nodes)
    if not nodes:
        return set()
    tenant_id = nodes[0].tenant_id
    if any(n.tenant_id != tenant_id for n in nodes):
        raise HierarchyError("descendant_ids requires all nodes share a tenant")
    qs = OrganizationNode.objects.none()
    for node in nodes:
        qs = qs | OrganizationNode.objects.filter(
            tenant_id=tenant_id,
            path_string__startswith=node.path_string,
        )
    return set(qs.values_list("pk", flat=True))


@transaction.atomic
def move_node(node: OrganizationNode, new_parent: OrganizationNode | None) -> OrganizationNode:
    """Reparent `node` under `new_parent` (or to root), rebuilding subtree paths."""

    if new_parent is not None:
        if new_parent.tenant_id != node.tenant_id:
            raise HierarchyError("cannot move across tenants")
        if new_parent.pk == node.pk or new_parent.path_string.startswith(node.path_string):
            raise HierarchyError("cycle detected: cannot move a node under its own descendant")

    old_prefix = node.path_string
    node.parent = new_parent
    node.path_string, node.depth = _compute_path(new_parent, node.pk)
    node.save(update_fields=("parent", "path_string", "depth"))

    # Rewrite descendant paths in bulk.
    if old_prefix:
        subtree = OrganizationNode.objects.filter(
            tenant_id=node.tenant_id,
            path_string__startswith=old_prefix,
        ).exclude(pk=node.pk)
        for child in subtree:
            child.path_string = node.path_string + child.path_string[len(old_prefix) :]
            # path_string is `/a/b/c/`: 2 slashes wrap a single root segment,
            # so depth = total_slashes - 2.
            child.depth = child.path_string.count("/") - 2
        OrganizationNode.objects.bulk_update(subtree, ("path_string", "depth"))
    return node


@transaction.atomic
def rebuild_paths(tenant_id: int) -> int:
    """Walk the tree top-down and recompute every path_string for a tenant.

    Returns the number of nodes rewritten. Idempotent and safe to run as a
    repair step after a bad import.
    """

    nodes = list(OrganizationNode.objects.filter(tenant_id=tenant_id).order_by("parent_id", "pk"))
    by_id = {n.pk: n for n in nodes}
    updated = 0
    for n in nodes:
        parent = by_id.get(n.parent_id) if n.parent_id else None
        new_path, new_depth = _compute_path(parent, n.pk)
        if n.path_string != new_path or n.depth != new_depth:
            n.path_string = new_path
            n.depth = new_depth
            updated += 1
    if updated:
        OrganizationNode.objects.bulk_update(nodes, ("path_string", "depth"))
    return updated


@transaction.atomic
def delete_node(node: OrganizationNode, *, cascade: bool = False) -> int:
    """Delete *node* from the hierarchy.

    Parameters
    ----------
    node:
        The node to remove.
    cascade:
        When ``True`` all descendants are deleted together with the node
        (deep delete).  When ``False`` (default) the operation is refused if
        any children exist, raising :exc:`HierarchyError` instead.

    Returns
    -------
    int
        Number of database rows deleted (node + any cascaded descendants).
    """

    has_children = OrganizationNode.objects.filter(
        tenant_id=node.tenant_id,
        parent=node,
    ).exists()

    if has_children and not cascade:
        raise HierarchyError(
            f"Cannot delete node '{node.name}' (pk={node.pk}): it still has children. "
            "Pass cascade=True to delete the entire subtree."
        )

    if cascade and has_children:
        # Delete the entire subtree (descendants + the node itself) in one query.
        subtree_qs = OrganizationNode.objects.filter(
            tenant_id=node.tenant_id,
            path_string__startswith=node.path_string,
        )
        deleted_count, _ = subtree_qs.delete()
        return deleted_count

    node.delete()
    return 1
