import json
import uuid as uuid_module
from collections import deque
from typing import Dict, List, Tuple

import click
from loguru import logger

from surfactant.configmanager import ConfigManager
from surfactant.plugin.manager import find_io_plugin, get_plugin_manager
from surfactant.sbomtypes._relationship import Relationship
from surfactant.sbomtypes._sbom import SBOM
from surfactant.sbomtypes._system import System


@click.argument("sbom_outfile", envvar="SBOM_OUTPUT", type=click.File("w"), required=True)
@click.argument("input_sboms", type=click.File("r"), required=True, nargs=-1)
@click.option(
    "--config_file",
    type=click.File("r"),
    required=False,
    help="Config file for controlling some aspects of the merged SBOM, primarily the creation of a new top-level system object (settings here will typically take precedence over command line options)",
)
@click.option(
    "--output_format",
    is_flag=False,
    default=ConfigManager().get(
        "core", "output_format", fallback="surfactant.output.cytrics_writer"
    ),
    help="SBOM output format, options=surfactant.output.[cytrics|csv|spdx]_writer",
)
@click.option(
    "--input_format",
    is_flag=False,
    default="surfactant.input_readers.cytrics_reader",
    help="SBOM input format, assumes that all input SBOMs being merged have the same format, options=surfactant.input_readers.[cytrics|cyclonedx|spdx]_reader",
)
@click.option(
    "--system_uuid",
    is_flag=False,
    help="System UUID to use for relationships to a top-level system object",
)
@click.option(
    "--system_relationship",
    is_flag=False,
    default="Contains",
    show_default=True,
    help="Relationship type between merged SBOM contents to a top-level system object",
)
@click.option(
    "--add_system/--no_add_system",
    default=False,
    show_default=True,
    help="Create a top-level system entry for tying together the merged SBOM components. When disabled, relationships will still be created to a provided system UUID",
)
@click.command("merge")
# pylint: disable-next=too-many-positional-arguments
def merge_command(
    input_sboms,
    sbom_outfile,
    config_file,
    output_format,
    input_format,
    system_uuid,
    system_relationship,
    add_system,
):
    """Merge two or more INPUT_SBOMS together into SBOM_OUTFILE.

    An optional CONFIG_FILE can be supplied to specify a root system entry
    """
    pm = get_plugin_manager()
    output_writer = find_io_plugin(pm, output_format, "write_sbom")
    input_reader = find_io_plugin(pm, input_format, "read_sbom")

    sboms = []
    for sbom in input_sboms:
        sboms.append(input_reader.read_sbom(sbom))
    config = None
    if config_file:
        config = json.load(config_file)
    merge(sboms, sbom_outfile, config, output_writer, system_uuid, system_relationship, add_system)


# pylint: disable-next=too-many-positional-arguments
def merge(
    input_sboms,
    sbom_outfile,
    config,
    output_writer,
    system_uuid=None,
    system_relationship="Contains",  # remember: keep in-sync with click arg default
    add_system=False,
):
    """Merge two or more SBOMs."""
    merged_sbom = input_sboms[0]
    for sbom_m in input_sboms[1:]:
        merged_sbom.merge(sbom_m)

    rel_graph = construct_relationship_graph(merged_sbom)
    roots = get_roots_check_cycles(rel_graph)

    # Check if provided UUID for a system object already exists to avoid creating a duplicate
    if config and "system" in config:
        if "UUID" in config["system"]:
            for s in merged_sbom.systems:
                if config["system"]["UUID"] == s.UUID:
                    add_system = False
                    break
    # Even if not adding the system, create a dummy/placeholder with the UUID for creating relationships
    system, using_random_uuid = create_system_object(merged_sbom, config, system_uuid)
    if add_system:
        merged_sbom.systems.append(system)

    # Add a system relationship to each root software/systems entry identified
    if not using_random_uuid or add_system:
        if config and "systemRelationship" in config:
            system_relationship = config["systemRelationship"]
        for r in roots:
            merged_sbom.relationships.add(
                Relationship(xUUID=system.UUID, yUUID=r, relationship=system_relationship)
            )
    else:
        logger.warning(
            "No top-level system relationships added; enable the add system option to randomly generate a UUID, or specify a system UUID"
        )

    output_writer.write_sbom(merged_sbom, sbom_outfile)


