import sys
import os
import petl
import logging
import importlib
from pydantic import BaseModel
from typing import Optional, Any
from enum import Enum
from typing_extensions import Self
# from pathlib import Path
from oaaclient.templates import IdPProviderType
from oaa.oaa_utils import get_oaa_client, get_or_create_data_source
from oaa.models import FieldMapping, VezaObjectType, Provider
from oaa.hooks.decorators import OAAHookEvent, run_hooks
from oaaclient.templates import OAAPermission
from dotenv import load_dotenv
from pydantic import (
    ConfigDict,
    HttpUrl,
    constr,
    Field,
    model_validator,
    FilePath,
    FileUrl,
    DirectoryPath,
    field_validator,
)
from .settings_utils import (
    make_abs,
    SourceType,
    QueryStream,
    VersionEnum,
    RunnerType,
    LogLevel,
)
from .settings_utils import ConfigBase

try:
    import pymssql
except Exception:
    pymyssql = None
try:
    import oracledb
except Exception:
    oracledb = None
try:
    import psycopg
    from oaa.modules.pg_logging_cursor import LoggingPGCursor
except Exception:
    psycopg = None



load_dotenv('.env')


logger = logging.getLogger(__name__)


class Connection(BaseModel):
    name: Optional[str] = None
    model_config = ConfigDict(validate_default=True)


class RequestMethod(str, Enum):
    get = 'GET'
    post = 'POST'


class CSVConnection(Connection):
    def fetch(self, source):
        return petl.fromcsv(source.csv_filepath, encoding='utf-8-sig')


class APIConnection(Connection):
    model_config = ConfigDict(validate_default=True)
    uri: str
    method: Optional[RequestMethod] = RequestMethod.get
    params: Optional[dict] = Field(default_factory=lambda: {})
    auth: Optional[dict] = Field(default_factory=lambda: {})
    fetch_module: str   # api_fetch
    max_results: Optional[int] = None

    @field_validator('method', mode='before')
    @classmethod
    def method_uppercase(cls, value):
        if value:
            value = value.upper()

        return value

    def fetch(self, **kwargs):
        """TODO: Docstring for fetch.
        :returns: TODO

        """
        fetch_module = importlib.import_module(self.fetch_module)
        results = fetch_module.fetch(self, **kwargs)

        if results:
            table = petl.fromdicts(results)

            return table


class VezaAPIConnection(APIConnection):
    uri: Optional[str] = None
    path: str
    response_type: Optional[str] = 'list'
    fetch_module: Optional[str] = None  # api_fetch
    max_results: Optional[int] = None

    def fetch(self, **kwargs):
        """TODO: Docstring for fetch.
        :returns: TODO

        """
        fetch_module = importlib.import_module('veza_api_fetch')
        results = fetch_module.fetch(self)

        if results:
            table = petl.fromdicts(results)

            return table


class CustomConnection(Connection):
    query: Optional[str] = None
    custom_module: str
    params: Optional[dict[str, Any]] = Field(default_factory=lambda: {})

    def fetch(self, source=None, **kwargs):
        """TODO: Docstring for fetch.
        :returns: TODO

        """
        # params = kwargs.get('source').params
        module_import_paths = [
            self.custom_module,
            f'oaa.modules.{self.custom_module}'
        ]

        for module_path in module_import_paths:
            try:
                fetch_module = importlib.import_module(module_path)

                break
            except ModuleNotFoundError as exc:
                logger.error(exc)
                logger.debug('custom module %s not found, looking internally',
                             self.custom_module)

        results = fetch_module.fetch(connection=self,
                                     source=source, **kwargs)

        if results:
            # if 'row' not in results.header():
            #     results = results.addrownumbers()

            return results


