import re
from collections import defaultdict
from collections.abc import Container, Iterable
from functools import partial, reduce
from itertools import chain, groupby
from pathlib import Path

import tldextract
from rdflib import OWL, RDF, RDFS, SKOS, Graph, Literal, Namespace, URIRef
from rdflib.namespace import split_uri
from thefuzz import fuzz, process

from cemento.term_matching.constants import (
    FALLBACK_STRAT_TYPES,
    RANK_PROPS,
    get_default_namespace_prefixes,
)
from cemento.term_matching.io import (
    get_search_terms_from_defaults,
    get_search_terms_from_graph,
    get_ttl_file_iter,
    read_prefixes_from_graph,
    read_prefixes_from_json,
)
from cemento.term_matching.preprocessing import merge_dictionaries
from cemento.utils.utils import get_abbrev_term, remove_term_names


def search_similar_terms_multikey(
    search_keys: Iterable[str], search_terms: Iterable[str], score_cutoff: int = 80
) -> list[tuple[URIRef, int]]:
    return [
        result
        for search_key in search_keys
        if (
            result := process.extractOne(
                search_key,
                search_terms,
                scorer=fuzz.token_sort_ratio,
                score_cutoff=score_cutoff,
            )
        )
        is not None
    ]


def get_term_matches(
    term: str, search_pool: Container[str], score_cutoff: int = None
) -> tuple[str, int]:
    return process.extractOne(
        term.lower(),
        [search_term.lower() for search_term in search_pool],
        score_cutoff=score_cutoff,
    )


def substitute_term(
    term: str, search_terms: set[str], score_cutoff=90
) -> tuple[str, bool]:
    matched_rank_term, score = (
        match_res if (match_res := get_term_matches(term, search_terms)) else (None, 0)
    )
    return (
        matched_rank_term if (meets_cutoff := score > score_cutoff) else term,
        meets_cutoff,
    )


def substitute_term_multikey(
    search_keys: Iterable[str],
    search_terms: dict[str, URIRef],
    score_cutoff: int = 80,
    log_results: bool = False,
    suppression_key: str = "*",
) -> URIRef | tuple[URIRef, list[tuple[URIRef, int]]]:

    if any([suppression_key in key for key in search_keys]):
        if log_results:
            return (None, [None], [(None, None)])
        return None

    search_keys = search_keys if not log_results else list(search_keys)
    search_results = search_similar_terms_multikey(
        search_keys, search_terms.keys(), score_cutoff=score_cutoff
    )
    best_match, _ = max(
        search_results,
        key=lambda x: x[1] if x is not None else -1,
        default=(None, -1),
    )
    term_substitute = search_terms[best_match] if best_match else None
    if log_results:
        return (term_substitute, search_keys, search_results)
    return term_substitute


def get_term_search_keys(term: str, inv_prefix: dict[URIRef, str]) -> list[str]:
    prefix, abbrev_term = get_abbrev_term(term)
    undo_camel_case_term = " ".join(
        re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[0-9]+", abbrev_term)
    )
    search_keys = [
        remove_term_names(term),
        f"{prefix}:{abbrev_term}",
        f"{prefix}:{undo_camel_case_term}",
    ]
    return [key.strip() for key in search_keys]


def get_term_search_result(
    term: URIRef,
    inv_prefixes: dict[URIRef | Namespace, str],
    search_terms: dict[str, URIRef],
) -> URIRef:
    ns, abbrev_term = split_uri(term)
    prefix = inv_prefixes[str(ns)]
    search_term = f"{prefix}:{abbrev_term}"
    if search_term in search_terms:
        return search_terms[search_term]
    return None


def add_exact_matches(
    term: URIRef, match_properties: dict[URIRef, URIRef | None], rdf_graph: Graph
) -> Graph:
    # if the term is already imported from somewhere else
    # get the type and label if available and add to the ttl file
    for match_property, value in match_properties.items():
        if value:
            rdf_graph.add((term, match_property, value))

    # add an exact match to the ttl file for easier cross-referencing
    rdf_graph.add((term, SKOS.exactMatch, term))
    return rdf_graph


def get_aliases(rdf_graph: Graph) -> dict[URIRef, Literal]:
    label_tuples = list(
        chain(
            rdf_graph.subject_objects(RDFS.label),
            rdf_graph.subject_objects(SKOS.altLabel),
        )
    )
    sorted(label_tuples, key=lambda x: x[0])
    return {
        subj: [obj for _, obj in objs]
        for subj, objs in groupby(label_tuples, key=lambda x: x[0])
    }


