"""
database system core layer to access Oracle databases
=====================================================

the class :class:`OraDb` of this namespace portion is a thin layer that is extending the :class:`~ae.db_core.DbBase` of
the module :mod:`ae.db_core` to connect to an Oracle database.

.. hint::
    this namespace portion is using the `cx_Oracle package <https://cx-oracle.readthedocs.io/en/7.2/module.html>`_
    as the database driver. the cx_Oracle package has to have at least version 5 or higher.


basic usage of the oracle database layer
----------------------------------------

to create an instance of the class :class:`OraDb` you first have to create a :class:`~ae.sys_core.SystemBase` instance.
this can be done either programmatically by providing an application instance (of the class
:class:`~ae.console.ConsoleApp` or an inherited sub-class of it) plus any database parameters, like required credentials
and any database configuration features/options::

    app = ConsoleApp()
    system = SystemBase('system-id', app, dict(User='user name', Password='password`, ...), ...)

alternatively provide all system-specific info within the :ref:`ae config files<config-files>` and let
:class:`~ae.sys_core.UsedSystems` load it::

    system = used_systems['system-id']

finally pass the database parameters in `system` to create an instance of :class:`OraDb`::

    ora_db = OraDb(system)

then call the :meth:`~OraDb.connect` method of this instance to connect to the Oracle database server::

    error_message = ora_db.connect()
    if error_message:
        print(error_message)

if the connection could not be established then :meth:`~OraDb.connect` is returning an error message string. if the
return value is an empty string then you can use all the methods provided by :class:`~ae.db_core.DbBase`, like e.g.
:meth:`~ae.db_core.DbBase.update`::

    error_message = ora_db.update('my_table`, {'my_col': 'new value'})
    if error_message:
        print(error_message)
        error_message = ora_db.rollback()

an explicit call of :meth:`~ae.db_core.DbBase.rollback` is only needed if you use transactions. in this case you should
also use :meth:`~ae.db_core.DbBase.commit` at the end of each transaction to store any data updates::

    error_message = ora_db.commit()

alternatively you can use the `commit` argument that is provided by the :class:`~ae.db_core.DbBase` DML methods: by
passing a `True` value to this argument, the method will automatically execute a :meth:`~ae.db_core.DbBase.commit` call
for you if no error occurred in the DML method::

    error_message = ora_db.update('table`, {'column': 369}, commit=True)

finally after all database actions are done you can close the connection to the databases server with the
:meth:`~ae.db_core.DbBase.close` method::

    error_message = ora_db.close()

"""
import datetime
import os
from typing import Any, Union

import cx_Oracle                                    # type: ignore

from ae.db_core import DbBase, SystemBase           # type: ignore  # SystemBase is an indirect import from ae.sys_core


__version__ = '0.2.6'


