import importlib
import logging
import os
import sys
from enum import Enum
from typing import Any, Optional

import petl
from dotenv import load_dotenv

# from pathlib import Path
from oaaclient.templates import IdPProviderType, OAAPermission
from pydantic import (
    BaseModel,
    ConfigDict,
    DirectoryPath,
    Field,
    FilePath,
    FileUrl,
    HttpUrl,
    constr,
    field_validator,
    model_validator,
)
from typing_extensions import Self

from oaa.hooks.decorators import OAAHookEvent, run_hooks
from oaa.models import FieldMapping, Provider, VezaObjectType
from oaa.oaa_utils import get_oaa_client, get_or_create_data_source

from .settings_utils import (
    ConfigBase,
    LogLevel,
    QueryStream,
    RunnerType,
    SourceType,
    VersionEnum,
    make_abs,
)

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", override=True)


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)

        return results or None


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):
    """Source configuration for data ingestion.

    Note: The `field_mapping` (inline) configuration is preferred over the
    deprecated `mapping_filepath` (CSV file). Using inline field_mapping provides
    better validation, version control, and self-contained configuration.
    """

    # 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

    enable_multipart: Optional[bool] = False

    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(self) -> "ConfigV2":
        """Post-validation processing of config.

        Sets source_name on each source and marks is_csv_push if any source
        uses csv_push source type.
        """
        for source_name, source in self.sources.items():
            source.source_name = source_name

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

        return self

    @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