class DBConnection(Connection):
    connection_type: Optional[str] = None
    # DB_PASSWORD: DB_FIELD  # Optional[str] = None
    DB_PASSWORD: Optional[str] = '!env'
    # DB_USER: Optional[DB_FIELD] = None  # Optional[str] = None
    DB_USER: Optional[str] = None
    DB_SERVER: Optional[str] = None
    DB_PORT: Optional[int] = None
    DB_NAME: Optional[str] = None
    DB_DIALECT: constr(to_upper=True) = "SQLSERVER"
    QUERY_PATH: Optional[FilePath] = None  # DEFAULT_QUERY_FILEPATH  # noqa E501
    QUERY: Optional[str] = None
    QUERY_STREAMS: Optional[list[QueryStream]] = None

    @model_validator(mode='after')
    def db_values_query_or_query_path(self) -> Self:

        if self.connection_type == SourceType.db:
            # DB type requires either QUERY or QUERY_PATH to be set

            if not any((self.QUERY_STREAMS, self.QUERY, self.QUERY_PATH)):

                raise ValueError('QUERY or QUERY_PATH must be defined for DB'
                                 ' sources')

        return self

        # return open(values.data['QUERY_PATH']).read()
        # return raw.lower()

    @field_validator('QUERY_PATH', mode='before')
    @classmethod
    def query_path_make_absolute(cls, value, info: dict[str, Any]):
        if value:
            value = make_abs(value, info.data['base_dir'])

        return value

    @field_validator('QUERY', mode='after')
    @classmethod
    def query_read_from_query_path(cls, value, info: dict[str, Any]):
        if not value:
            if info.data['QUERY_PATH']:
                value = open(info.data['QUERY_PATH']).read()

        return value

    # @field_validator('DB_PASSWORD', 'DB_USER',
    #                  'DB_SERVER', 'DB_NAME',
    #                  'DB_DIALECT',
    #                  mode='before')
    # def before_validate_db_vars(cls, var_value: Any,
    #                             values: dict[str, Any]) -> Any:

    #     return var_value

    @field_validator('DB_PASSWORD', 'DB_USER',
                     'DB_SERVER', 'DB_NAME',
                     'DB_DIALECT',
                     mode='before')
    def after_validate_db_vars(cls, var_value: Any,
                               values: dict[str, Any]) -> Any:

        if var_value == '!env':
            env_value = os.getenv(values.field_name)
            var_value = env_value

        if not var_value:
            raise ValueError('value must be set when source_type=db')

        return var_value

    def _get_connection(self):
        # logger.info(self._connection_string())
        # DB_DIALECT = 'SQLSERVER'
        db_dialect = self.DB_DIALECT.lower()
        connection = None

        if db_dialect == 'sqlserver':
            try:
                connection = pymssql.connect(host=self.DB_SERVER,
                                             user=self.DB_USER,
                                             password=self.DB_PASSWORD,
                                             database=self.DB_NAME,
                                             port=self.DB_PORT
                                             )
            except pymssql.exceptions.OperationalError as err:
                logger.error(err)

                return
        elif db_dialect == 'sqlserver.odbc':
            from fabric_odbc import fetch
            try:
                connection = fetch(connection=self, source=self)
            except Exception as err:
                logger.error(err)

                return
        elif 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=self.DB_USER,
                                          password=self.DB_PASSWORD,
                                          dsn=self.DB_SERVER)
        elif db_dialect == 'postgres':
            connect_obj = dict(
                user=self.DB_USER,
                password=self.DB_PASSWORD,
                host=self.DB_SERVER,
                port=self.DB_PORT,
                dbname=self.DB_NAME)
            logging.getLogger("psycopg").setLevel(logging.DEBUG)
            connection = psycopg.connect(cursor_factory=LoggingPGCursor,
                                         **connect_obj)

        run_hooks(event=OAAHookEvent.POST_DB_CONNECT,
                  connection=connection,
                  conx_config=self)

        return connection


class Tree(BaseModel):
    id_field: str
    parent_id_field: str
    sub_fieldname: Optional[str] = 'sub_resources'


