from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
import os
import modules.logger_tool as logger
log_name = 'api_modules_database_tools_neo4j_session_tools'
log_dir = os.getenv("LOG_PATH", "/logs")  # Default path as fallback
logging = logger.get_logger(
    name=log_name,
    log_level=os.getenv("LOG_LEVEL", "DEBUG"),
    log_path=log_dir,
    log_file=log_name,
    runtime=True,
    log_format='default'
)
import modules.database.tools.queries as query
from contextlib import suppress

def get_node_by_uuid_string_and_adjacent_nodes(session, uuid_string):
    return session.read_transaction(_get_node_by_uuid_string_and_adjacent_nodes, uuid_string)

def _get_node_by_uuid_string_and_adjacent_nodes(tx, uuid_string):
    query = """
    MATCH (n {uuid_string: $uuid_string})
    OPTIONAL MATCH (n)-[r]-(adjacent)
    RETURN n AS node, COLLECT(DISTINCT {node: adjacent, relationship: r}) AS connected_nodes
    """
    result = tx.run(query, uuid_string=uuid_string)
    record = result.single()
    if record:
        node = record["node"]
        connected_nodes = record["connected_nodes"]
        return {"node": node, "connected_nodes": connected_nodes}
    return None

def delete_all_nodes_and_relationships(session):
    total_deleted = 0
    while True:
        deleted_count = session.write_transaction(_delete_batch)
        total_deleted += deleted_count
        if deleted_count == 0:
            break

def _delete_batch(tx):
    result_data = tx.run(query.delete_batch, batch_size=10000).single()
    return 0 if result_data is None else result_data[0]

def delete_all_constraints(session):
    if show_constraints_result := session.run(query.show_constraints).data():
        for constraint in show_constraints_result:
            constraint_name = constraint['name']
            session.run(query.drop_constraint(constraint_name))
        
def reset_all_indexes(session):
    indexes = session.run(query.show_indexes).data()
    for index in indexes:
        index_name = index['name']
        session.run(query.drop_index(index_name))
        
def reset_databases(session):
    delete_all_nodes_and_relationships(session)
    delete_all_constraints(session)
    reset_all_indexes(session)
    
def close_session(session):
    if session:
        with suppress(Exception):
            session.close()

def create_node(session, label, properties, returns=False):
    """
    Function to create a node in Neo4j database.
    
    Args:
        driver (neo4j.Driver): The Neo4j driver.
        session (str): The Neo4j session.
        label (str): The label of the node.
        properties (dict): A dictionary of properties for the node.
        
    Example usage:
        create_node(neo4j_driver, "Topic", {"TopicID": "AP.PAG10", "Title": "Topic 10"})
        
    Returns:
        None
    """
    transaction = session.write_transaction(_create_node, label, properties)
    if returns:
        transaction_id = transaction.id
        # logging.database(f"Created {label} node with transaction ID {transaction_id} and properties {properties}")
        print(f"Created {label} node with transaction ID {transaction_id} and properties {properties}")
        return find_node_by_transaction_id(session, transaction_id)
    else:
        # logging.warning(f"Failed to create {label} node with properties {properties}")
        print(f"Failed to create {label} node with properties {properties}")
        return None

def _create_node(tx, label, properties):
    query = f"""
    CREATE (n:{label} $properties)
    RETURN n
    """
    # logging.query(f"Running query: {query}")
    print(f"Running query: {query}")
    result = tx.run(query, properties=properties)
    return result.single()[0] if result.single() is not None else None  # Handle no record found

# Function to find a node by its element ID
def find_node_by_transaction_id(session, transaction_id):
    """
    Function to find a node in Neo4j database by its element ID.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        element_id (str): The element ID of the node to find.

    Returns:
        The matched node.
    """
    return session.read_transaction(_find_node_by_element_id, transaction_id)
    
def _find_node_by_element_id(tx, transaction_id):
    query = """
    MATCH (n)
    WHERE id(n) = $transaction_id
    RETURN n
    """
    # logging.query(f"Running query: {query}")
    result = tx.run(query, transaction_id=transaction_id)
    record = result.single()  # Get the single result record, if any
    return record[0] if record is not None else None  # Handle no record found

