import errno
import importlib
import logging
from typing import List, cast

from addict import Dict, addict

from kodexa import Assistant, AssistantResponse, LocalDocumentStore
from kodexa import ContentNode, Document, PipelineContext, TableDataStore
from kodexa.model.document_families import ContentEvent, DocumentActor, DocumentTransition
from kodexa.model.model import DocumentStore

logger = logging.getLogger('kodexa.testing')


def print_data_table(context: PipelineContext, store_name: str):
    """A small helper to support working with a store in a test

    Args:
      context: param store_name:
      context: PipelineContext: 
      store_name: str: 

    Returns:

    """
    if store_name in context.get_store_names():
        print(f"\n{store_name}\n")
        data_table = cast(TableDataStore, context.get_store(store_name))
        from texttable import Texttable
        table = Texttable(max_width=1000).header(data_table.columns)
        table.add_rows(data_table.rows, header=False)
        print(table.draw() + "\n")
    else:
        print(f"\n{store_name} - MISSING\n")


def snapshot_store(context: PipelineContext, store_name: str, filename: str):
    """Capture the data in a store to a JSON file so that we can use it later
    to compare the data (usually in a test)

    Args:
      context: the pipeline context
      store_name: the name of the store
      filename: the name of the file to snapshot the store to
      context: PipelineContext: 
      store_name: str: 
      filename: str: 

    Returns:

    """
    import json
    logger.warning('Snapshotting store')
    with open(filename, 'w') as f:
        json.dump(context.get_store(store_name).to_dict(), f)


def simplify_node(node: ContentNode):
    """

    Args:
      node: ContentNode: 

    Returns:

    """
    return {
        "index": node.index,
        "node_type": node.node_type,
        "features": [feature.to_dict() for feature in node.get_features()],
        "content": node.content,
        "content_parts": node.content_parts,
        "children": [simplify_node(child_node) for child_node in node.children]
    }


def simplify_document(document: Document) -> Dict:
    """

    Args:
      document: Document: 

    Returns:

    """
    return {
        "content_node": simplify_node(document.get_root())
    }


def compare_document(document: Document, filename: str, throw_exception=True):
    """

    Args:
      document: Document: 
      filename: str: 
      throw_exception:  (Default value = True)

    Returns:

    """
    from os import path
    import json
    import os

    try:
        os.makedirs('test_snapshots')
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise

    filename = "test_snapshots/" + filename

    if not path.exists(filename):
        with open(filename, 'w') as f:
            simplified_document = simplify_document(document)
            json.dump(simplified_document, f)

        logger.warning("WARNING!!! Creating snapshot file")
        raise Exception("Creating snapshot, invalid test")

    with open(filename) as f:
        snapshot_document = json.load(f)

    target_document = json.loads(json.dumps(simplify_document(document)))

    from deepdiff import DeepDiff
    diff = DeepDiff(snapshot_document, target_document, ignore_order=False)

    if bool(diff) and throw_exception:
        print(diff)
        raise Exception('Document does not match')

    return diff


def compare_store(context: PipelineContext, store_name: str, filename: str, throw_exception=True):
    """Compare a store in the provided pipeline context to the store that has been snapshot

    Args:
      context: the pipeline context containing the store to compare
      store_name: the name of the store
      filename: the filename of the
      throw_exception: throw an exception if there is a mismatch (Default value = True)
      context: PipelineContext: 
      store_name: str: 
      filename: str: 

    Returns:

    """

    from os import path

    import os
    try:
        os.makedirs('test_snapshots')
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise

    filename = "test_snapshots/" + filename

    if not path.exists(filename):
        snapshot_store(context, store_name, filename)
        logger.warning("WARNING!!! Creating snapshot file")
        raise Exception("Creating snapshot, invalid test")

    import json

    # A list of the descriptions of issues
    issues = []

    target_table_store: TableDataStore = cast(TableDataStore, context.get_store(store_name))

    if target_table_store is None:
        print(f"Store {store_name} doesn't exist in the pipeline context")
        return False

    with open(filename) as f:
        snapshot_table_store = TableDataStore.from_dict(json.load(f))

    row_match = len(target_table_store.rows) == len(snapshot_table_store.rows)

    if not row_match:
        issues.append(
            f"Number of rows don't match {len(target_table_store.rows)} is target vs {len(snapshot_table_store.rows)} in snapshot")
    else:
        for row_idx, row in enumerate(target_table_store.rows):
            for cell_idx, cell in enumerate(row):
                if snapshot_table_store.rows[row_idx][cell_idx] != target_table_store.rows[row_idx][cell_idx]:
                    issues.append(
                        f"Row {row_idx} cell {cell_idx} doesn't match - should be [{snapshot_table_store.rows[row_idx][cell_idx]}] but is [{target_table_store.rows[row_idx][cell_idx]}]")

    col_match = len(target_table_store.columns) == len(snapshot_table_store.columns)

    if not col_match:
        issues.append(
            f"Number of columns don't match {len(target_table_store.columns)} is target vs {len(snapshot_table_store.columns)} in snapshot")
    else:
        for col_idx, col in enumerate(target_table_store.columns):
            if snapshot_table_store.columns[col_idx] != col:
                issues.append(
                    f"Column name at index {col_idx} doesn't match - should be [{snapshot_table_store.columns[col_idx]}] but is [{col}]")

    if len(issues) > 0 and throw_exception:
        raise Exception("\n".join(issues))

    for issue in issues:
        print(issue)

    return len(issues) == 0


