"""CMEM plugin for SHACl validation using pySHACL"""

from collections import OrderedDict
from datetime import UTC, datetime
from tempfile import NamedTemporaryFile
from time import time

import validators.url
from cmem.cmempy.dp.proxy.graph import get, post_streamed
from cmem_plugin_base.dataintegration.context import ExecutionContext
from cmem_plugin_base.dataintegration.description import Icon, Plugin, PluginParameter
from cmem_plugin_base.dataintegration.entity import (
    Entities,
    Entity,
    EntityPath,
    EntitySchema,
)
from cmem_plugin_base.dataintegration.parameter.choice import ChoiceParameterType
from cmem_plugin_base.dataintegration.parameter.graph import (
    GraphParameterType,
    get_graphs_list,
)
from cmem_plugin_base.dataintegration.plugins import WorkflowPlugin
from cmem_plugin_base.dataintegration.types import (
    BoolParameterType,
    IntParameterType,
    StringParameterType,
)
from cmem_plugin_base.dataintegration.utils import setup_cmempy_user_access
from pyshacl import validate
from rdflib import (
    PROV,
    RDF,
    RDFS,
    SH,
    SKOS,
    XSD,
    BNode,
    Graph,
    Literal,
    Namespace,
    URIRef,
)
from rdflib.term import Node

SKOSXL = Namespace("http://www.w3.org/2008/05/skos-xl#")
DATA_GRAPH_TYPES = [
    "https://vocab.eccenca.com/di/Dataset",
    "http://rdfs.org/ns/void#Dataset",
    "https://vocab.eccenca.com/shui/ShapeCatalog",
    "http://www.w3.org/2002/07/owl#Ontology",
    "https://vocab.eccenca.com/dsm/ThesaurusProject",
]


def e_t(start: float) -> float:
    """Calculate rounded elapsed time"""
    return round(time() - start, 3)


def get_label(graph: Graph, subject: URIRef | BNode):  # noqa: ANN201
    """Get preferred label"""
    labels = preferred_label(graph, subject)
    if labels:
        return labels[0][1]
    return None


def preferred_label(
    graph: Graph,
    subject: URIRef | BNode,
    lang: str | None = None,
    default: list | None = None,
    label_properties: tuple = (
        RDFS.label,
        SKOSXL.prefLabel / SKOSXL.literalForm,
        SKOS.prefLabel,
    ),
) -> list:
    """Adapted from rdflib 6.1.1, removed in rdflib 6.2.0"""
    if default is None:
        default = []
    # setup the language filtering
    if lang is not None:
        if lang == "":  # we only want not language-tagged literals

            def langfilter(lbl: Literal) -> bool:
                return lbl.language is None
        else:

            def langfilter(lbl: Literal) -> bool:
                return lbl.language == lang
    else:  # we don't care about language tags

        def langfilter(lbl: Literal) -> bool:  # noqa: ARG001
            return True

    for label_prop in label_properties:
        labels = list(filter(langfilter, graph.objects(subject, label_prop)))  # type:ignore[arg-type, var-annotated]
        if len(labels) == 0:
            continue
        return [(label_prop, lbl) for lbl in labels]
    return default