# Function to create a relationship between two nodes in Neo4j
def create_relationship(session, start_node, end_node, label, properties=None, returns=False):
    """
    Function to create a relationship between two nodes in Neo4j database.
    
    Args:
        driver (neo4j.Driver): The Neo4j driver.
        session (str): The Neo4j session.
        start_node (str): The ID of the start node.
        end_node (str): The ID of the end node.
        rel_type (str): The type of the relationship.
        properties (dict): A dictionary of properties for the relationship.
        
    Example usage:
        create_relationship(neo4j_driver, "AP.PAG10", "AP.PAG11", "HAS_NEXT")
        
    Returns:
        None
    """
    relationship = session.write_transaction(_create_relationship, start_node, end_node, label, properties)
    if returns:
        relationship_id = relationship.id
        return find_relationship_by_relationship_id(session, relationship_id)
    else:
        return None
        
def _create_relationship(tx, start_node, end_node, label, properties):
    query = f"""
    MATCH (a), (b)
    WHERE ID(a) = $start_node_id AND ID(b) = $end_node_id
    CREATE (a)-[r:{label}]->(b)
    RETURN r
    """
    # logging.query(f"Running query: {query}")
    result = tx.run(query, start_node_id=start_node.id, end_node_id=end_node.id, properties=properties)
    single_result = result.single()
    return single_result[0] if single_result is not None else None

def order_list_of_nodes_by_property(session, label, property_name, order="ASC"):
    """
    Function to order a list of nodes in Neo4j database by a property.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        label (str): The label of the nodes to find.
        property_name (str): The name of the property to order by.
        order (str): The order of the sorting (ASC or DESC).

    Returns:
        List of matched nodes.
    """
    return session.read_transaction(_order_list_of_nodes_by_property, label, property_name, order)

def _order_list_of_nodes_by_property(tx, label, property_name, order):
    query = f"""
    MATCH (n:{label})
    RETURN n
    ORDER BY n.{property_name} {order}
    """
    # logging.query(f"Running query: {query}")
    result = tx.run(query)
    return [record["n"] for record in result]

def find_relationship_by_relationship_id(session, relationship_id):
    """
    Function to find a relationship in Neo4j database by its relationship ID.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        relationship_id (str): The relationship ID of the relationship to find.

    Returns:
        The matched relationship.
    """
    return session.read_transaction(_find_relationship_by_relationship_id, relationship_id)
    
def _find_relationship_by_relationship_id(tx, relationship_id):
    query = """
    MATCH ()-[r]->()
    WHERE id(r) = $relationship_id
    """
    # logging.query(f"Running query: {query}")
    print(f"Running query: {query}")
    result = tx.run(query, relationship_id=relationship_id)
    record = result.single()  # Get the single result record, if any
    return record[0] if record is not None else None  # Handle no record found

# Function to find nodes in Neo4j database by label
def find_nodes_by_label(session, label):
    """
    Function to find nodes in Neo4j database by label.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        label (str): The label of the nodes to find.
        
    Example usage:
        find_nodes_by_label(neo4j_driver, "Topic")

    Returns:
        List of matched nodes.
    """
    return session.read_transaction(_find_nodes_by_label, label)

def _find_nodes_by_label(tx, label):
    query = f"""
    MATCH (n:{label})
    RETURN n
    """
    # logging.query(f"Running query: {query}")
    print(f"Running query: {query}")
    result = tx.run(query)
    return [record["n"] for record in result]

def get_node_by_uuid_string(session, uuid_string):
    return session.read_transaction(_get_node_by_uuid_string, uuid_string)

def _get_node_by_uuid_string(tx, uuid_string):
    query = f"""
    MATCH (n)
    WHERE n.uuid_string = $uuid_string
    RETURN n
    """
    logging.debug(f"Executing query with uuid_string: {uuid_string}")
    result = tx.run(query, uuid_string=uuid_string)
    record = result.single()
    if record is None:
        logging.warning(f"No node found with uuid_string: {uuid_string}")
        return None
    return record[0]

