from __future__ import annotations

import re
from typing import TYPE_CHECKING, Any, Optional, Self, Union, cast

from pydantic import Field, field_validator

from infrahub.core.branch.enums import BranchStatus
from infrahub.core.branch.filters import BranchListFilters
from infrahub.core.constants import GLOBAL_BRANCH_NAME, SYSTEM_USER_ID
from infrahub.core.graph import GRAPH_VERSION
from infrahub.core.models import SchemaBranchHash  # noqa: TC001
from infrahub.core.node.standard import StandardNode, StandardNodeOrdering
from infrahub.core.query import Query, QueryType
from infrahub.core.query.branch import (
    BranchNodeGetListQuery,
    DeleteBranchRelationshipsQuery,
    RebaseBranchQuery,
)
from infrahub.core.registry import registry
from infrahub.core.timestamp import Timestamp
from infrahub.exceptions import BranchNotFoundError, InitializationError, ValidationError

if TYPE_CHECKING:
    from neo4j.graph import Node as Neo4jNode

    from infrahub.database import InfrahubDatabase


class Branch(StandardNode):
    name: str = Field(
        max_length=250, min_length=3, description="Name of the branch (git ref standard)", validate_default=True
    )
    status: BranchStatus = BranchStatus.OPEN
    description: str = ""
    origin_branch: str = "main"
    branched_from: Optional[str] = Field(default=None, validate_default=True)
    hierarchy_level: int = 2
    is_default: bool = False
    is_global: bool = False
    is_protected: bool = False
    sync_with_git: bool = Field(
        default=True,
        description="Indicate if the branch should be extended to Git and if Infrahub should merge the branch in Git as part of a proposed change",
    )
    is_isolated: bool = True
    schema_changed_at: Optional[str] = None
    schema_hash: Optional[SchemaBranchHash] = None
    graph_version: int | None = None

    _exclude_attrs: list[str] = ["id", "uuid", "owner"]

    @field_validator("name", mode="before")
    @classmethod
    def validate_branch_name(cls, value: str) -> str:
        checks = [
            (r".*/\.", "/."),
            (r"\.\.", ".."),
            (r"^/", "starts with /"),
            (r"//", "//"),
            (r"@{", "@{"),
            (r"\\", "backslash (\\)"),
            (r"[\000-\037\177 ~^:?*[]", "disallowed ASCII characters/patterns"),
            (r"\.lock$", "ends with .lock"),
            (r"/$", "ends with /"),
            (r"\.$", "ends with ."),
        ]

        offending_patterns = []

        for pattern, description in checks:
            if re.search(pattern, value):
                offending_patterns.append(description)

        if value == GLOBAL_BRANCH_NAME:
            return value  # this is the only allowed exception

        if offending_patterns:
            error_text = ", ".join(offending_patterns)
            raise ValidationError(f"Branch name contains invalid patterns or characters: {error_text}")

        return value

    @field_validator("branched_from", mode="before")
    @classmethod
    def set_branched_from(cls, value: str) -> str:
        return Timestamp(value).to_string()

    def get_branched_from(self) -> str:
        if not self.branched_from:
            raise RuntimeError(f"branched_from not set for branch {self.name}")
        return self.branched_from

    def get_created_at(self) -> str:
        if not self.created_at:
            raise RuntimeError(f"created_at not set for branch {self.name}")
        return self.created_at

    @property
    def is_terminal(self) -> bool:
        return self.status in (BranchStatus.MERGED, BranchStatus.DELETING)

    @property
    def active_schema_hash(self) -> SchemaBranchHash:
        if self.schema_hash:
            return self.schema_hash

        raise InitializationError("The schema_hash has not been loaded for this branch")

    @property
    def has_schema_changes(self) -> bool:
        if not self.schema_hash:
            return False

        origin_branch = self.get_origin_branch()
        if not origin_branch or not origin_branch.schema_hash:
            return False

        if self.schema_hash.main != origin_branch.schema_hash.main:
            return True

        return False

    def update_schema_hash(self, at: Timestamp | str | None = None) -> bool:
        latest_schema = registry.schema.get_schema_branch(name=self.name)
        new_hash = latest_schema.get_hash_full()
        if self.schema_hash and new_hash.main == self.schema_hash.main:
            return False

        self.schema_changed_at = Timestamp(at).to_string()
        self.schema_hash = new_hash
        return True

    @classmethod
    async def get_by_name(cls, name: str, db: InfrahubDatabase, ignore_deleting: bool = True) -> Branch:
        query = """
        MATCH (n:Branch)
        WHERE n.name = $name
        AND NOT n.status IN $ignore_statuses
        RETURN n
        """

        params: dict[str, Any] = {"name": name}
        params["ignore_statuses"] = []
        if ignore_deleting:
            params["ignore_statuses"].append(BranchStatus.DELETING.value)

        results = await db.execute_query(query=query, params=params, name="branch_get_by_name", type=QueryType.READ)

        if len(results) == 0:
            raise BranchNotFoundError(identifier=name)

        return cls.from_db(results[0].values()[0])

    @classmethod
    async def get_list(
        cls,
        db: InfrahubDatabase,
        limit: int = 1000,
        ids: list[str] | None = None,
        name: str | None = None,
        node_ordering: StandardNodeOrdering | None = None,
        **kwargs: dict[str, Any],
    ) -> list[Self]:
        # Extract branch-specific params from kwargs, a future refactoring could update the parent signature for
        # standard nodes instead. Should be considered when additional StandardNode subclasses are introduced
        branch_filters: BranchListFilters | None = kwargs.pop("branch_filters", None)  # type: ignore[assignment]
        exclude_global: bool = kwargs.pop("exclude_global", False)  # type: ignore[assignment]

        if branch_filters is None:
            branch_filters = BranchListFilters(name=name, ids=ids)
        else:
            branch_filters.ids = branch_filters.ids or ids
            branch_filters.name = branch_filters.name or name

        node_ordering = node_ordering or StandardNodeOrdering()
        query: Query = await BranchNodeGetListQuery.init(
            db=db,
            node_class=cls,
            branch_filters=branch_filters,
            exclude_global=exclude_global,
            limit=limit,
            node_ordering=node_ordering,
        )
        await query.execute(db=db)

        return [cls.from_db(node=cast("Neo4jNode", result.get("n"))) for result in query.get_results()]

    @classmethod
    async def get_list_count(
        cls,
        db: InfrahubDatabase,
        limit: int = 1000,
        ids: list[str] | None = None,
        name: str | None = None,
        partial_match: bool = False,
        branch_filters: BranchListFilters | None = None,
        node_ordering: StandardNodeOrdering | None = None,
        **_kwargs: Any,
    ) -> int:
        if branch_filters is None:
            branch_filters = BranchListFilters(name=name, ids=ids, partial_match=partial_match)
        else:
            branch_filters.ids = branch_filters.ids or ids
            branch_filters.name = branch_filters.name or name
            branch_filters.partial_match = branch_filters.partial_match or partial_match

        node_ordering = node_ordering or StandardNodeOrdering()
        query: Query = await BranchNodeGetListQuery.init(
            db=db,
            node_class=cls,
            branch_filters=branch_filters,
            limit=limit,
            exclude_global=True,
            node_ordering=node_ordering,
        )
        return await query.count(db=db)

    @classmethod
    def isinstance(cls, obj: Any) -> bool:
        return isinstance(obj, cls)

    def get_origin_branch(self) -> Optional[Branch]:
        """Return the branch Object of the origin_branch."""
        if not self.origin_branch or self.origin_branch == self.name:
            return None

        return registry.get_branch_from_registry(branch=self.origin_branch)

    def get_branches_in_scope(self) -> list[str]:
        """Return the list of all the branches that are constituing this branch.

        For now, either a branch is the default branch or it must inherit from it so we can only have 2 values at best
        But the idea is that it will change at some point in a future version.
        """
        default_branch = registry.default_branch
        if self.name == default_branch:
            return [self.name]

        return [default_branch, self.name]

    def get_branches_and_times_to_query(self, at: Optional[Timestamp] = None) -> dict[frozenset, str]:
        """Return all the names of the branches that are constituing this branch with the associated times excluding the global branch"""

        at = Timestamp(at)

        if self.is_default:
            return {frozenset([self.name]): at.to_string()}

        time_default_branch = at

        # If the branch is isolated, and if the time requested is after the creation of the branch
        if self.is_isolated and at > Timestamp(self.branched_from):
            time_default_branch = Timestamp(self.branched_from)

        return {
            frozenset([self.origin_branch]): time_default_branch.to_string(),
            frozenset([self.name]): at.to_string(),
        }

    def get_branches_and_times_to_query_global(
        self,
        at: Optional[Timestamp] = None,
        is_isolated: bool = True,
    ) -> dict[frozenset, str]:
        """Return all the names of the branches that are constituting this branch with the associated times."""

        at = Timestamp(at)

        if self.is_default:
            return {frozenset((GLOBAL_BRANCH_NAME, self.name)): at.to_string()}

        time_default_branch = at

        # If the branch is isolated, and if the time requested is after the creation of the branch
        if self.is_isolated and is_isolated and at > Timestamp(self.branched_from):
            time_default_branch = Timestamp(self.branched_from)

        return {
            frozenset((GLOBAL_BRANCH_NAME, self.origin_branch)): time_default_branch.to_string(),
            frozenset((GLOBAL_BRANCH_NAME, self.name)): at.to_string(),
        }

    def get_branches_and_times_for_range(
        self, start_time: Timestamp, end_time: Timestamp
    ) -> tuple[dict[str, str], dict[str, str]]:
        """Return the names of the branches that are constituing this branch with the start and end times."""

        start = {}
        end = {}

        time_branched_from = Timestamp(self.branched_from)
        time_created_at = Timestamp(self.created_at)

        # Ensure start time is not older than the creation of the branch (time_created_at)
        time_query_start = start_time
        if start_time < time_created_at:
            time_query_start = time_created_at

        start[self.name] = time_query_start.to_string()

        # START
        if not self.is_default and time_query_start <= time_branched_from:
            start[self.origin_branch] = time_branched_from.to_string()
        elif not self.is_default and time_query_start > time_branched_from:
            start[self.origin_branch] = time_query_start.to_string()

        # END
        end[self.name] = end_time.to_string()
        if not self.is_default:
            end[self.origin_branch] = end_time.to_string()

        return start, end

    async def create(self, db: InfrahubDatabase, user_id: str = SYSTEM_USER_ID) -> bool:
        self.graph_version = GRAPH_VERSION
        return await super().create(db=db, user_id=user_id)

    async def delete(self, db: InfrahubDatabase) -> None:
        if self.is_default:
            raise ValidationError(f"Unable to delete {self.name} it is the default branch.")
        if self.is_global:
            raise ValidationError(f"Unable to delete {self.name} this is an internal branch.")

        self.status = BranchStatus.DELETING
        await self.save(db=db)

        query = await DeleteBranchRelationshipsQuery.init(db=db, branch_name=self.name)
        await query.execute(db=db)
        await super().delete(db=db)

    def get_query_filter_relationships(
        self, rel_labels: list, at: Optional[Timestamp] = None, include_outside_parentheses: bool = False
    ) -> tuple[list, dict]:
        """
        Generate a CYPHER Query filter based on a list of relationships to query a part of the graph at a specific time and on a specific branch.
        """

        filters = []
        params: dict[str, Any] = {}

        if not isinstance(rel_labels, list):
            raise TypeError(f"rel_labels must be a list, not a {type(rel_labels)}")

        at = Timestamp(at)
        branches_times = self.get_branches_and_times_to_query_global(at=at)

        for idx, (branch_name, time_to_query) in enumerate(branches_times.items()):
            params[f"branch{idx}"] = list(branch_name)
            params[f"time{idx}"] = time_to_query

        for rel in rel_labels:
            filters_per_rel = []
            for idx in range(len(branches_times)):
                filters_per_rel.append(
                    f"({rel}.branch IN $branch{idx} AND {rel}.from <= $time{idx} AND {rel}.to IS NULL)"
                )
                filters_per_rel.append(
                    f"({rel}.branch IN $branch{idx} AND {rel}.from <= $time{idx} AND {rel}.to > $time{idx})"
                )

            if not include_outside_parentheses:
                filters.append("\n OR ".join(filters_per_rel))

            filters.append("(" + "\n OR ".join(filters_per_rel) + ")")

        return filters, params

    def get_query_filter_path(
        self,
        at: Optional[Union[Timestamp, str]] = None,
        is_isolated: bool = True,
        branch_agnostic: bool = False,
        variable_name: str = "r",
        params_prefix: str = "",
    ) -> tuple[str, dict]:
        """
        Generate a CYPHER Query filter based on a path to query a part of the graph at a specific time and on a specific branch.

        Examples:
            >>> rels_filter, rels_params = self.branch.get_query_filter_path(at=self.at)
            >>> self.params.update(rels_params)
            >>> query += "\n WHERE all(r IN relationships(p) WHERE %s)" % rels_filter

            There is a currently an assumption that the relationship in the path will be named 'r'
        """
        pp = params_prefix
        params: dict[str, Any] = {}
        at = Timestamp(at)
        at_str = at.to_string()
        if branch_agnostic:
            filter_str = (
                f"{variable_name}.from < ${pp}time1 AND ({variable_name}.to IS NULL or {variable_name}.to > ${pp}time1)"
            )
            params[f"{pp}time1"] = at_str
            return filter_str, params

        branches_times = self.get_branches_and_times_to_query_global(at=at, is_isolated=is_isolated)

        for idx, (branch_name, time_to_query) in enumerate(branches_times.items()):
            params[f"{pp}branch{idx}"] = list(branch_name)
            params[f"{pp}time{idx}"] = time_to_query

        filters = []
        for idx in range(len(branches_times)):
            filters.append(
                f"({variable_name}.branch IN ${pp}branch{idx} "
                f"AND {variable_name}.from <= ${pp}time{idx} AND {variable_name}.to IS NULL)"
            )
            filters.append(
                f"({variable_name}.branch IN ${pp}branch{idx} "
                f"AND {variable_name}.from <= ${pp}time{idx} "
                f"AND {variable_name}.to > ${pp}time{idx})"
            )

        filter_str = "(" + "\n OR ".join(filters) + ")"

        return filter_str, params

    async def rebase(
        self, db: InfrahubDatabase, at: str | Timestamp | None = None, user_id: str = SYSTEM_USER_ID
    ) -> None:
        """Rebase the current Branch with its origin branch"""

        at = Timestamp(at)

        await self.rebase_graph(db=db, at=at)

        self.branched_from = at.to_string()
        self.status = BranchStatus.OPEN
        await self.save(db=db, user_id=user_id)

        # Update the branch in the registry after the rebase
        registry.branch[self.name] = self

    async def rebase_graph(self, db: InfrahubDatabase, at: Timestamp) -> None:
        """Rebase all relationships on this branch to a new point in time.

        This method updates the graph to reflect the state of the branch as if it had been created
        at the specified timestamp. Relationships are processed as follows:

        - Relationships with no `to` timestamp and `from` <= at: Updated to start from `at`
        - Relationships with `to` < at: Deleted (ended before rebase point)
        - Relationships with `to` >= at: Updated to start from `at`

        Orphaned nodes (nodes with no remaining relationships) are also cleaned up.
        """
        query = await RebaseBranchQuery.init(db=db, branch=self, at=at)
        await query.execute(db=db)


registry.branch_object = Branch
