import sys
import importlib
import os
import json
from datetime import datetime
from pathlib import Path
from shutil import copyfile
# from typing import Literal
import petl
# from oaaclient.templates import HRISProvider  # , IdPProviderType
from oaaclient.utils import encode_icon_file
from oaaclient.templates import OAAPropertyType
from oaaclient.client import OAAClientError, OAAResponseError
from oaa import utils
from oaa import oaa_utils
# from oaa.settings_utils import RunnerType
from oaa.settings_config import DestinationType
from oaa.settings import config  # , DBConfig  # , load_config
from oaa.utils import jinja_env
from oaa.settings_config import App

from oaa.models import FieldMapping
import logging
# Make the help output directly to the screen rather than getting stuck in a
# pager. See https://stackoverflow.com/questions/58299680/help-gets-stuck-in-command-line-for-fire-module
os.environ["PAGER"] = "cat"
# import inspect

LOGIC_MODULE_REQUIRED_ARGS = ['config', 'streams']

try:
    import pymssql
except Exception:
    pymyssql = None
try:
    import oracledb
except Exception:
    oracledb = None
try:
    import psycopg
except Exception:
    psycopg = None

logger = logging.getLogger(__name__)
petl.config.failonerror = True

# -----


class BaseRunner(object):
    ''' We create a base runner class so that we can re-use logic that is
    shared across the different types.
    '''
    source_type = None

    def __init__(self,
                 dry_run=False,
                 do_custom_attributes=True,
                 save_json=False,  # whether to save a local copy of
                                   # the payload on push
                 config_file=None,
                 config_file_yaml=None,
                 config_content=None,
                 config_url=None,
                 priority_push=False,
                 # env_file=None,
                 **kwargs
                 ):
        self.__kwargs = kwargs

        self._config_file = config_file
        self._config_file_yaml = config_file_yaml
        self._config_content = config_content
        self._config_url = config_url
        self._priority_push = priority_push

        if self._config_file:
            config.update(config_file=self._config_file,
                          source_type=self.source_type)

        if self._config_content:
            config.update(config_content=self._config_content,
                          source_type=self.source_type)

        if self._config_url:
            config.update(config_url=self._config_url,
                          source_type=self.source_type)

        if self._config_file_yaml:
            config.update(config_file=self._config_file_yaml,
                          config_format='yaml',
                          source_type=self.source_type)

        self._dry_run = dry_run
        self._do_custom_attributes = do_custom_attributes
        self._save_json = save_json

        if not config:
            logger.error('configuration is not valid')
        # else:

        #     self._provider_name = self.config.HRIS_VENDOR_NAME

        #     self._datasource_name = f"{self.config.HRIS_VENDOR_NAME} Datasource"  # noqa E501

        # This will store a list of all attributes (source->destiantion)
        self._attributes = {}
        self._custom_attrs_map = {}
        # self.hris = None
        # this makes oaa_utils available on the CLI as oaa-utils
        self.oaa = oaa_utils.FUNCS
        self._provider_object = None
        self._storage = {}
        self._datasource_name = None
        self._datasource = None

        self._post_init()

    def _get(self, key, default=None):
        return self._storage.get(key, default)

    def _set(self, key, value):
        self._storage[key] = value

    def _post_init(self, *args, **kwargs):
        pass

    def _hook_pre_oaa(self, *args, **kwargs):
        '''If a runner overrides this function, it can do data manipulation of
        the stream(s) before the OAA objects are created.
        '''

        return

    def _get_logic_modules(self):
        """Get any custom logic modules for this runner.
        :returns: TODO

        """
        logic_modules = config.LOGIC_MODULES or []

        modules = []

        for module in logic_modules:
            imported_module = importlib.import_module(module)

            try:
                # argsinfo = inspect.getfullargspec(imported_module.run)
                # assert all((f in argsinfo.args for f in LOGIC_MODULE_REQUIRED_ARGS))   # noqa E501
                assert utils.validate_logic_module(imported_module)
            except Exception as err:
                # TODO this is not longer a requirement. Let's release this so
                # it doesn't show as a warning
                logger.debug('you may consider defining a function called `run` '
                             f' in the {module} module and it must take the '
                             'following arguments: '
                             f' {LOGIC_MODULE_REQUIRED_ARGS}')

            modules.append(imported_module)

        return modules

    def utils(self):
        '''
        List utility functions.

        These functions are those that are available as transforms in the
        mapping document.
        '''

        return utils.transformers

    def show_config(self):

        return config._CONFIG.model_dump_json(indent=2)

    def _get_provider_type(self):
        '''Implement this methods to return the provider class to use when
        creating the provider here.
        '''
        raise NotImplementedError

    def _custom_provider_actions(self, provider_object=None):
        '''Action to take on the provider after the object is instantiated,
        before it is sent to Veza.
        '''
        raise NotImplementedError

    def _get_or_create_datasource(self, **kwargs):
        if not self._datasource:
            veza_con = oaa_utils.get_oaa_client(runner=self)
            datasource = veza_con.get_data_source(name=self._datasource_name,
                                                  provider_id=self._provider_object.provider['id'])

            if not datasource:
                datasource = veza_con.create_datasource(name=self._datasource_name,
                                                        provider_id=self._provider_object.provider['id'])

            self._datasource = datasource

        return self._datasource

    def _get_or_create_provider(self, **kwargs):

        if not self._provider_object:
            # Configure the Custom IdP

            if config.is_csv_push:
                self._set('_created_provider', False)

                return
            logger.info(f"Creating provider for {self._provider_name}")
            provider_cls = self._get_provider_type()

            if isinstance(kwargs, App):
                kwargs = kwargs.model_dump()

            options = {}

            if kwargs.get('options'):
                options = kwargs.pop('options')
            kwargs = {k: v for k, v in kwargs.items() if v}
            # provisioning = kwargs.pop('provisioning', options.get('provisioning', False))

            self._provider_object = provider_cls(**kwargs)

            self._custom_provider_actions(provider_object=self._provider_object)

            veza_con = oaa_utils.get_oaa_client(runner=self)
            provider = veza_con.get_provider(self._provider_name)
            # create a new provider if the provider doesn't already exist

            # create_provider_options = {}
            # external_lifecycle_management_type = kwargs.get('external_lifecycle_management_type')

            # if external_lifecycle_management_type:
            #     create_provider_options['external_lifecycle_management_type'] = external_lifecycle_management_type
            #     create_provider_options['provisioning'] = True

            # TODO update the create_provider action to take the 
            # external_lifecycle_management_type value. Set provisioning=True if it
            # is set.
            # {
            #     "name": "KhansenHR2-v4",
            #     "data_plane_id": "019afaed-8540-71a7-b159-4b91dd1144b5",
            #     "external_lifecycle_management_type": "SEND_REST_PAYLOAD",
            #     "provisioning": true,
            #     "custom_templates": [
            #         "application"
            #     ]
            # }

            if not provider:
                logger.info(f"Creating new provider {self._provider_name}")
                try:
                    provider = veza_con.create_provider(self._provider_name,
                                                        self._provider_object.TEMPLATE,
                                                        options=options)
                    logger.info(f"Provider created {provider['id']}")
                except OAAResponseError as err:
                    logger.error(str(err.details))
            self._provider_object.provider = provider

            if config.HRIS_ICON_B64_PATH:
                veza_con.update_provider_icon(provider['id'],
                                              encode_icon_file(config.HRIS_ICON_B64_PATH)) # noqa E501

            logger.info('template: %s', self._provider_object.TEMPLATE)

            # if self._provider_object.TEMPLATE in ('hris', 'idp') and provider.get('provisioning') != provisioning:

            # if config.runner_type in (RunnerType.hris, RunnerType.idp):  # and provider.get('provisioning') != provisioning:

            '''
            if config.destination_type == DestinationType.HRIS:  # and provider.get('provisioning') != provisioning:
                logger.info('updating provisioning=%s', str(provisioning))
                veza_con.update_provisioning_status(provider['id'], provisioning)
                # try:
                #     veza_con.update_provisioning_status(provider['id'], provisioning)
                # except Exception as err:
                #     logger.error(err.details)
            '''

            # self._provider_object = provider
            # self._get_or_create_datasource()

        return self._provider_object

    def _get_push_options(self):
        options = {
            'priority_push': self._priority_push
        }

        return options

    def _push_csv_file(self, provider, datasource, source):

        payload = oaa_utils.make_csv_upload_payload(provider['id'],
                                                    datasource['id'],
                                                    source.stream)

        oaaclient = oaa_utils.get_oaa_client()

        path = f'/api/v1/providers/custom/{provider["id"]}/datasources/{datasource["id"]}:push_csv' # noqa E501
        logger.info('path: %s', path)

        try:
            response = oaaclient.api_post(path, # noqa E501
                                          payload)

            return response
        except Exception as exc:
            logger.error(exc.details)

    def _push_to_oaa(self):
        veza_con = oaa_utils.get_oaa_client(runner=self)
        response = None
        datasource_id = None

        if config.is_csv_push:
            for source_name, source in config.sources.items():
                datasource = source.upsert_datasource()
                datasource_id = datasource['id']

                has_problems, errors = source.validate_to_api_csv_mapping()

                if not errors:
                    response = self._push_csv_file(source.get_provider(),
                                                   datasource,
                                                   source)
                else:
                    logger.error('csv file for source[%s:%s] is not valid',
                                 source_name, source.csv_filepath)
        else:
            try:
                # push the provider information to Veza, log and errors or
                # warnings

                if not self._dry_run:
                    logger.info("Sending to Veza")

                    # import bpdb; bpdb.set_trace()  # noqa: E702
                    datasource = self._get_or_create_datasource()
                    datasource_id = datasource['id']
                    response = veza_con.push_application(self._provider_name,
                                                         self._datasource_name,
                                                         self._provider_object,
                                                         save_json=self._save_json, # noqa E501
                                                         options=self._get_push_options()) # noqa E501
                    # pl = self._provider_object.get_payload()
                    # del pl['custom_property_definition']['domain_properties']
                    # response = veza_con.push_metadata(self._provider_name,
                    #                                   self._datasource_name,
                    #                                   pl,
                    #                                   save_json=self._save_json, # noqa E501
                    #                                   options=self._get_push_options()) # noqa E501

                    if response.get("warnings", None):
                        logger.warning("Push succeeded with warnings")

                        for w in response['warnings']:
                            logger.warning(w)
                    logger.info("Success")
                else:
                    logger.info('Would have sent a payload to Veza')
                    if self._save_json:
                        metadata = self._provider_object.get_payload()
                        ts = datetime.now().strftime('%Y%m%d-%H%M%S')
                        out_name = f'{self._datasource_name}-{ts}.json'
                        with open(out_name, 'w') as f:
                            f.write(json.dumps(metadata, indent=2))
                        logger.info(f'Dry run payload saved to {out_name}')
            except OAAResponseError as e:
                logger.error(e.details)  # import bpdb; bpdb.set_trace()  # noqa: E702
            except OAAClientError as e:
                logger.error(f"Error during push {e.message} {e.status_code}")

                if e.details:
                    from pprint import pprint
                    pprint(e.details)
                    # logger.error(e.details)
                logger.error("exiting with error")
                sys.exit(1)

        return datasource_id, response

    def _get_stream(self, query=None, source=None):
        '''
        The default is to load data from a DB.

        Overide this method to make it do something different.
        '''
        try:
            conn = self._get_connection()

            if conn:
                table = petl.fromdb(conn, query)

                return table
        except Exception as exc:
            logger.error(exc)

            return

    # Consider caching this method. It's only used once, for now, but it may be
    # called multiple times in the future
    def field_mappings(self,
                       output_format=None,
                       field_mapping_filepath=None):
        '''
        Field Mappings

        View all of the current field mappings. This output of this function
        defaults to json. If pass --output=table then it will output it in a
        text table. The output is really wide, especially when there are long
        notes or field_options, so I'm not really sure how useful it is.

        ¯\\_(ツ)_/¯

        '''
        mappings = {}

        if not field_mapping_filepath:
            field_mapping_filepath = config.SOURCE_MAPPING_FILEPATH

        fields = petl.fromcsv(config.SOURCE_MAPPING_FILEPATH)

        # Use pydantic to validate field mappings

        for idx, field in enumerate(fields.dicts()):

            ctx = {'row_num': idx+1}
            field = FieldMapping.model_validate(field,
                                                context=ctx).model_dump()

            mappings[field['source_field'] or field['destination_field']] = field  # noqa E501

        if output_format == 'table':

            return fields.look(200)

        elif output_format == 'json':

            return json.dumps(mappings, indent=2)

        return mappings

    def gen_config(self, app_name, num_sources=5):
        template = jinja_env.get_template('sample_config.yaml')

        context = {
            'app_name': app_name,
            'num_sources': num_sources,
            'os': os
        }

        return template.render(**context)

    def gen_oaa(self, app_name, num_sources=5, folder_location=None, **kwargs):
        # template = jinja_env.get_template('sample_config.yaml')

        # folder_location

        project_dir = Path(folder_location or self.__kwargs.get('folder_location', Path(os.getcwd())))

        logger.info('project folder %s', project_dir)
        templates = [
            ('README.md', f'{app_name}/README.md'),
            ('sample_config.yaml', f'{app_name}/config.yaml'),
            ('sample.env', f'{app_name}/sample.env'),
            ('requirements.txt', f'{app_name}/requirements.txt'),
            ('modules/appname.py', f'{app_name}/modules/{app_name}.py'),
            ('modules/appname_hooks.py', f'{app_name}/modules/{app_name}_hooks.py'),
            ('modules/appname_mocks.py', f'{app_name}/modules/{app_name}_mocks.py')
        ]

        context = {
            'app_name': app_name,
            'num_sources': num_sources,
            'os': os
        }
        try:
            # Define whatever we want the oaa project directory structure here

            for t, destination in templates:

                # create the folders, as needed
                destination = project_dir / destination
                Path(os.path.dirname(destination)).mkdir(parents=True, exist_ok=True)
                logger.info('creating: %s', destination)
                with open(destination, 'w+') as f:
                    f.write(jinja_env.get_template(t).render(**context))

        except Exception as e:
            print("Failed to create oaa project")
            print(e)

        return "Done!"

    # def generate_sample_field_mapping(self, output_filepath):
    #     '''
    #     Generate sample field mapping

    #     This command generates a sample field mapping document at the pathname
    #     given at output_filepath.

    #     '''

    #     holding_dir = os.path.dirname(output_filepath)

    #     if not os.path.exists(holding_dir):
    #         os.makedirs(holding_dir)

    #     # from pathlib import Path
    #     THIS_BASE_DIR = Path(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))  # noqa E501

    #     sample_path = THIS_BASE_DIR / 'data' / 'sample_field_mapping.csv'
    #     copyfile(sample_path, output_filepath)

    def _create_oaa_objects(self, table):
        """Create the OAA objects. Every runner needs to define this.

        :table: TODO
        :returns: TODO

        """
        logger.info('Starting submission, provider name '
                    f'{self._provider_object.name}')

        for prop_name, field in self._custom_attrs_map.items():

            # TODO map type to an actual type
            destination = field['destination_field']

            logger.debug('destination: %s', destination)
            logger.debug('field: %s', field)
            logger.debug('creating custom_property %s as type: %s',
                         destination, field['veza_field_type'].upper())
            upper_field_type = field['veza_field_type'].upper()
            try:
                field_type = getattr(OAAPropertyType, upper_field_type)
            except AttributeError as err:

                valid_types = [p.name for p in OAAPropertyType]
                logger.error(err)
                logger.error('Invalid custom property type %s:  '
                             'Must be one of %s',
                             upper_field_type,
                             valid_types)
                exit()
            self._provider_object.property_definitions.define_employee_property(prop_name, # noqa E501
                                                                                field_type) # noqa E501
        self._create_oaa_objects_custom(table)

    def _create_oaa_objects_custom(self, table):
        raise NotImplementedError

    def _get_connection(self):
        # logger.info(self._connection_string())
        # DB_DIALECT = 'SQLSERVER'

        if config.DB_DIALECT == 'SQLSERVER':
            try:
                connection = pymssql.connect(config.DB_SERVER,
                                             config.DB_USER,
                                             config.DB_PASSWORD,
                                             config.DB_NAME)
            except pymssql.exceptions.OperationalError as err:
                logger.error(err)

                return
        elif config.DB_DIALECT == 'ORACLE':
            '''
            Supported ORACLE Versions

            https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html#supported-oracle-database-versions # noqa E501
            '''
            # cs = "localhost/orclpdb"
            connection = oracledb.connect(user=config.DB_USER,
                                          password=config.DB_PASSWORD,
                                          dsn=config.DB_SERVER)
        elif config.DB_DIALECT == 'POSTGRES':
            connect_obj = dict(
                user=config.DB_USER,
                password=config.DB_PASSWORD,
                host=config.DB_SERVER,
                port=8307,
                dbname=config.DB_NAME)
            connection = psycopg.connect(**connect_obj)

        return connection