def get_term_types(rdf_graph: Graph) -> dict[URIRef, URIRef]:
    return {subj: obj for subj, pred, obj in rdf_graph if pred == RDF.type}


def combine_graphs(graphs: Iterable[Graph]) -> Graph:
    return reduce(lambda acc, graph: acc + graph, graphs, Graph())


def generate_residual_prefixes(
    rdf_graph: Graph, inv_prefixes: dict[Namespace | URIRef, str]
):
    new_prefixes = defaultdict(list)
    new_prefix_namespaces = set()
    for subj, pred, obj in rdf_graph:
        for term in [subj, pred, obj]:
            if isinstance(term, URIRef):
                try:
                    ns, abbrev = split_uri(term)
                except ValueError:
                    ns = term
                if ns not in inv_prefixes:
                    new_prefix_namespaces.add(str(ns))
    gns_idx = 0
    for ns in new_prefix_namespaces:
        url_extraction = tldextract.extract(ns)
        new_prefix = res[-1] if (res := re.findall(r"\w+", ns)) else ""
        if url_extraction.suffix and new_prefix in url_extraction.suffix.split("."):
            new_prefix = url_extraction.domain
        new_prefix = re.sub(r"[^a-zA-Z0-9]", "", new_prefix)
        if not new_prefix or new_prefix.isdigit():
            new_prefix = f"gns{gns_idx}"
            gns_idx += 1
        new_prefixes[new_prefix].append(ns)

    return_prefixes = dict()
    for prefix, namespaces in new_prefixes.items():
        if len(namespaces) > 1:
            for idx, ns in enumerate(namespaces):
                return_prefixes[f"{prefix}{idx+1}"] = ns
        else:
            return_prefixes[prefix] = namespaces[0]

    return return_prefixes


def get_prefixes(
    prefixes_path: str | Path, onto_ref_folder: str | Path
) -> tuple[dict[str, URIRef | Namespace], dict[URIRef | Namespace, str]]:
    prefixes = dict()
    if prefixes_path:
        prefixes = read_prefixes_from_json(prefixes_path)

    default_namespace_prefixes = get_default_namespace_prefixes()
    prefixes.update(default_namespace_prefixes)

    if onto_ref_folder:
        file_prefixes = map(
            read_prefixes_from_graph, get_ttl_file_iter(onto_ref_folder)
        )
        prefixes |= merge_dictionaries(file_prefixes)
        inv_prefixes = {value: key for key, value in prefixes.items()}

        residual_file_prefixes = map(
            partial(generate_residual_prefixes, inv_prefixes=inv_prefixes),
            get_ttl_file_iter(onto_ref_folder),
        )
        residual_file_prefixes = {
            key: value
            for residual_prefixes in residual_file_prefixes
            for key, value in residual_prefixes.items()
        }
        prefixes.update(residual_file_prefixes)
        inv_prefixes = {value: key for key, value in prefixes.items()}

    return prefixes, inv_prefixes


def get_default_terms(defaults_folder: str | Path = None):
    default_namespace_prefixes = get_default_namespace_prefixes()
    default_terms_from_lib = {
        term
        for ns in default_namespace_prefixes.values()
        for term in dir(ns)
        if isinstance(term, URIRef)
    }
    if defaults_folder:
        default_terms_from_file = reduce(
            lambda acc, rdf_graph: acc | set(rdf_graph.all_nodes()),
            get_ttl_file_iter(defaults_folder),
            set(),
        )
        default_terms_from_lib |= default_terms_from_file
    default_terms_from_lib = set(
        filter(lambda x: isinstance(x, URIRef), default_terms_from_lib)
    )
    return default_terms_from_lib


def get_search_terms(
    inv_prefixes: dict[URIRef, str],
    onto_ref_folder: str | Path = None,
    defaults_folder: str | Path = None,
):
    search_terms = get_search_terms_from_defaults(get_default_namespace_prefixes())

    if defaults_folder:
        defaults_file_search_terms = map(
            partial(get_search_terms_from_graph, inv_prefixes=inv_prefixes),
            get_ttl_file_iter(defaults_folder),
        )
        search_terms |= merge_dictionaries(defaults_file_search_terms)

    if onto_ref_folder:
        file_search_terms = map(
            partial(get_search_terms_from_graph, inv_prefixes=inv_prefixes),
            get_ttl_file_iter(onto_ref_folder),
        )
        search_terms |= merge_dictionaries(file_search_terms)

    return search_terms


