import json
from pathlib import Path
from io import IOBase, TextIOWrapper
from types import GeneratorType
from csv import DictReader, DictWriter
from operator import methodcaller

class Detakon():
    """detakon uses a detakon map to convert data."""
    def __init__(self, detamap, source, destination, *args, **kargs):
        """
        Initialize all values for Detakon object.
        
        :param self: Object reference.
        :param detamap: detamap location that describes field mappings, default values, and operations to perform on data.
        :param source: Source to input.  See load_source method for accepted types.
        :param destination: Destination to output.
        :param args: Additional parameters.
        :param kargs: Addtional flags.
        """
        self.original_detamap = detamap
        self.detamap: dict = self.load_detamap(detamap)
        self.mappings: dict = self.detamap["Mappings"]
        self.defaults: dict = self.detamap.get("Defaults", dict())
        self.operations: dict = self.detamap.get("Operations", dict())
        self.source_info: dict = self.detamap["Source"]
        self.output_info: dict = self.detamap["Output"]
        self.source = source
        self.destination = destination

    def reload_detamap(self, detamap):
        """
        For interactive sessions to change the detamap. If original detamap was an external source (such as a file), self.original_detamap may be passed to reload with changes."""
        self.detamap: dict = self.load_detamap(detamap)
        self.mappings: dict = self.detamap["Mappings"]
        self.defaults: dict = self.detamap.get("Defaults", dict())
        self.operations: dict = self.detamap.get("Operations", dict())
        self.source_info: dict = self.detamap["Source"]
        self.output_info: dict = self.detamap["Output"]

    def convert(self):
        """
        Convert current source data using current detamap and destination.
        """
        data_generator = self.source_reader()

        # branch to determine output method called based on detamap.Output.argument for destination parameter
        # filepath as either string or Path object 
        if self.output_info["argument"] == "filepath":
            if self.output_info["type"] == "str":
                self.destination = Path(self.destination)

            # if not appending to existing file, delete if file exists and create new empty file
            if not self.output_info.get("append", False) and self.destination.exists() and self.destination.is_file():
                self.destination.unlink()

            if self.destination.exists() and self.destination.is_file():
                new_file = False
            else:
                new_file = True
            self.destination.touch()

            with self.destination.open(mode="a",
                        buffering=self.output_info.get("buffering", -1),
                        encoding=self.output_info.get("encoding", "utf-8"),
                        errors=self.output_info.get("errors", None),
                        newline=self.output_info.get("newline", None)) as file:
                csv_writer = DictWriter(file,
                            fieldnames=self.output_info.get("fields"),
                            restval=self.output_info.get("restval", ""),
                            extrasaction=self.output_info.get("extrasaction", "raise"),
                            dialect=self.output_info.get("dialect", "excel"),
                            delimiter=self.output_info.get("delimiter", ","),
                            quotechar=self.output_info.get("quotechar", '"'),
                            escapechar=self.output_info.get("escapechar", None),
                            doublequote=self.output_info.get("doublequote", True),
                            skipinitialspace=self.output_info.get("skipinitialspace", False),
                            lineterminator=self.output_info.get("lineterminator", "\r\n"),
                            quoting=self.output_info.get("quoting", 0),
                            strict=self.output_info.get("strict", False))
                
                if new_file and not self.output_info.get("omit_heading", False):
                    csv_writer.writeheader()
                for entry in data_generator:
                    row_data = {}
                    for source_field, destination_field in self.mappings.items():
                        row_data[destination_field] = entry[source_field]
                    csv_writer.writerow(row_data)
        elif self.output_info["argument"] == "return":
            pass

    def source_reader(self):
        """
        Validate source type, and return a generator object if possible, otherwise return full object in accepted format.

        Intent to add remote file, or result of API calls - giving consideration to add ability, or require calling application to submit data directly.
        
        :param self: Object reference.
        :param source: Source data.  File as string, Path object, or an opened file object.  Also can accept string object of CSV (comma or tab delimited), or JSON.
        :return: File path as string.
        :rtype: str
        """
        # branch based on source.argument value provided in detamap

        # handler for source.argument being a filepath
        # filepath must be either a string to a file, or a Path object for a file.
        if self.source_info["argument"] == "filepath":
            if self.source_info["type"] == "str":
                self.source = Path(self.source)
            if self.source.exists() and self.source.is_file():
                with self.source.open(mode='r',
                                        buffering=self.source_info.get("buffering", -1),
                                        encoding=self.source_info.get("encoding", "utf-8"),
                                        errors=self.source_info.get("errors", None),
                                        newline=self.source_info.get("newline", None)) as source_file:
                    # handler for source.format csv values.  if no source.format value is provided, csv value is default
                    if self.source_info.get("format", "csv") == "csv":
                        # DictReader defaults are used if Source does not contain a key for a given keyword.
                        # key:value paris can be provided in the detamap Source section to set the value of any DictReader keyword.
                        # values must conform with DictReader's expectation.
                        csv_reader = DictReader(source_file,
                                                fieldnames=self.source_info.get("fieldnames", None),
                                                restkey=self.source_info.get("restkey", None),
                                                restval=self.source_info.get("restval", None),
                                                dialect=self.source_info.get("dialect", "excel"),
                                                delimiter=self.source_info.get("separator", ","),
                                                quotechar=self.source_info.get("quotechar", '"'),
                                                escapechar=self.source_info.get("escapechar", None),
                                                doublequote=self.source_info.get("doublequote", True),
                                                skipinitialspace=self.source_info.get("skipinitialspace", False),
                                                lineterminator=self.source_info.get("lineterminator", "\r\n"),
                                                quoting=self.source_info.get("quoting", 0),
                                                strict=self.source_info.get("strict", False))
                        for row in csv_reader:
                            # insert default values if key missing or value is empty string
                            row = self.process_defaults(row)
                            # process operations
                            row = self.process_operations(row)
                            yield row

        # elif isinstance(source, TextIOWrapper):
        #     pass
        # elif isinstance(source, str):
        #     if Path(source).exists() and Path(source).is_file():
        #         pass
        #     elif isinstance(source, dict):
        #         pass
        # elif isinstance(source, GeneratorType):
        #     pass
        # else:
        #     raise ValueError("Source could not be determined.")

    def process_defaults(self, row):
        """Add default values to any missing column or empty string."""
        for key, value in self.defaults.items():
            if key not in row:
                row[key] = value
            elif row[key] == "" or row[key] == None:
                row[key] = value
        return row
    
    def process_operations(self, row):
        """Process all operations from self.operations in order that operations appear in list."""
        for entry in self.operations:
            for operator, info in entry.items():
                operation = operator
                fields = info.get("fields")
                if fields == "*":
                    fields = row.keys()
                elif isinstance(fields, str):
                    fields = [fields]
                arguments = tuple(info.get("arguments", []))

            for field in fields:
                # process string operations
                if operation in dir(str):
                    row[field] = getattr(row[field], operation)(*arguments)
                elif operation == "slice":
                    slice_object = slice(*(x if isinstance(x, int) else None for x in arguments))
                    row[field] = row[field][slice_object]
                elif operation == "hashmap":
                    # print(f"before {operation}: {row[field]}")
                    hashmap = arguments[0]
                    if row[field] in hashmap.keys():
                        row[field] = hashmap[row[field]]
                    # print(f"after: {row[field]}")
                # below operations are place holders, and may change operation names during implementation
                elif operation == "mergeFields":
                    pass
                elif operation == "splitFields":
                    pass
                elif operation == "formatTime":
                    pass
                elif operation == "filter":
                    pass

        # print("---------Start Data Output ------------")
        # for key, value in row.items():
        #     print(f"{key}: {value} - Type: {type(value)}")
        # print("---------End Data Output ------------")
        return row

    def load_detamap(self, detamap) -> dict:
        """
        Process object passed as detamap and return dictionary detamap.
        
        :param self: Object reference.
        :param detamap: Either a dictionary, JSON stream/string, or file path (string or pathlib.Path) to JSON file.
        :return: Dictionary of detamap
        :rtype: dict
        """
        # accepts python dictionary or JSON data.  Future plans to add TOML detamap.
        if isinstance(detamap, dict):
            return detamap
        elif isinstance(detamap, str) and detamap[0] == '{':
            try:
                return json.loads(detamap)
            except Exception as e:
                raise Exception(f"Failed to load JSON string: {e}")
        else:
            try:
                if isinstance(detamap, Path):
                    with detamap.open("r") as file:
                        return json.load(file)
                else:
                    with open(detamap, "r") as file:
                        return json.load(file)
            except Exception as e:
                raise Exception(f"Failed to load JSON file: {e}")
        