class AssistantTestHarness:
    """
    A test harness to allow the testing of assistants in unit tests and offline for development

    >>> util = ExtensionPackUtil("../kodexa.yml")
    >>> harness = util.get_assistant("my-assistant", stores=[my_local_store])

    """

    def __init__(self, assistant: Assistant, stores: List[DocumentStore]):
        """
        Initialize the test harness

        Args:
            assistant: the instance of the assistant
            stores: the list of stores (usually LocalDocumentStore) that we will use to monitor for events
        """
        self.assistant = assistant
        self.stores = stores

        for store in self.stores:
            store.register_listener(self)

    def process_event(self, event: ContentEvent):
        """The harnesss will take the content event and
        will pass it to the assistant - then we will
        take each of the pipelines and run the document
        through them in turn (note in the platform this might be in parallel)

        Args:
          event: content event
          event: ContentEvent: 

        Returns:
          None

        """

        response: AssistantResponse = self.assistant.process_event(event)

        # We need to get the document down
        store = self.get_store(event)

        for pipeline in response.pipelines:
            pipeline.connector = [store.get_document_by_content_object(event.content_object)]
            pipeline_context = pipeline.run()

            if pipeline_context.output_document is not None:
                # We need to build the transition between the old and the new
                document_relationship = DocumentTransition("DERIVED", event.content_object.id, None,
                                                           DocumentActor("testing", "assistant"))

                store.add_related_document_to_family(event.document_family.id, document_relationship,
                                                     pipeline_context.output_document)

            # We need to handle the context here, basically we are going to take the result
            # of the pipeline as a new document and add it to the family?? think about that?

            # so does the assistant decide to write to the store? or do we just do it anyway?
            # how does the assistant know what happened to the pipeline?

        pass

    def register_local_document_store(self, store: LocalDocumentStore):
        """
        Register a local document store with this harness

        Args:
          store: LocalDocumentStore: 

        Returns:

        """
        pass

    def get_store(self, event: ContentEvent) -> DocumentStore:
        """
        Get a document store for the event (based on the document family ID)

        Args:
          event: ContentEvent: 

        Returns:
          The instance of the document store
        """
        for store in self.stores:
            if event.document_family.store_ref == store.get_ref():
                return store

        raise Exception(f"Unable to get store ref {event.document_family.store_ref}")


class OptionException(Exception):
    """
    An exception that is raised when there is a problem with a requests option
    """
    pass


class ExtensionPackUtil:
    """
    A utility that can be used to access an action defined in a kodexa.yml.

    This allows you to use the kodexa.yml in unit tests to ensure it matches your current action code

    >>> util = ExtensionPackUtil("../kodexa.yml")
    >>> pipeline = Pipeline()
    >>> pipeline.add_step(util.get_step("my-action",{"my-option": "cheese"}))
    >>> pipeline.run()

    """

    def __init__(self, file_path='kodexa.yml'):
        self.file_path = file_path

        import yaml

        with open(file_path, 'r') as stream:
            self.kodexa_metadata = addict.Dict(yaml.safe_load(stream))

    def get_step(self, action_slug, options=None):
        """

        Args:
          action_slug: 
          options:  (Default value = None)

        Returns:

        """
        if options is None:
            options = {}

        for service in self.kodexa_metadata.services:
            if service.type == 'action' and service.slug == action_slug:
                # TODO We need to validate all the options

                if len(service.metadata.options) > 0:
                    option_names = []
                    for option in service.metadata.options:
                        option_names.append(option.name)
                        if option.name not in options and option.default is not None:
                            options[option.name] = option.default
                        if option.required and option.name not in options:
                            raise OptionException(f"Missing required option {option.name}")

                    for option_name in options.keys():
                        if option_name not in option_names:
                            raise OptionException(f"Unexpected option {option_name}")

                # We need to create and return our action
                module = importlib.import_module(service.step.package)
                klass = getattr(module, service.step['class'])
                return klass(**options)

        raise Exception("Unable to find the action " + action_slug)

    def get_assistant_test_harness(self, assistant_slug, options=None, stores=None) -> AssistantTestHarness:
        """Provides a local test harness that can be used to validate the functionality
        of an assistant in a test case

        Args:
          assistant_slug: param options:
          stores: a list of the document stores to monitor (Default value = None)
          options:  (Default value = None)

        Returns:
          The assistant test harness

        """
        if stores is None:
            stores = []
        assistant = self.get_assistant(assistant_slug, options)

        return AssistantTestHarness(assistant, stores)

    def get_assistant(self, assistant_slug, options=None):
        """Create an instance of an assistant from the kodexa metadata

        Args:
          assistant_slug: param options:
          options:  (Default value = None)

        Returns:

        """
        if options is None:
            options = {}

        for service in self.kodexa_metadata.services:
            if service.type == 'assistant' and service.slug == assistant_slug:
                # TODO We need to validate all the options

                # We need to create and return our action

                logger.info(f"Creating new assistant {service.assistant}")
                module = importlib.import_module(service.assistant.package)
                klass = getattr(module, service.assistant['class'])
                return klass(**options)

        raise Exception("Unable to find the assistant " + assistant_slug)
