from __future__ import annotations

import re
from typing import Any, Dict, List

from typing_extensions import Literal, TypedDict

# Model explanation:
# Nodes or edges group define nodes or edges of the same type.
# For example: City is a node group, Marseilles and Paris are nodes of type City.
# Nodes of the same type can be available in different columns and/or datasets.
# To configure all the sources of nodes of a specific type, one must configure one or many node group definitions.


# Type aliases
GraphId = str
"""
Generated id.
"""
SnapshotId = str
"""
Generated id.
"""
NodeGroupId = str
"""
Generated id.
"""
EdgeGroupId = str
"""
Generated id.
"""
DefinitionId = str
"""
Generated id.
"""
DbType = Literal["kuzu", "neo4j"]


# Edge model, this is basically the layout of what is persisted in metadata dataset.
class FilterMetadata(TypedDict):
    column: str
    operator: Literal["matches", "excludes", "above", "below", "range"]
    value: str
    max_value: Any


class EdgeGroupDefinitionMetadata(TypedDict):
    definition_id: DefinitionId
    edge_dataset: str

    source_column: str
    target_column: str

    property_list: List[str]

    filters_stored: List[FilterMetadata]
    filters_association: Literal["and", "or"]


class EdgeViewConfiguration(TypedDict):
    color: str | None
    size: int


class EdgeGroupMetadata(TypedDict):
    edge_id: EdgeGroupId
    """
    Not user defined, unique identifier of the edge type.
    """

    edge_group: str
    """
    User defined.
    In the usual graph sense, it is the type of the edge.
    """

    source_node_id: NodeGroupId
    target_node_id: NodeGroupId

    definitions: List[EdgeGroupDefinitionMetadata]


# Node model, this is basically the layout of what is persisted in metadata dataset.
class NodeViewConfiguration(TypedDict):
    color: str
    size: Literal["large", "medium", "normal", "small", "x-small"]
    icon: str


class NodeGroupDefinitionMetadata(TypedDict):
    definition_id: DefinitionId
    source_dataset: str
    primary_col: str
    label_col: str
    property_list: List[str]

    filters_stored: List[FilterMetadata]
    filters_association: Literal["and", "or"]


class NodeGroupMetadata(TypedDict):
    node_id: NodeGroupId
    """
    Not user defined, unique identifier of the node type.
    """

    node_group: str
    """
    User defined.
    In the usual graph sense, it is the type of the node.
    """

    definitions: List[NodeGroupDefinitionMetadata]


# Node model, this is basically the layout of what is persisted in metadata dataset.
class Sampling(TypedDict):
    sampling: Literal["head", "random", "all"]
    max_rows: int


class CypherQuery(TypedDict):
    id: str
    name: str
    query: str


class ConnectionConfiguration(TypedDict):
    type: DbType


class GraphMetadata(TypedDict):
    id: GraphId
    name: str

    nodes: Dict[NodeGroupId, NodeGroupMetadata]
    edges: Dict[EdgeGroupId, EdgeGroupMetadata]

    nodes_view: Dict[NodeGroupId, NodeViewConfiguration]
    edges_view: Dict[EdgeGroupId, EdgeViewConfiguration]

    sampling: Sampling

    cypher_queries: List[CypherQuery]

    connection: ConnectionConfiguration


class VersionedGraphMetadata(GraphMetadata):
    version_token: str
    """
    This property uniquely identifies the corresponding graph configuration.
    It is used for concurrent update control.
    """


class GraphMetadataSnapshot(TypedDict):
    id: SnapshotId
    name: str
    comment: str

    graph_id: GraphId
    graph_name: str

    # epoch in milliseconds
    epoch_ms: int

    nodes: Dict[NodeGroupId, NodeGroupMetadata]
    edges: Dict[EdgeGroupId, EdgeGroupMetadata]

    nodes_view: Dict[NodeGroupId, NodeViewConfiguration]
    edges_view: Dict[EdgeGroupId, EdgeViewConfiguration]

    cypher_queries: List[CypherQuery]


class EdgeGroupDefinition(TypedDict):
    definition_id: DefinitionId

    edge_id: EdgeGroupId
    edge_group: str

    edge_dataset: str

    source_node_id: NodeGroupId
    source_node_group: str
    source_column: str

    target_node_id: NodeGroupId
    target_node_group: str
    target_column: str

    property_list: List[str]

    filters_stored: List[FilterMetadata]
    filters_association: Literal["and", "or"]


class NodeGroupDefinition(TypedDict):
    """
    NodeGroupDefinitionMetadata enhanced with id and group info.
    """

    definition_id: DefinitionId

    node_id: NodeGroupId

    node_group: str

    source_dataset: str
    primary_col: str
    label_col: str
    property_list: List[str]

    filters_stored: List[FilterMetadata]
    filters_association: Literal["and", "or"]