# Function to find nodes in Neo4j database by label and properties
def find_nodes_by_label_and_properties(session, label, properties):
    """
    Function to find nodes in Neo4j database by label and properties.

    Args:
        session (neo4j.Session): The Neo4j session.
        label (str): The label of the nodes to find.
        properties (dict): A dictionary of properties to match.

    Returns:
        List of matched nodes.
    """
    logging.debug(f"Finding nodes with label: {label} and properties: {properties}")
    with session:
        response = session.read_transaction(_find_nodes_by_label_and_properties, label, properties)
        logging.debug(f"Response: {response}")
        return response

def _find_nodes_by_label_and_properties(tx, label, properties):
    query = f"""
    MATCH (n:{label})
    WHERE {' AND '.join([f'n.{key} = ${key}' for key in properties.keys()])}
    RETURN n
    """
    logging.debug(f"Running query: {query}")
    result = tx.run(query, **properties)
    logging.debug(f"Result: {result}")
    return [record["n"] for record in result]

# Function to find relationships in Neo4j database by type
def find_relationships_by_type(session, rel_type):
    """
    Function to find relationships in Neo4j database by type.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        rel_type (str): The type of the relationships to find.

    Returns:
        List of matched relationships.
    """
    return session.read_transaction(_find_relationships_by_type, rel_type)
    
def _find_relationships_by_type(tx, rel_type):
    query = f"""
    MATCH ()-[r:{rel_type}]->()
    RETURN r
    """
    # logging.query(f"Running query: {query}")
    print(f"Running query: {query}")
    result = tx.run(query)
    return [record["r"] for record in result]

# Function to find relationships in Neo4j database by type and properties
def find_relationships_by_type_and_properties(session, label, properties):
    """
    Function to find relationships in Neo4j database by type and properties.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        rel_type (str): The type of the relationships to find.
        properties (dict): A dictionary of properties to match.

    Returns:
        List of matched relationships.
    """
    return session.read_transaction(_find_relationships_by_type_and_properties, label, properties)
    

def _find_relationships_by_type_and_properties(tx, label, properties):
    query = f"""
    MATCH (a)-[r:{label}]->(b)
    WHERE {' AND '.join([f'r.{key} = ${key}' for key in properties.keys()])}
    RETURN r
    """
    # logging.query(f"Running query: {query}")
    print(f"Running query: {query}")
    result = tx.run(query, **properties)
    return [record["r"] for record in result]

# Function to find nodes and relationships in Neo4j database by label and properties
def find_nodes_and_relationships_by_label_and_properties(session, label, properties):
    """
    Function to find nodes and relationships in Neo4j database by label and properties.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        label (str): The label of the nodes to find.
        properties (dict): A dictionary of properties to match.

    Returns:
        List of matched nodes and relationships.
    """
    return session.read_transaction(_find_nodes_and_relationships_by_label_and_properties, label, properties)
    
def _find_nodes_and_relationships_by_label_and_properties(tx, label, properties):
    query = f"""
    MATCH (n:{label})
    WHERE {' AND '.join([f'n.{key} = ${key}' for key in properties.keys()])}
    RETURN n
    """
    # logging.query(f"Running query: {query}")
    result = tx.run(query, **properties)
    return [record["n"] for record in result]

# Function to delete nodes in Neo4j based on given criteria
def delete_nodes(session, criteria, delete_related=False):
    """
    Function to delete nodes in Neo4j based on given criteria.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        criteria (dict): A dictionary containing the properties to match for deletion.
        delete_related (bool): If True, deletes related nodes and relationships; otherwise, deletes only the matched nodes.

    Example usage:
        # Delete only the nodes matching the criteria
        delete_nodes(neo4j_driver, {'TopicID': 'AP.PAG10'})

        # Delete the nodes and their related relationships
        delete_nodes(neo4j_driver, {'TopicID': 'AP.PAG10'}, delete_related=True)
    """
    session.write_transaction(_delete_nodes, criteria, delete_related)