def construct_relationship_graph(sbom: SBOM):
    """Function to get create a relationship graph of systems and software within an sbom

    Args:
        sbom (SBOM): The sbom to generate relationship graph from.
    """
    # construct a graph for adding a system relationship to all root software entries
    rel_graph: Dict[str, List[str]] = {}
    # add all UUIDs as nodes in the graph
    for system in sbom.systems:
        rel_graph[system.UUID] = []
    for sw in sbom.software:
        rel_graph[sw.UUID] = []
    # iterate through all relationships, adding edges to the adjacency list
    for rel in sbom.relationships:
        if rel.xUUID not in rel_graph or rel.yUUID not in rel_graph:
            logger.error(
                f"Either the xUUID or yUUID for the relationship does not exist in the graph: {rel = }"
            )
            continue
        # consider also including relationship type for the edge
        # treat as directed graph, with inverted edges (pointing to parents) so dfs will eventually lead to the root parent node for a (sub)graph
        rel_graph[rel.yUUID].append(rel.xUUID)
    return rel_graph


def get_roots_check_cycles(rel_graph):
    """Function to get roots of the sbom and check for circular dependencies

    Args:
        rel_graph: The relationship graph for an sbom.
    """
    visited = set()
    roots = set()
    rootFound = set()
    recursionStack = deque()

    def dfs(rel):
        recursionStack.append(rel)
        # if the node is already visited, no revisiting required
        if rel in visited:
            recursionStack.pop()
            return rel in rootFound
        # mark the node as visited
        visited.add(rel)
        # the node has no parents, it is a root
        if not rel_graph[rel]:
            roots.add(rel)
            rootFound.add(rel)
            recursionStack.pop()
            return True
        cycle = False
        # node is not a root, move on to parents
        for parent in rel_graph[rel]:
            # detect cycles
            if parent in recursionStack:
                logger.info(f"CYCLE DETECTED: {parent} {rel}")
                cycle = True
            if dfs(parent):
                rootFound.add(rel)
        # if there was a cycle, and none of the parents led to a definite root node
        if cycle and rel not in rootFound:
            logger.info(f"CYCLE AND NO ROOT FOUND, SETTING {rel} AS THE ROOT")
            roots.add(rel)
            rootFound.add(rel)
        recursionStack.pop()
        return rel in rootFound

    for rel in rel_graph:
        dfs(rel)
    logger.info(f"ROOTS: {roots}")
    return roots


def create_system_object(sbom: SBOM, config=None, system_uuid=None) -> Tuple[System, bool]:
    """Function to create an accurate system object

    Positional arguments:
        sbom (SBOM): The SBOM the system object is being created for.
        config: The user specified config json (Optional).

    Returns:
        Tuple[System, bool]: The created system object and a boolean indicating if a random UUID was used.
    """

    system = {}
    using_random_uuid = False
    if config and "system" in config:
        system = config["system"]

    # system_uuid supplied via command line overrides config file UUID
    if system_uuid:
        system["UUID"] = system_uuid
    elif "UUID" not in system:
        # No UUID, generate a random one...
        using_random_uuid = True
        system["UUID"] = str(uuid_module.uuid4())
    # check if the UUID appears valid based on the CyTRICS schema
    elif not sbom.is_valid_uuid4(system["UUID"]):
        invalid_uuid = system["UUID"]
        logger.error(f"Invalid uuid4 given ({invalid_uuid}) for the system")

    if "name" not in system:
        system["name"] = ""
    captureStart = -1
    captureEnd = -1
    for sw in sbom.software:
        if captureStart and sw.captureTime:
            if captureStart == -1 or sw.captureTime < captureStart:
                captureStart = sw.captureTime
        if captureEnd and sw.captureTime:
            if captureEnd == -1 or sw.captureTime > captureEnd:
                captureEnd = sw.captureTime
    if "captureStart" not in system or not system["captureStart"]:
        system["captureStart"] = captureStart
    if "captureEnd" not in system or not system["captureEnd"]:
        system["captureEnd"] = captureEnd
    return System(**system), using_random_uuid