@Plugin(
    label="SHACL validation with pySHACL",
    icon=Icon(file_name="pyshacl.svg", package=__package__),
    plugin_id="shacl-pyshacl",
    description="Performs SHACL validation with pySHACL.",
    documentation="Performs SHACL validation with "
    "[pySHACL](https://github.com/RDFLib/pySHACL). Select a "
    "__Data graph__ and a __SHACL shapes graph__ to get started. The "
    "plugin can output __Entities__, and/or produce a more detailed "
    "__Validation graph__ by specifying a __Validation graph URI__. "
    "Additional configuration parameters can be set to control the "
    "output generated by the plugin. Refer to each parameter "
    "description for details.",
    parameters=[
        PluginParameter(
            param_type=GraphParameterType(classes=DATA_GRAPH_TYPES),
            name="data_graph_uri",
            label="Data graph URI",
            description="The URI of the graph to be validated. The graph URI is "
            "selected from a list of graphs of types `void:Dataset`, "
            "`shui:ShapeCatalog`, `owl:Ontology` and "
            "`dsm:ThesaurusProject`.",
        ),
        PluginParameter(
            param_type=GraphParameterType(classes=["https://vocab.eccenca.com/shui/ShapeCatalog"]),
            name="shacl_graph_uri",
            label="SHACL shapes graph URI",
            description="The URI of the graph containing the SHACL shapes to be "
            "validated against. The graph URI is selected from a list of "
            "graphs of type `shui:ShapeCatalog`.",
        ),
        PluginParameter(
            param_type=StringParameterType(),
            name="validation_graph_uri",
            label="Validation graph URI",
            description="If the `Generate validation graph` option is enabled the "
            "validation graph is posted to the CMEM instance with this "
            "graph URI.",
            default_value="",
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="generate_graph",
            label="Generate validation graph",
            description="If enabled, the validation graph is posted to the CMEM "
            "instance with the graph URI specified with the `Validation "
            "graph URI` option.",
            default_value=False,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="output_entities",
            label="Output entities",
            description="If enabled, the plugin outputs the validation results as "
            "entities and can be connected to, for instance, a CSV "
            "dataset to produce a results table.",
            default_value=False,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="clear_validation_graph",
            label="Clear validation graph",
            description="If enabled, the validation graph is cleared before workflow execution.",
            default_value=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="owl_imports",
            label="Resolve owl:imports",
            description="If enabled, the graph tree defined with owl:imports in the "
            "data graph is resolved.",
            default_value=True,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="skolemize",
            label="Blank node skolemization",
            description="If enabled, blank nodes in the validation graph are skolemized into URIs.",
            default_value=True,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="add_labels",
            label="Add labels",
            description="If enabled, `rdfs:label` triples are added to the validation "
            "graph for instances of `sh:ValidationReport` and "
            "`sh:ValidationResult`.",
            default_value=True,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="include_graphs_labels",
            label="Add labels to focus nodes and values",
            description="If enabled along with the `Add labels` option, `rdfs:label` "
            "triples are added for the focus nodes, values and SHACL "
            "shapes in the validation graph. The labels are taken from "
            "the specified data and SHACL graphs.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="add_shui_conforms",
            label="Add shui:conforms flag to focus node resources.",
            description="If enabled, `shui:conforms false` triples are added to the "
            "focus nodes in the validation graph.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="meta_shacl",
            label="Meta-SHACL",
            description="If enabled, the SHACL shapes graph is validated against the "
            "SHACL-SHACL shapes graph before validating the data graph.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=GraphParameterType(classes=["http://www.w3.org/2002/07/owl#Ontology"]),
            name="ontology_graph_uri",
            label="Ontology graph URI",
            description="The URI of a graph containing extra ontological information. "
            "RDFS and OWL definitions from this are used to inoculate the "
            "data graph. The graph URI is selected from a list of graphs "
            "of type `owl:Ontology`.",
            default_value="",
            advanced=True,
        ),
        PluginParameter(
            param_type=ChoiceParameterType(
                OrderedDict({"none": "None", "rdfs": "RDFS", "owlrl": "OWLRL", "both": "Both"})
            ),
            name="inference",
            label="Inference",
            description="If enabled, OWL inferencing expansion of the data graph is "
            "performed before validation. Options are RDFS, OWLRL, Both, "
            "None.",
            default_value="none",
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="advanced",
            label="SHACL advanced features",
            description="Enable SHACL advanced features.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="js",
            label="SHACL-JS features",
            description="Enable SHACL-JS features.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="remove_dataset_graph_type",
            label="Remove graph type http://rdfs.org/ns/void#Dataset from data graph",
            description="Before validating, remove the triple `<data_graph_uri> a "
            "<http://rdfs.org/ns/void#Dataset>` from the in-memory data graph.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="remove_thesaurus_graph_type",
            label="Remove graph type https://vocab.eccenca.com/dsm/ThesaurusProject "
            "from data graph",
            description="Before validating, remove the triple `<data_graph_uri> a "
            "<https://vocab.eccenca.com/dsm/ThesaurusProject>` from the in-memory data "
            "graph.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=BoolParameterType(),
            name="remove_shape_catalog_graph_type",
            label="Remove graph type https://vocab.eccenca.com/shui/ShapeCatalog from data graph",
            description="Before validating, remove the triple `<data_graph_uri> a "
            "<https://vocab.eccenca.com/shui/ShapeCatalog>` from the in-memory data "
            "graph.",
            default_value=False,
            advanced=True,
        ),
        PluginParameter(
            param_type=IntParameterType(),
            name="max_validation_depth",
            label="Specify a custom max-evaluation-depth",
            description="specify a custom max-evaluation-depth. If you find yourself with a "
            "legitimate use case, and you are certain you need to increase this limit, and you are "
            "cetain you know what you are doing.",
            default_value=15,
            advanced=True,
        ),
    ],
)
class ShaclValidation(WorkflowPlugin):
    """Plugin class"""

    def __init__(  # noqa: PLR0913
        self,
        data_graph_uri: str,
        shacl_graph_uri: str,
        ontology_graph_uri: str = "",
        generate_graph: bool = False,
        validation_graph_uri: str = "",
        output_entities: bool = False,
        clear_validation_graph: bool = True,
        owl_imports: bool = True,
        skolemize: bool = True,
        add_labels: bool = True,
        include_graphs_labels: bool = False,
        add_shui_conforms: bool = False,
        meta_shacl: bool = False,
        inference: str = "none",
        advanced: bool = False,
        js: bool = False,
        remove_dataset_graph_type: bool = False,
        remove_thesaurus_graph_type: bool = False,
        remove_shape_catalog_graph_type: bool = False,
        max_validation_depth: int = 15,
    ) -> None:
        self.data_graph_uri = data_graph_uri
        self.shacl_graph_uri = shacl_graph_uri
        self.ontology_graph_uri = ontology_graph_uri
        self.validation_graph_uri = validation_graph_uri
        self.generate_graph = generate_graph
        self.output_entities = output_entities
        self.owl_imports = owl_imports
        self.clear_validation_graph = clear_validation_graph
        self.skolemize = skolemize
        self.add_labels = add_labels
        self.include_graphs_labels = include_graphs_labels
        self.add_shui_conforms = add_shui_conforms
        self.meta_shacl = meta_shacl
        self.inference = inference
        self.advanced = advanced
        self.js = js
        self.remove_dataset_graph_type = remove_dataset_graph_type
        self.remove_thesaurus_graph_type = remove_thesaurus_graph_type
        self.remove_shape_catalog_graph_type = remove_shape_catalog_graph_type
        self.max_validation_depth = max_validation_depth

    def add_prov(self, validation_graph: Graph, utctime: str) -> Graph:
        """Add provenance data"""
        self.log.info("Adding PROV information validation graph")
        validation_report_uri = validation_graph.value(
            predicate=RDF.type, object=SH.ValidationReport
        )
        if validation_report_uri:
            validation_graph.add(
                (validation_report_uri, PROV.wasDerivedFrom, URIRef(self.data_graph_uri))
            )
            validation_graph.add(
                (validation_report_uri, PROV.wasInformedBy, URIRef(self.shacl_graph_uri))
            )
            validation_graph.add(
                (
                    validation_report_uri,
                    PROV.generatedAtTime,
                    Literal(utctime, datatype=XSD.dateTime),
                )
            )

        return validation_graph

    def add_labels_val(
        self,
        validation_graph: Graph,
        data_graph: Graph,
        shacl_graph: Graph,
        validation_result_uris: list,
    ) -> tuple:
        """Add labels"""
        self.log.info("Adding labels to validation graph")
        focus_nodes = []
        validation_report_uri = validation_graph.value(
            predicate=RDF.type, object=SH.ValidationReport
        )
        conforms = validation_graph.value(subject=validation_report_uri, predicate=SH.conforms)
        label = f"SHACL validation report, conforms={conforms!s}"
        if validation_report_uri:
            validation_graph.add((validation_report_uri, RDFS.label, Literal(label)))
        for validation_result_uri in validation_result_uris:
            message = str(
                validation_graph.value(subject=validation_result_uri, predicate=SH.resultMessage)
            )
            result_path = validation_graph.value(
                subject=validation_result_uri, predicate=SH.resultPath
            )
            result_path_string = f"{result_path}: " if result_path else ""
            label = Literal(f"SHACL: {result_path_string}{message}")
            validation_graph.add((validation_result_uri, RDFS.label, label))
            if self.include_graphs_labels:
                focus_node = validation_graph.value(
                    subject=validation_result_uri, predicate=SH.focusNode
                )
                if self.add_shui_conforms:
                    focus_nodes.append(focus_node)
                label = get_label(data_graph, focus_node)
                if label and focus_node:
                    validation_graph.add((focus_node, RDFS.label, label))  # type: ignore[arg-type]
                value = validation_graph.value(subject=validation_result_uri, predicate=SH.value)
                if value and isinstance(value, URIRef | BNode):
                    label = get_label(data_graph, value)
                    if label:
                        validation_graph.add((value, RDFS.label, label))  # type: ignore[arg-type]
                source_shape = validation_graph.value(
                    subject=validation_result_uri, predicate=SH.sourceShape
                )
                label = get_label(shacl_graph, source_shape)
                if label:
                    validation_graph.add((source_shape, RDFS.label, label))  # type: ignore[arg-type]
        return validation_graph, focus_nodes

    def add_shui_conforms_val(
        self, validation_graph: Graph, validation_result_uris: list, focus_nodes: list
    ) -> Graph:
        """Add shui conforms flag"""
        self.log.info("Adding shui:conforms flags to validation graph")
        itr = focus_nodes if focus_nodes else validation_result_uris
        for i in itr:
            subj = i if focus_nodes else validation_graph.value(subject=i, predicate=SH.focusNode)
            validation_graph.add(
                (
                    subj,
                    URIRef("https://vocab.eccenca.com/shui/conforms"),
                    Literal(False, datatype=XSD.boolean),
                )
            )
        return validation_graph

    def post_graph(self, validation_graph: Graph) -> None:
        """Post validation graph to cmem"""
        self.log.info("Posting SHACL validation graph...")
        with NamedTemporaryFile(suffix=".nt") as temp:
            validation_graph.serialize(temp.name, format="nt", encoding="utf-8")
            res = post_streamed(
                self.validation_graph_uri,
                temp.name,
                replace=self.clear_validation_graph,
                content_type="application/n-triples",
            )
        if res.status_code != 204:  # noqa: PLR2004
            raise OSError(f"Error posting SHACL validation graph (status code {res.status_code}).")

    def check_object(  # noqa: C901 PLR0912
        self, graph: Graph, subj: Node, pred: URIRef, data_graph: Graph, shacl_graph: Graph
    ) -> str:
        """Format RDF objects for entities output"""
        if pred in (SH.sourceShape, SH.conforms):
            label_g = shacl_graph
        elif pred in (SH.value, SH.resultPath, SH.focusNode):
            label_g = data_graph
        val = graph.value(subject=subj, predicate=pred)
        obj = val if val else None
        res_val = ""
        if obj:
            if isinstance(obj, URIRef):
                if self.include_graphs_labels and pred not in (
                    SH.sourceConstraintComponent,
                    SH.resultSeverity,
                ):
                    label = get_label(label_g, obj)
                    res_val = str(label) if label else obj
                else:
                    res_val = obj
            elif isinstance(obj, BNode):
                if self.include_graphs_labels:
                    label = get_label(label_g, obj)
                    if label:
                        res_val = str(label)
                if not res_val:
                    # first 50 lines of turtle CBD
                    res_val = graph.cbd(obj).serialize(format="turtle")
                    cbd_lines = res_val.split("\n")
                    if len(cbd_lines) > 50:  # noqa: PLR2004
                        res_val = "\n".join(cbd_lines[:50]) + "\n..."
            elif isinstance(obj, Literal):
                if pred == SH.value:
                    res_val = f'"{obj}"^^<{obj.datatype}>' if obj.datatype else f'"{obj}"'
                elif pred == SH.resultMessage:
                    res_val = str(obj)
        return res_val

    def make_entities(
        self, validation_graph: Graph, data_graph: Graph, shacl_graph: Graph, utctime: str
    ) -> Entities:
        """Create entities"""
        self.log.info("Creating entities")
        shp = [
            "focusNode",
            "resultPath",
            "value",
            "sourceShape",
            "sourceConstraintComponent",
            # "detail",
            "resultMessage",
            "resultSeverity",
        ]
        entities = []
        conforms = next(iter(validation_graph.objects(predicate=SH.conforms)))
        for validation_result in list(validation_graph.subjects(RDF.type, SH.ValidationResult)):
            values = [
                [
                    self.check_object(
                        validation_graph,
                        validation_result,
                        SH[p],
                        data_graph,
                        shacl_graph,
                    )
                ]
                for p in shp
            ] + [[conforms], [self.data_graph_uri], [self.shacl_graph_uri], [utctime]]
            entities.append(Entity(uri=validation_result, values=values))
        paths = [EntityPath(path=SH[p]) for p in shp] + [
            EntityPath(path=SH.conforms),
            EntityPath(path=PROV.wasDerivedFrom),
            EntityPath(path=PROV.wasInformedBy),
            EntityPath(path=PROV.generatedAtTime),
        ]
        return Entities(
            entities=entities,
            schema=EntitySchema(type_uri=SH.ValidationResult, paths=paths),
        )

    def get_graph(self, uri: str) -> Graph:
        """Get graph from cmem"""
        graph = Graph()
        graph.parse(data=get(uri, owl_imports_resolution=self.owl_imports).text, format="turtle")
        return graph

    def check_parameters(  # noqa: C901 PLR0912
        self,
    ) -> None:
        """Validate plugin parameters"""
        self.log.info("Validating parameters...")
        if not self.output_entities and not self.generate_graph:
            raise ValueError(
                "Generate validation graph or Output values parameter needs to be set to true"
            )
        if not validators.url(self.data_graph_uri):
            raise ValueError("Data graph URI parameter is invalid")
        if not validators.url(self.shacl_graph_uri):
            raise ValueError("SHACL graph URI parameter is invalid")
        graphs_dict = {graph["iri"]: graph["assignedClasses"] for graph in get_graphs_list()}

        if self.ontology_graph_uri:
            if not validators.url(self.ontology_graph_uri):
                raise ValueError("Ontology graph URI parameter is invalid")
            if self.ontology_graph_uri not in graphs_dict:
                raise ValueError(f"Ontology graph <{self.ontology_graph_uri}> not found")
            if "http://www.w3.org/2002/07/owl#Ontology" not in graphs_dict[self.ontology_graph_uri]:
                raise ValueError(
                    f"Invalid graph type for Ontology graph <{self.ontology_graph_uri}>"
                )

        if self.data_graph_uri not in graphs_dict:
            raise ValueError(f"Data graph <{self.data_graph_uri}> not found")
        if self.shacl_graph_uri not in graphs_dict:
            raise ValueError(f"SHACL graph <{self.shacl_graph_uri}> not found")
        if not any(check in graphs_dict[self.data_graph_uri] for check in DATA_GRAPH_TYPES):
            raise ValueError(f"Invalid graph type for data graph <{self.data_graph_uri}>")
        if "https://vocab.eccenca.com/shui/ShapeCatalog" not in graphs_dict[self.shacl_graph_uri]:
            raise ValueError(f"Invalid graph type for SHACL graph <{self.shacl_graph_uri}>")
        if self.generate_graph:
            if not validators.url(self.validation_graph_uri):
                raise ValueError("Validation graph URI parameter is invalid")
            if self.validation_graph_uri in graphs_dict:
                self.log.warning(f"Graph <{self.validation_graph_uri}> already exists")
        if not self.add_labels:
            self.include_graphs_labels = False

        if self.inference not in ("none", "rdfs", "owlrl", "both"):
            raise ValueError("Invalid value for inference parameter")

        if self.max_validation_depth not in range(1, 1000):
            raise ValueError("Invalid value for maximum evaluation depth")

    def remove_graph_type(self, data_graph: Graph, iri: str) -> None:
        """Remove triple <data_graph_uri> a <iri>"""
        self.log.info(f"Removing graph type <{iri}> from data graph")
        data_graph.remove((URIRef(self.data_graph_uri), RDF.type, URIRef(iri)))

    def execute(  # noqa: C901
        self,
        inputs: tuple,  # noqa: ARG002
        context: ExecutionContext = ExecutionContext,
    ) -> Entities | None:
        """Execute plugin"""
        setup_cmempy_user_access(context.user)
        self.check_parameters()
        self.log.info(f"Loading data graph <{self.data_graph_uri}> into memory...")
        start = time()
        data_graph = self.get_graph(self.data_graph_uri)
        self.log.info(f"Finished loading data graph in {e_t(start)} seconds")

        if self.remove_dataset_graph_type:
            self.remove_graph_type(data_graph, "http://rdfs.org/ns/void#Dataset")
        if self.remove_thesaurus_graph_type:
            self.remove_graph_type(data_graph, "https://vocab.eccenca.com/dsm/ThesaurusProject")
        if self.remove_shape_catalog_graph_type:
            self.remove_graph_type(data_graph, "https://vocab.eccenca.com/shui/ShapeCatalog")

        self.log.info(f"Loading SHACL graph <{self.shacl_graph_uri}> into memory...")
        start = time()
        shacl_graph = self.get_graph(self.shacl_graph_uri)
        self.log.info(f"Finished loading SHACL graph in {e_t(start)} seconds")

        if self.ontology_graph_uri:
            self.log.info(f"Loading ontology graph <{self.ontology_graph_uri}> into memory...")
            ontology_graph = self.get_graph(self.ontology_graph_uri)
            self.log.info(f"Finished loading ontology graph in {e_t(start)} seconds")
        else:
            ontology_graph = None

        self.log.info("Starting SHACL validation...")
        start = time()
        _conforms, validation_graph, _results_text = validate(
            data_graph=data_graph,
            shacl_graph=shacl_graph,
            ont_graph=ontology_graph,
            meta_shacl=self.meta_shacl,
            inference=self.inference,
            advanced=self.advanced,
            js=self.js,
            max_validation_depth=self.max_validation_depth,
            inplace=True,
        )
        self.log.info(f"Finished SHACL validation in {e_t(start)} seconds")
        utctime = str(datetime.fromtimestamp(int(time()), tz=UTC))[:-6].replace(" ", "T") + "Z"
        if self.output_entities:
            entities = self.make_entities(validation_graph, data_graph, shacl_graph, utctime)
        if self.generate_graph:
            if self.skolemize:
                self.log.info("Skolemizing validation graph")
                validation_graph = validation_graph.skolemize(basepath=self.validation_graph_uri)
            if self.add_labels or self.add_shui_conforms:
                validation_graph_uris = validation_graph.subjects(RDF.type, SH.ValidationResult)
                focus_nodes = []
                if self.add_labels:
                    validation_graph, focus_nodes = self.add_labels_val(
                        validation_graph, data_graph, shacl_graph, validation_graph_uris
                    )
                if self.add_shui_conforms:
                    validation_graph = self.add_shui_conforms_val(
                        validation_graph, validation_graph_uris, focus_nodes
                    )
            validation_graph = self.add_prov(validation_graph, utctime)

            self.post_graph(validation_graph)

        if self.output_entities:
            self.log.info("Outputting entities")
            return entities
        return None