def _delete_nodes(tx, criteria, delete_related=False):
    """
    Internal function to execute a Cypher query to delete nodes based on criteria.

    Args:
        tx (neo4j.Transaction): The Neo4j transaction.
        criteria (dict): A dictionary containing the properties to match for deletion.
        delete_related (bool): Specifies whether to delete related nodes and relationships.
    """
    condition_str = " AND ".join([f"n.{key} = ${key}" for key in criteria])
    if delete_related:
        query = f"""
        MATCH (n)-[r]-()
        WHERE {condition_str}
        DELETE n, r
        """
    else:
        query = f"""
        MATCH (n)
        WHERE {condition_str}
        DELETE n
        """
    # logging.query(f"Running query: {query}")
    tx.run(query, **criteria)

# Function to delete all nodes and relationships in the Neo4j database in batches
def delete_lots_of_nodes_and_relationships(session):
    """
    Function to delete all nodes and relationships in the Neo4j database in batches.

    Args:
        driver (neo4j.Driver): The Neo4j driver.
        session (str): The Neo4j session.
    """
    total_deleted = 0
    while True:
        deleted_count = session.write_transaction(_delete_batch)
        total_deleted += deleted_count
        if deleted_count == 0:
            break  # Exit the loop if no more nodes are deleted
    # logging.prod(f"All nodes and relationships have been deleted. Total deleted: {total_deleted}")
    print(f"Neo4j:  All nodes and relationships have been deleted. Total deleted: {total_deleted}")

def _delete_batch(tx):
    """
    Function to execute a Cypher query to delete a batch of nodes and relationships.

    Args:
        tx (neo4j.Transaction): The Neo4j transaction.
    """
    batch_size = 10000  # Adjust the batch size according to your needs
    query = """
    MATCH (n)
    WITH n LIMIT $batch_size
    DETACH DELETE n
    RETURN count(*)
    """
    # logging.query(f"Running query: {query}")
    result = tx.run(query, batch_size=batch_size)
    result_data = result.single()
    
    if result_data is None:
        return 0
    deleted_count = result_data[0]
    if deleted_count is None:  # This check might be redundant, but kept for clarity
        return 0
    if deleted_count > 0:
        # logging.database(f"Deleted {deleted_count} nodes.")
        print(f"Neo4j: Deleted {deleted_count} nodes.")
    return deleted_count

def delete_all_constraints(session):
    # Correct command to fetch all constraints for Neo4j 4.x and later
    constraints_query = "SHOW CONSTRAINTS"
    # logging.query(f"Running query: {constraints_query}")
    if constraints_query_result := session.run(constraints_query).data():
        for constraint in constraints_query_result:
            # Ensure correct key is used to extract constraint name
            constraint_name = constraint['name']  # Adjust this if necessary
            drop_query = f"DROP CONSTRAINT {constraint_name}"
            # logging.query(f"Running query: {drop_query}")
            session.run(drop_query)
            # logging.database(f"Dropped constraint: {constraint_name}")
            print(f"Neo4j: Dropped constraint: {constraint_name}")
    else:
        # logging.warning("No constraints found to delete.")
        print("Neo4j: No constraints found to delete.")
        
def reset_all_indexes(session):
    indexes = session.run("SHOW INDEXES").data()
    for index in indexes:
        index_name = index['name']
        session.run(f"DROP INDEX {index_name}")
        # logging.info(f"Deleted index: {index_name}")
        print(f"Neo4j: Deleted index: {index_name}")

def reset_database_in_session(session):
    logging.debug("Neo4j: Resetting database")
    delete_lots_of_nodes_and_relationships(session)
    delete_all_constraints(session)
    reset_all_indexes(session)
    logging.info("Neo4j: Database reset")

def create_database(session, db_name):
    """
    Creates a new database in Neo4j if it does not already exist.

    Args:
        session (neo4j.Session): The Neo4j session.
        db_name (str): The name of the database to create.
    """
    logging.debug(f"Neo4j: Creating database {db_name}")
    query = f"CREATE DATABASE `{db_name}` IF NOT EXISTS"
    try:
        session.run(query)
        logging.info(f"Neo4j: Database {db_name} created successfully.")
    except Exception as e:
        logging.error(f"Neo4j: Failed to create database {db_name}: {str(e)}")