class ExplorerMetadata(TypedDict):
    snapshot_id: SnapshotId
    db_type: DbType
    name: str
    comment: str

    epoch_ms: int

    node_definitions: List[NodeGroupDefinition]
    edge_definitions: List[EdgeGroupDefinition]

    nodes_view: Dict[NodeGroupId, NodeViewConfiguration]
    edges_view: Dict[EdgeGroupId, EdgeViewConfiguration]

    cypher_queries: List[CypherQuery]


def to_node_group_definition(
    node_id: NodeGroupId, node_group: str, definition: NodeGroupDefinitionMetadata
) -> NodeGroupDefinition:
    return NodeGroupDefinition(
        definition_id=definition["definition_id"],
        node_id=node_id,
        node_group=node_group,
        source_dataset=definition["source_dataset"],
        primary_col=definition["primary_col"],
        label_col=definition["label_col"],
        property_list=definition["property_list"],
        filters_stored=definition["filters_stored"],
        filters_association=definition["filters_association"],
    )


def to_node_group_definitions(group_metadata: NodeGroupMetadata) -> List[NodeGroupDefinition]:
    return [
        to_node_group_definition(group_metadata["node_id"], group_metadata["node_group"], definition)
        for definition in group_metadata["definitions"]
    ]


def to_edge_group_definitions(
    group_metadata: EdgeGroupMetadata, graph_metadata: GraphMetadata
) -> List[EdgeGroupDefinition]:
    edge_id = group_metadata["edge_id"]
    edge_group = group_metadata["edge_group"]
    source_node_id = group_metadata["source_node_id"]
    source_node_group = graph_metadata["nodes"][source_node_id]["node_group"]
    target_node_id = group_metadata["target_node_id"]
    target_node_group = graph_metadata["nodes"][target_node_id]["node_group"]

    return [
        EdgeGroupDefinition(
            definition_id=definition["definition_id"],
            edge_id=edge_id,
            edge_group=edge_group,
            edge_dataset=definition["edge_dataset"],
            source_node_id=source_node_id,
            source_node_group=source_node_group,
            source_column=definition["source_column"],
            target_node_id=target_node_id,
            target_node_group=target_node_group,
            target_column=definition["target_column"],
            property_list=definition["property_list"],
            filters_stored=definition["filters_stored"],
            filters_association=definition["filters_association"],
        )
        for definition in group_metadata["definitions"]
    ]


class ModelValidationError(Exception):
    def __init__(self, message: str) -> None:
        super().__init__(message)
        self.message = message


_NAME_REGEX = re.compile(r"[A-Za-z0-9_]+")
_NEO4J_DB_NAME_REGEX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$|^[A-Za-z0-9]$")


def validate_group_name(name: str) -> None:
    """
    Validate node/edge group names.

    Raises:
        ModelValidationError
    """
    if not name:
        raise ModelValidationError("Name cannot be empty.")
    if len(name) > 50:
        raise ModelValidationError("Name cannot be longer than 50 characters.")
    if " " in name:
        raise ModelValidationError("Name cannot contain spaces.")
    if not _NAME_REGEX.fullmatch(name):
        raise ModelValidationError("Name can only contain alphanumeric characters and underscores.")
    if not name[0].isalpha():
        raise ModelValidationError("Name must start with a letter.")


def validate_neo4j_db_name(name: str) -> None:
    """
    Validate Neo4j database name according to Neo4j naming rules.

    Raises:
        ModelValidationError
    """
    if not name:
        raise ModelValidationError("Database name cannot be empty.")

    if len(name) < 3:
        raise ModelValidationError("Database name must be at least 3 characters long.")

    if len(name) > 63:
        raise ModelValidationError("Database name cannot be longer than 63 characters.")

    if name.startswith("_"):
        raise ModelValidationError("Database name cannot start with underscore.")

    if name.lower().startswith("system"):
        raise ModelValidationError("Database name cannot start with 'system' (reserved prefix).")

    if not _NEO4J_DB_NAME_REGEX.fullmatch(name):
        raise ModelValidationError(
            "Database name can only contain alphanumeric characters, dots, and dashes. "
            "It must start and end with an alphanumeric character."
        )


def create_neo4j_db_name(prefix: str, project_key: str, identifier: str) -> str:
    """
    Create a valid Neo4j database name.

    Args:
        prefix: "dku" for published, "tmp" for editor temp
        project_key: DSS project key
        identifier: snapshot_id or graph_id

    Returns:
        Sanitized database name (max 63 chars, alphanumeric only, lowercase).
        Note: Does not enforce min length - caller should validate result if needed.
    """
    truncated_project_key = project_key[:32]
    combined = f"{prefix}{truncated_project_key}{identifier}"
    sanitized = re.sub(r"[^a-zA-Z0-9]", "", combined)
    sanitized = sanitized[:63]
    return sanitized.lower()


def __check_name_conflicts__(graph_metadata: GraphMetadata, name: str) -> None:
    """
    Raises:
        ModelValidationError
    """
    for node_group in graph_metadata["nodes"].values():
        if node_group["node_group"] == name:
            raise ModelValidationError(f"A node group named '{name}' already exists. Names should be unique.")
    for edge_group in graph_metadata["edges"].values():
        if edge_group["edge_group"] == name:
            raise ModelValidationError(f"An edge group named '{name}' already exists. Names should be unique.")