class Source(BaseModel):
    # parent: Any = Field(default=None, exclude=True)
    model_config = ConfigDict(validate_default=True)

    # TODO make sure this is a valid destination type
    destination: Optional[VezaObjectType] = None
    source_type: SourceType
    source_name: Optional[str] = None
    source: Optional[str] = None
    tree: Optional[Tree] = None
    query: Optional[str] = None
    # If field_mapping is defined, we will ignore the mapping_filepath value
    field_mapping: Optional[dict[str, FieldMapping]] = None
    mapping_filepath: Optional[FilePath] = None
    csv_filepath: Optional[FilePath] = None
    csv_provider_id: Optional[str] = None
    resource_type: Optional[str] = None
    # connection: Optional[Connection] = None
    connection: Optional[str] = None
    _stream: Optional[Any] = None

    stream_set: Optional[bool] = False

    # custom_attrs_map: dict[str, FieldMapping] = Field(default_factory=lambda: FieldMapping())  # noqa E501
    custom_attrs_map: dict[str, FieldMapping] = Field(default_factory=lambda: {})  # noqa E501
    attributes: dict[str, str] = Field(default_factory=lambda: {})
    params: Optional[dict[str, Any]] = None
    record_transforms: Optional[list[str]] = Field(default_factory=lambda: [])
    record_transforms_non_stream: Optional[list[str]] = Field(default_factory=lambda: [])

    def upsert_datasource(self, datasource_name=None):

        provider = self.get_provider()

        if not datasource_name:
            datasource_name = f"{provider['name']} - Datasource"
        datasource, created = get_or_create_data_source(provider['name'],
                                                        provider['id'])

        return datasource

    def get_provider(self):

        veza_con = get_oaa_client()
        provider = veza_con.get_provider_by_id(self.csv_provider_id)

        if provider:
            logger.info("Found existing provider")

            return provider

    def validate_to_api_csv_mapping(self):
        """Validate this source by downloading the current mapping in Veza and
        running through the file to check that it is valid.
        """
        mapping = self.get_api_mapping()

        constraints = []
        table = self.get_stream()
        header_errors = []

        for cm in mapping.column_mappings:
            if cm.column_name:
                if cm.column_name not in table.fieldnames():
                    header_errors.append(f'{cm} missing')
                constraints.append(
                        {'name': cm.column_name,
                         'field': cm.column_name,
                         'test': bool
                         })

        if not header_errors:
            errors = table.validate(constraints=constraints)
            has_problems = errors.nrows() >= 1
        else:
            has_problems = True
            errors = header_errors

        return not has_problems, errors

    def get_api_mapping(self):
        '''Query the Veza API to get the current CSV field mappings. We will
        use this to validate the CSV prior to uploading.
        '''
        oaa_client = get_oaa_client()
        path = f'/api/v1/providers/custom/{self.csv_provider_id}' # noqa E501

        try:
            provider_response = oaa_client.api_get(path)
            provider = Provider.model_validate(provider_response)

            return provider.csv_mapping_configuration
        except Exception as err:
            logger.error(err)

    def field_mappings(self):
        '''
        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.

        ¯\\_(ツ)_/¯

        '''

        if self.destination == VezaObjectType.none and not self.field_mapping:
            self.field_mapping = {}

            return self.field_mapping

        if not self.field_mapping:

            # Use pydantic to validate field mappings
            mappings = {}

            if self.source_type == SourceType.csv_push:
                for idx, field in enumerate(self.get_api_mapping().column_mappings):  # noqa E501

                    ctx = {'row_num': idx+1}

                    field_dict = {
                        'source_field': field.column_name,
                        'destination_field': field.destination_property or 'None'  # noqa E501
                    }

                    if field.custom_property:

                        field_dict.update({
                            'is_custom': bool(field.custom_property),
                            'veza_field_type': field.custom_property['type']
                        })
                    field = FieldMapping.model_validate(field_dict,
                                                        context=ctx)

                    mappings[field.source_field or field.destination_field] = field  # noqa E501
            else:
                # TODO this is no longer correct to create a fieldmapping
                # for sources that a set to None. They just don't need a
                # fieldmapping if we aren't doing any transforms.
                fields = petl.fromcsv(self.mapping_filepath)

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

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

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

            self.field_mapping = mappings

        return self.field_mapping

    def get_stream(self):
        from oaa.settings import config

        if not self.stream_set:

            if self.source_type == SourceType.db:
                connection = config.connections[self.connection]
                conn = connection._get_connection()

                if conn:
                    table = petl.fromdb(conn, self.query)
                else:
                    raise Exception('unable to connect to DB')
            elif self.source_type == SourceType.csv or self.source_type == SourceType.csv_push:
                # table = petl.fromcsv(self.csv_filepath, encoding='utf-8-sig')
                connection = CustomConnection(
                    name='csv-loader',
                    custom_module='oaa.modules.csv_connector')  #.connections[self.connection]
                table = connection.fetch(source=self)
            elif self.source_type == SourceType.source:
                table = config.sources[self.source].get_stream()
                # TODO PICK UP here
                # connection = CustomConnection(
                #     name='csv-loader',
                #     custom_module='oaa.modules.csv_connector')  #.connections[self.connection]
                # table = connection.fetch(source=self, config=config)
            elif self.source_type in [SourceType.api, SourceType.veza_api]:
                connection = config.connections[self.connection]

                table = connection.fetch(source=self)
            elif self.source_type in [SourceType.custom]:
                connection = config.connections[self.connection]
                logger.debug('fetching custom stream from module')

                table = connection.fetch(source=self)

            # import bpdb; bpdb.set_trace()  # noqa: E702

            if not table:
                logger.error('Unable to fetch data for source %s', self.source_name)
                raise Exception(f'Unable to fetch data for source {self.source_name}')
            else:

                if 'row' not in petl.header(table):
                    table = table.addrownumbers()

                # table = table.addrownumbers()

                self.stream_set = True
                self._stream = table


        return self._stream

    @property
    def stream(self):
        '''
        Get the stream for this object.
        '''

        if self.stream_set:
            return self._stream

        stream = self.get_stream()
        # try:
        # except Exception as _err:
        #     logger.error(_err)
        #     # stream = petl.fromcolumns([])
        #     # raise Exception("Hello")
        #     # import bpdb; bpdb.set_trace()  # noqa: E702

        return stream

    @property
    def records(self):
        return petl.records(self.stream)

    # @field_validator('destination', mode='after')
    # @classmethod
    # def destination_validator(cls, value, info) -> str:
    #     if not value:
    #         value = info.data['parent'].name

    #     return value

    # @field_validator('*', mode='before')
    # @classmethod
    # def add_parent(cls, value, info) -> str:
    #     if info.field_name != 'parent' and type(value) is dict:
    #         value['parent'] = info.data

    #     if info.field_name != 'parent' and type(value) is list:
    #         for i in value:
    #             if type(i) is dict:
    #                 i['parent'] = info.data

    #     return value

    @field_validator('source_type', mode='before')
    def validate_SOURCE_TYPE(cls, source_type):
        if source_type:
            return source_type.upper()

        return source_type

    # # -------- DB Requirements

    @field_validator('mapping_filepath', 'csv_filepath', mode='before')
    @classmethod
    def validate_source_mapping_filepath(cls, filepath: str,
                                         values: dict[str, Any]) -> str:

        if values.data.get('destination') != VezaObjectType.none:

            if values.field_name == 'csv_filepath':
                if values.data.get('source_type') in (SourceType.csv,):

                    if not filepath:
                        raise ValueError('csv_filepath required when '
                                         'source_type is '
                                         f'{values.data["source_type"]}')

            if not values.data.get('field_mapping'):
                if values.field_name == 'mapping_filepath':
                    if values.data.get('source_type') in (SourceType.csv,
                                                          SourceType.db,
                                                          SourceType.api,
                                                          SourceType.custom,
                                                          ):

                        if not filepath and not values.data.get('field_mapping'):
                            raise ValueError('mapping_filepath required when '
                                             'source_type is '
                                             f'{values.data["source_type"]}')

        if filepath:
            from oaa.settings import Config
            base_dir = Config.BASE_DIR

            if filepath[:4] != 'http':
                filepath = make_abs(filepath, base_dir)
            logger.debug('base_dir: %s', Config.BASE_DIR)
            logger.debug('fieldname: %s - '
                         'source.filepath %s', values.field_name, filepath)
            # logger.error('parent %s', values.data['parent'])

        return filepath