def get_prop_family(rdf_graph: Graph, prop: URIRef) -> set[URIRef]:
    props_from_type = rdf_graph.transitive_subjects(RDF.type, prop)
    props_from_subclass = rdf_graph.transitive_subjects(RDFS.subClassOf, prop)
    return set(chain(props_from_type, props_from_subclass, [prop]))


def get_abbrev_uri(
    default_term: URIRef, inv_prefixes: dict[URIRef | Namespace, str]
) -> str:
    ns, abbrev_term = split_uri(default_term)
    prefix = inv_prefixes[str(ns)]
    return f"{prefix}:{abbrev_term.strip()}"


def get_abbrev_uri_with_prefix(term: URIRef, prefix: str) -> str:
    ns, abbrev_term = split_uri(term)
    return f"{prefix}:{abbrev_term.strip()}"


def get_prop_family_from_defaults(
    prop: URIRef,
    defaults_folder: str | Path,
    inv_prefixes: dict[URIRef | Namespace, str],
) -> Iterable[str]:
    prop_family = map(
        partial(get_prop_family, prop=prop),
        get_ttl_file_iter(defaults_folder),
    )
    prop_family = (prop for props in prop_family for prop in props)
    return prop_family


def get_rank_props() -> Iterable[URIRef]:
    # TODO: add subclass terms to constants
    return RANK_PROPS


def get_strat_props(
    defaults_folder: str | Path,
    inv_prefixes: dict[URIRef | Namespace, str],
    include_non_rank_props: bool = True,
) -> set[str]:
    non_rank_strat_prop_parents = {
        OWL.AnnotationProperty,
        OWL.DatatypeProperty,
    }
    strat_props = get_rank_props()
    if include_non_rank_props:
        try:
            non_rank_strat_props = map(
                partial(
                    get_prop_family_from_defaults,
                    inv_prefixes=inv_prefixes,
                    defaults_folder=defaults_folder,
                ),
                non_rank_strat_prop_parents,
            )
        except Exception:
            non_rank_strat_props = FALLBACK_STRAT_TYPES
        strat_props = chain(strat_props, *non_rank_strat_props)
    return set(strat_props)


def get_abbrev_prefixed_literal(
    term: URIRef, literal: Literal, inv_prefixes: dict[URIRef | Namespace, str]
) -> str:
    ns, _ = split_uri(term)
    prefix = inv_prefixes[str(ns)]
    return f"{prefix}:{literal.lower().strip()}"


def get_preds_in_ref(rdf_graph: Graph, type_refs: set[URIRef]) -> Iterable[URIRef]:
    return (
        subj for subj, obj in rdf_graph.subject_objects(RDF.type) if obj in type_refs
    )


def get_term_aliases_from_graph(rdf_graph: Graph, term: URIRef) -> list[URIRef]:
    return (
        term,
        chain(
            rdf_graph.objects(term, RDFS.label),
            rdf_graph.objects(term, SKOS.altLabel),
        ),
    )


# TODO: make these functions more pure by taking rdf_graphs and type_refs
def get_strat_predicates(
    onto_ref_folder: str | Path,
    defaults_folder: str | Path,
    inv_prefixes: dict[URIRef | Namespace, str],
) -> list[URIRef]:
    type_refs = get_strat_props(defaults_folder, inv_prefixes)
    return list(
        chain(
            *map(
                partial(get_preds_in_ref, type_refs=type_refs),
                get_ttl_file_iter(onto_ref_folder),
            )
        )
    )


def get_strat_predicates_str(
    onto_ref_folder: str | Path,
    defaults_folder: str | Path,
    inv_prefixes: dict[URIRef | Namespace, str],
) -> set[str]:
    strat_preds = get_strat_predicates(onto_ref_folder, defaults_folder, inv_prefixes)
    stat_pred_aliases = (
        get_term_aliases_from_graph(graph, pred)
        for graph in get_ttl_file_iter(onto_ref_folder)
        for pred in strat_preds
    )
    stat_preds_str = map(
        partial(get_abbrev_uri, inv_prefixes=inv_prefixes), strat_preds
    )
    aliased_stat_preds_str = (
        get_abbrev_prefixed_literal(term, alias, inv_prefixes)
        for term, aliases in stat_pred_aliases
        for alias in aliases
    )
    rank_props_str = map(
        partial(get_abbrev_uri, inv_prefixes=inv_prefixes), get_rank_props()
    )
    return set(chain(stat_preds_str, aliased_stat_preds_str, rank_props_str))