def validate_node_group(graph_metadata: GraphMetadata, group_metadata: NodeGroupMetadata) -> None:
    """
    Raises:
        ModelValidationError
    """
    name_to_validate = group_metadata["node_group"]
    validate_group_name(name_to_validate)

    # If it is a new node or if the name changed, we need to check if the new name is already used.
    should_check_name_conflicts = (
        group_metadata["node_id"] not in graph_metadata["nodes"]
        or name_to_validate != graph_metadata["nodes"][group_metadata["node_id"]]["node_group"]
    )

    if should_check_name_conflicts:
        __check_name_conflicts__(graph_metadata, name_to_validate)

    definition_ids = [d["definition_id"] for d in group_metadata["definitions"]]
    if len(definition_ids) != len(set(definition_ids)):
        raise ModelValidationError("Node definition identifiers should be unique.")


def validate_edge_group(graph_metadata: GraphMetadata, group_metadata: EdgeGroupMetadata) -> None:
    """
    Raises:
        ModelValidationError
    """
    name_to_validate = group_metadata["edge_group"]
    validate_group_name(name_to_validate)

    # If it is a new edge or if the name changed, we need to check if the new name is already used.
    should_check_name_conflicts = (
        group_metadata["edge_id"] not in graph_metadata["edges"]
        or name_to_validate != graph_metadata["edges"][group_metadata["edge_id"]]["edge_group"]
    )

    if should_check_name_conflicts:
        __check_name_conflicts__(graph_metadata, name_to_validate)

    source_node_id = group_metadata["source_node_id"]
    target_node_id = group_metadata["target_node_id"]
    if not source_node_id or not target_node_id:
        raise ModelValidationError("Source or target nodes are not set.")

    if source_node_id not in list(graph_metadata["nodes"].keys()):
        raise ModelValidationError(f"Source node id '{source_node_id}' does not exist.")

    if target_node_id not in list(graph_metadata["nodes"].keys()):
        raise ModelValidationError(f"Target node id '{target_node_id}' does not exist.")

    definition_ids = [d["definition_id"] for d in group_metadata["definitions"]]
    if len(definition_ids) != len(set(definition_ids)):
        raise ModelValidationError("Edge definition identifiers should be unique.")


def validate_sampling(sampling: Sampling):
    """
    Raises:
        ModelValidationError
    """
    if sampling["sampling"] not in ["head", "random", "all"]:
        raise ModelValidationError(f"Unknown sampling strategy '{sampling['sampling']}'.")

    if sampling["max_rows"] < 1 or sampling["max_rows"] > 1_000_000_000:
        raise ModelValidationError(
            f"Sampling max rows must be greater than 0 or lower than 1 000 000 000, currently '{sampling['max_rows']}'."
        )


class GraphDoesNotExistError(Exception):
    graph_id: str | None

    def __init__(self, graph_id: GraphId | None) -> None:
        super().__init__(f"Graph {graph_id} does not exist.")
        self.graph_id = graph_id


class GraphAlreadyExistsError(Exception):
    graph_id: str | None

    def __init__(self, graph_id: GraphId | None) -> None:
        super().__init__(f"Graph {graph_id} already exists.")
        self.graph_id = graph_id


class GraphDBDoesNotExistError(Exception):
    def __init__(self) -> None:
        super().__init__("Graph database does not exist.")


class GraphDBWriteInProgressError(Exception):
    def __init__(self) -> None:
        super().__init__(f"Graph is currently being built (write lock in place).")


class GraphDBReadInProgressError(Exception):
    def __init__(self) -> None:
        super().__init__(f"Graph is in use for reading; cannot acquire write lock.")


class SnapshotDoesNotExistError(Exception):
    snapshot_id: str | None

    def __init__(self, snapshot_id: SnapshotId | None) -> None:
        super().__init__(f"Saved configuration {snapshot_id} does not exist.")
        self.snapshot_id = snapshot_id


class DeletingNodeWithDepedentEdgesError(Exception):
    def __init__(self, node_group: str, edge_group: str) -> None:
        super().__init__(f"Attempting to delete node {node_group} while edge {edge_group} is dependent on it.")


class FeatureUnavailableError(Exception):
    def __init__(self, message) -> None:
        super().__init__(message)


class PresetNotSetException(Exception):
    def __init__(self) -> None:
        super().__init__("A preset is mandatory to perform this operation.")


class PropertyTypeNotSupportedError(Exception):
    def __init__(self, property_name: str, dataiku_type: str, db_type: DbType) -> None:
        self.property_name = property_name
        self.dataiku_type = dataiku_type
        self.db_type = db_type
        super().__init__(
            f"Property '{property_name}' with Dataiku type '{dataiku_type}' is not supported by {db_type}."
        )