class OraDb(DbBase):
    """ Oracle database class, based on :class:`~.db_core.DbBase` """
    def __init__(self, system: SystemBase):
        """ create instance of oracle database object.

        :param system:      instance of a :class:`~ae.sys_core.SystemBase` class.

        :class:`~ae.sys_core.SystemBase` (defined in the module :mod:`ae.sys_core`) is providing
        the credentials and features, which get retrieved from :ref:`config-files`, then converted
        by :meth:`~ae.db_core.DbBase.connect_params` into :ref:`connection parameters` to connect
        to the Postgres database.

        if you experiencing the following unicode encoding error::

            'charmap' codec can't decode byte 0x90 in position 2: character maps to <undefined>

        don't try to create a type handler like recommended in some places - still got same error after adding
        the following method to replace the self.conn.outputtypehandler of the database driver::

                def output_type_handler(cursor, name, default_type, size, precision, scale):
                    if default_type in (cx_Oracle.STRING, cx_Oracle.FIXED_CHAR):
                        return cursor.var(cx_Oracle.NCHAR, size, cursor.arraysize)

        luckily, finally found workaround by setting the following OS environment variable to the
        character set of the used Oracle server (here UTF8)::

            os.environ["NLS_LANG"] = ".AL32UTF8"

        """
        super().__init__(system)
        os.environ["NLS_LANG"] = '.AL32UTF8'

    def connect(self) -> str:
        """ connect this instance to the database driver. """
        self.last_err_msg = ''

        conn_args = self.connect_params()
        user = conn_args.get('user')
        password = conn_args.get('password')
        dsn: str = conn_args.get('dsn')
        if dsn:
            if dsn.count(':') == 1 and dsn.count('/@') == 1:   # old style format == host:port/@SID
                host, rest = dsn.split(':', maxsplit=1)
                port, service_id = rest.split('/@', maxsplit=1)
                dsn = cx_Oracle.makedsn(host=host, port=port, sid=service_id)
            elif dsn and dsn.count(':') == 1 and dsn.count('/') == 1:  # old style format == host:port/service_name
                host, rest = dsn.split(':', maxsplit=1)
                port, service_name = rest.split('/', maxsplit=1)
                dsn = cx_Oracle.makedsn(host=host, port=port, service_name=service_name)
        else:
            make_dsn_args = {k: v for k, v in conn_args.items() if k not in ('user', 'password')}
            dsn = cx_Oracle.makedsn(**make_dsn_args)
        app_name = self.console_app.app_name

        try:
            # connect old style (using conn str): cx_Oracle.connect(self.usr + '/"' + self.pwd + '"@' + self.dsn)
            if cx_Oracle.__version__ >= '6':
                # sys context is using appcontext kwarg starting with cx_Oracle Version 6, which is
                # .. list of 3-tuples. so since V6 need to replace clientinfo kwarg with appcontext=app_ctx
                name_space = "CLIENTCONTEXT"  # fetch in Oracle with SELECT SYS_CONTEXT(NAMESPACE, "APP") FROM DUAL
                app_ctx = [(name_space, "APP", app_name),
                           (name_space, "LANG", "Python"),
                           (name_space, "MOD", "ae.db_ora")]
                self.conn = cx_Oracle.connect(user=user, password=password, dsn=dsn, appcontext=app_ctx)
            else:
                # sys context old style (until cx_Oracle Version 5 using clientinfo):
                self.conn = cx_Oracle.connect(user=user, password=password, dsn=dsn, clientinfo=app_name)
            # self.conn.outputtypehandler = output_type_handler       # see also comment in OraDb.__init__()
            self.console_app.dpo(f"OraDb.connect(): connected"
                                 f" via client version {cx_Oracle.clientversion()}/{cx_Oracle.apilevel}"
                                 f" with n-/encoding {self.conn.nencoding}/{self.conn.encoding} for {self}")
        except Exception as ex:
            self.last_err_msg = f"OraDb-connect() error '{ex}' for {self}"
        else:
            self.create_cursor()

        return self.last_err_msg

    def prepare_ref_param(self, value: Union[datetime.datetime, int, float, str]) -> Any:
        """ prepare special Oracle reference parameter.

        :param value:   the input value passed into the reference parameter of the called stored procedure.
        :return:        a handle to the reference variable.

        the following code snippet shows how to use this method together with :meth:`~.get_value` to retrieve the
        returned value of a reference parameter::

            ora_db = OraDb(...)
            *ref_var* = ora_db.prepare_ref_param("input_value")
            err_msg = ora_db.call_proc('STORED_PROCEDURE', (*ref_var*, ...))
            if not err_msg:
                output_value = ora_db.get_value(*ref_var*)

        """
        if isinstance(value, datetime.datetime):    # also True if value is datetime.date because inherits from datetime
            ora_type = cx_Oracle.DATETIME
        elif isinstance(value, int) or isinstance(value, float):
            ora_type = cx_Oracle.NUMBER
        else:
            ora_type = cx_Oracle.STRING
            value = str(value)
        ref_var = self.curs.var(ora_type)
        if value is not None:
            self.set_value(ref_var, value)
        return ref_var

    @staticmethod
    def get_value(var) -> Any:
        """ get output value from a reference variable passed into a stored procedure.

        :param var:     handle to a reference variable.
        :return:        output value of the reference variable.
        """
        return var.getvalue()

    @staticmethod
    def set_value(var: Any, value: Union[datetime.datetime, int, float, str]):
        """ set the input value of a reference variable to pass into a stored procedure.

        :param var:     handle to the reference variable to set.
        :param value:   value to set as input value of the reference variable.
        """
        var.setvalue(0, value)