class ExternalLifecycleManagementType(str, Enum):
    SEND_REST_PAYLOAD = 'SEND_REST_PAYLOAD'
    SCIM = 'SCIM'


class App(BaseModel):
    # All
    name: str
    # Custom App
    application_type: Optional[str] = None
    # IDP
    domain: Optional[str] = None
    idp_type: Optional[str] = None
    # HRIS
    hris_type: Optional[str] = None
    url: Optional[str] = None
    options: dict[str, Any] = Field(default_factory=lambda: {})
    provisioning: Optional[bool] = False
    external_lifecycle_management_type: Optional[ExternalLifecycleManagementType] = None
    data_plane_id: Optional[str] = False

    @field_validator("external_lifecycle_management_type", mode='before')
    @classmethod
    def validate_runnertype(cls, external_lifecycle_management_type, values):
        return external_lifecycle_management_type.upper()


class DestinationType(str, Enum):
    IDP = 'IDP'
    HRIS = 'HRIS'
    CUSTOM_APP = 'CUSTOM_APP'


class PermissionMapping(BaseModel):
    name: Optional[str] = None
    permissions: list[OAAPermission]


# class ConfigV2(BaseModel):
class ConfigV2(ConfigBase):
    version: VersionEnum = VersionEnum.v2
    runner_type: RunnerType = RunnerType.custom_app

    log_level: Optional[LogLevel] = Field(default=LogLevel.info)
    oaa_time_zone: constr(to_upper=True) = 'Z'

    VEZA_URL: Optional[HttpUrl] = None
    VEZA_API_KEY: Optional[str] = None  # '<veza-api-key>'
    # todo validate connection when both are set?

    # TODO Rename this to PROVIDER_ICON_PATH
    provider_icon_path: Optional[str] = None

    destination_type: Optional[DestinationType] = None

    # (optional, defaults to 'Department' label for employee groups)
    GROUP_TYPE: str = "Department"

    idp_provider_type: Optional[IdPProviderType] = IdPProviderType.OKTA
    # # -------- Custom App Requirements

    app: Optional[App] = None

    sources: Optional[dict[str, Source]] = Field(default_factory=lambda: {})
    is_csv_push: Optional[bool] = Field(default=False)
    connections: Optional[dict[str, Connection]] = None

    permission_to_veza_map: Optional[dict[str, PermissionMapping]] = Field(default_factory=lambda: {})

    # ------- MULTISTREAM Requirements

    module_directory: Optional[DirectoryPath] = None
    logic_modules: Optional[list[str]] = Field(default_factory=lambda: [])

    @field_validator("runner_type", mode='before')
    @classmethod
    def validate_runnertype(cls, runner_type, values):
        return runner_type.upper()

    @field_validator("destination_type", mode='before')
    @classmethod
    def validate_destination_type(cls, destination_type, values):
        if destination_type:
            destination_type = destination_type.upper()

        return destination_type

    @model_validator(mode='after')
    def post_update(cls,
                    values: dict[str, Any]) -> dict[str, Any]:

        for source_name, source in values.sources.items():
            source.source_name = source_name

            if source.source_type == SourceType.csv_push:
                values.is_csv_push = True

        return values

    @field_validator("app", mode='after')
    @classmethod
    def validate_app(cls, app, values):

        if values.data['runner_type'] == RunnerType.custom_app:
            pre_config = values.context.get('pre-config')

            if not app and not pre_config:
                raise Exception('app required when runnertype=custom_app')

        return app

    @field_validator("connections", mode='before')
    @classmethod
    def validate_connections(cls, connections, values):
        if isinstance(connections, dict):
            for name, connection_info in connections.items():

                connection_type = connection_info['connection_type'].upper()

                if connection_type == SourceType.db:
                    connections[name] = DBConnection.model_validate(connection_info)   # noqa E501
                elif connection_type == SourceType.api:
                    connections[name] = APIConnection.model_validate(connection_info)   # noqa E501
                elif connection_type == SourceType.veza_api:
                    connections[name] = VezaAPIConnection.model_validate(connection_info)   # noqa E501
                elif connection_type == SourceType.custom:
                    connections[name] = CustomConnection.model_validate(connection_info)   # noqa E501
                else:
                    c_type = {connection_info['connection_type']}
                    raise Exception(f'invalid connection type: {c_type}')

        return connections

    @field_validator("module_directory", mode='before')
    @classmethod
    def validate_module_directory(cls, module_path: str,
                                  values: dict[str, Any]) -> str:

        if module_path:

            from oaa.settings import Config
            module_path = make_abs(module_path, Config.BASE_DIR)
            logger.debug('new module_path %s', module_path)
            logger.debug('exists and is director %s',
                         os.path.isdir(module_path))
            sys.path.append(str(module_path))
            # sys.path.extend([str(filepath),])

        return module_path

    # # -- generic validators

    @field_validator("idp_provider_type", mode="before")
    @classmethod
    def transform(cls, raw: str) -> str:
        return raw.lower()

    def lookup_perms(self, perm_name, default=None):
        '''
        lookup the permissions list on the config object. It will be defined as
        follows:

            version: v2
            log_level: DEBUG
            veza_url: https://lab-kevin.vezacloud.com
            app:
              name: abnormal
              application_type:  abnormal
            module_directory: ./modules
            logic_modules:
            - abnormal_hooks
            - abnormal_mocks
            permission_to_veza_map:
              read:
                permissions:
                  - DataRead
              write:
                permissions:
                  - DataWrite
                  - DataRead
              delete:
                name: Delete
                permissions:
                  - DataWrite
            .....
        '''
        # Default to an list of NonData permission
        perm_list = [OAAPermission.NonData]

        if default is not None and type(default) in (list, tuple):
            perm_list = default

        if self.permission_to_veza_map:
            perm = self.permission_to_veza_map.get(perm_name)

            if perm:
                perm_list = perm.permissions

        return perm_list
