from contextlib import suppress
from logging import Logger
from typing import Any

from .db_common import (
    DbEngine, _bind_columns, _bind_marks, _except_msg, _remove_nulls
)
from .db_pomes import db_connect, db_count


# ruff: noqa: C901 PLR0912 PLR0915
def db_migrate_data(errors: list[str] | None,
                    source_engine: DbEngine,
                    source_table: str,
                    source_columns: list[str],
                    target_engine: DbEngine,
                    target_table: str,
                    target_columns: list[str] = None,
                    source_conn: Any = None,
                    target_conn: Any = None,
                    source_committable: bool = None,
                    target_committable: bool = None,
                    where_clause: str = None,
                    orderby_clause: str = None,
                    offset_count: int = None,
                    limit_count: int = None,
                    batch_size_in: int = None,
                    batch_size_out: int = None,
                    identity_column: str = None,
                    has_nulls: bool = None,
                    logger: Logger = None) -> int:
    """
    Migrate data from one database to another database.

    The origin and destination databases must be in the list of databases configured and
    supported by this package. The specified database columns must not be type LOB
    (large binary object), and must have the same cardinality, and be of respectively
    equivalent types, in both the origin and the destination databases.

    Specific handling is required for identity columns (i.e., columns whose values are generated
    directly by the database engine - typically, they are also primary keys), and thus they must be
    identified by *identity_column*. For target Oracle databases, *GENERATED ALWAYS*
    identity columns should be ommited, as attempting to insert values therein raises an error,
    whereas *GENERATED BY DEFAULT AS IDENTITY* columns are acceptable.

    The parameter *has_nulls* indicates that one or more columns holding string content in the source
    table have embedded NULL characters. Some databases, particularly Postgres, do not accept NULLs
    embedded in text, so they must be removed.

    The parameter *offset_count* is used to offset the retrieval of tuples. If it is set to -1,
    its value is set from a *COUNT* operation in *target_table*, with *where_clause* if specified.
    It is ignored for all other values different from a positive integer.

    The parameters *batch_size_in* and *limit_count* are used to limit the maximum number of tuples
    to retrieve in each SELECT operation, and *batch_size_out* indicates the maximum number of tuples
    to write in each INSERT operation. They are ignored if non-integers, or if less than 1.

    Further, if result set sorting has not been specified in *orderby_clause*, the parameters
    *batch_size_in* and *offset_count* are ignored.

    The parameters *source_committable* and *target_committable* are relevant only
    if *source_conn* and *target_conn* are provided, respectively, and are otherwise ignored.
    A rollback is always attempted, if an error occurs.

    :param errors: incidental error messages
    :param source_engine: the source database engine type
    :param source_table: the possibly schema-qualified table to read the data from
    :param source_columns: the columns to be migrated
    :param target_engine: the destination database engine type
    :param target_table: the possibly schema-qualified table to write the data to
    :param target_columns: the columns to write the data to (defaults to the source columns)
    :param source_conn: the connection to the source database (obtains a new one, if not provided)
    :param target_conn: the connection to the destination database (obtains a new one, if not provided)
    :param source_committable: whether to commit on *source_conn* upon errorless completion
    :param target_committable: whether to commit on *target_conn* upon errorless completion
    :param where_clause: optional criteria for tuple selection
    :param orderby_clause: optional retrieval order
    :param offset_count: number of tuples to skip in source table (defaults to none)
    :param limit_count: maximum number of tuples to migrate (defaults to no limit)
    :param batch_size_in: maximum number of tuples to read in each batch (defaults to no limit)
    :param batch_size_out: maximum number of tuples to write in each batch (defaults to no limit)
    :param identity_column: column whose values are generated by the database
    :param has_nulls: indicates that one or more string-type source columns have nulls
    :param logger: optional logger
    :return: the number of tuples effectively migrated
    """
    # initialize the return variable
    result: int = 0

    # initialize the local errors list
    op_errors: list[str] = []

    # make sure to have connections to the source and destination databases
    curr_source_conn: Any = source_conn or db_connect(errors=op_errors,
                                                      engine=source_engine,
                                                      logger=logger)
    curr_target_conn: Any = target_conn or db_connect(errors=op_errors,
                                                      engine=target_engine,
                                                      logger=logger)
    if curr_source_conn and curr_target_conn:

        # definite whether it is possible to retrieve tuples in batches
        if not orderby_clause:
            batch_size_in = 0
            offset_count = 0

        # define the number of rows to skip
        if offset_count == -1:
            offset_count = db_count(errors=op_errors,
                                    table=target_table,
                                    where_clause=where_clause,
                                    engine=target_engine,
                                    connection=target_conn,
                                    committable=target_committable,
                                    logger=logger)
        else:
            offset_count = __normalize_value(value=offset_count)

        # normalize these parameters
        limit_count = __normalize_value(value=limit_count)
        batch_size_in = __normalize_value(value=batch_size_in)
        batch_size_out = __normalize_value(value=batch_size_out)

        # make sure to have target columns
        if not target_columns:
            target_columns = source_columns

        # buid the SELECT query:
        #   SELECT * FROM <table>
        #     WHERE <where-clause>
        #     ORDER BY <column>
        #     FETCH FIRST <n> ROWS ONLY
        #   or
        #   SELECT * FROM <table>
        #     WHERE <where-clause>
        #     ORDER BY <column>
        #     OFFSET <n> ROWS
        #     FETCH NEXT <n> ROWS ONLY
        cols: str = ", ".join(source_columns)
        sel_stmt: str = f"SELECT {cols} FROM {source_table}"
        if where_clause:
            sel_stmt += f" WHERE {where_clause}"
        if orderby_clause:
            sel_stmt += f" ORDER BY {orderby_clause}"

        if offset_count or limit_count > batch_size_in:
            sel_stmt += " OFFSET @offset ROWS"
        if limit_count or batch_size_in:
            if source_engine == DbEngine.POSTGRES:
                sel_stmt += " LIMIT @limit"
            elif source_engine == DbEngine.ORACLE:
                sel_stmt += " FETCH @limit ROWS ONLY"
            elif source_engine == DbEngine.SQLSERVER:
                sel_stmt = sel_stmt.replace("SELECT ", "SELECT TOP @limit ", 1)

        # build the INSERT query
        if target_engine == DbEngine.POSTGRES:
            values: str = "VALUES %s"
            # pre-insert handling of identity columns on Postgres
            if identity_column:
                values = " OVERRIDING SYSTEM VALUE " + values
        else:
            bind_marks: str = _bind_marks(engine=target_engine,
                                          start=1,
                                          finish=len(target_columns) + 1)
            values: str = f"VALUES({bind_marks})"
        cols = ", ".join(target_columns)
        insert_stmt: str = (f"INSERT INTO {target_table} "
                            f"({cols}) {values}")

        # log the migration start
        if logger:
            logger.debug(msg=f"Started migrating data, "
                             f"from {source_engine}.{source_table} "
                             f"to {target_engine}.{target_table}")

        # pre-insert handling of identity columns on SQLServer
        if identity_column and target_engine == DbEngine.SQLSERVER:
            from . import sqlserver_pomes
            # noinspection PyProtectedMember
            # ruff: noqa: SLF001
            sqlserver_pomes._identity_pre_insert(errors=op_errors,
                                                 insert_stmt=insert_stmt,
                                                 conn=target_conn,
                                                 logger=logger)
        err_msg: str | None = None
        # errors ?
        if not op_errors:
            # no, migrate the data
            try:
                source_cursor: Any = curr_source_conn.cursor()
                target_cursor: Any = curr_target_conn.cursor()
                if target_engine == DbEngine.SQLSERVER:
                    target_cursor.fast_executemany = True

                # adjust the offset and limit
                curr_stmt: str = sel_stmt
                curr_limit: int = min(batch_size_in, limit_count)
                if curr_limit == 0:
                    curr_limit = max(batch_size_in, limit_count)
                if offset_count:
                    curr_stmt = curr_stmt.replace("@offset", str(offset_count), 1)\
                                         .replace("@limit", str(curr_limit), 1)\
                                         .replace(" FETCH ", " FETCH NEXT ", 1)
                else:
                    curr_stmt = curr_stmt.replace(" OFFSET @offset ROWS", "", 1)\
                                         .replace("@limit", str(curr_limit), 1)\
                                         .replace(" FETCH ", " FETCH FIRST ", 1)

                # execute the query and retrieve the result set
                source_cursor.execute(statement=curr_stmt)
                rows_in: list[tuple] = source_cursor.fetchall()

                # traverse the result set
                while rows_in:
                    # log the retrieval operation
                    if logger:
                        logger.debug(msg=f"Read {len(rows_in)} tuples "
                                         f"from {source_engine}.{source_table}")
                    pos_from: int = 0

                    # migrate the tuples
                    while pos_from < len(rows_in):
                        pos_to: int = min(pos_from + batch_size_out, len(rows_in)) \
                                      if batch_size_out else len(rows_in)
                        rows_out: list[tuple] = rows_in[pos_from:pos_to]
                        pos_from = pos_to
                        match target_engine:
                            case DbEngine.MYSQL:
                                pass
                            case DbEngine.ORACLE:
                                target_cursor.executemany(statement=insert_stmt,
                                                          parameters=rows_out)
                            case DbEngine.POSTGRES:
                                from psycopg2.extras import execute_values
                                if has_nulls:
                                    rows_in = _remove_nulls(rows=rows_out)
                                execute_values(cur=target_cursor,
                                               sql=insert_stmt,
                                               argslist=rows_out)
                            case DbEngine.SQLSERVER:
                                target_cursor.executemany(insert_stmt,
                                                          rows_out)

                        # increment the tuple migration counter and log the partial migration
                        result += len(rows_out)
                        if logger:
                            logger.debug(msg=f"Migrated {result} tuples, "
                                             f"from {source_engine}.{source_table} "
                                             f"to {target_engine}.{target_table}")
                    # read the next batch
                    if limit_count > result or (batch_size_in and not limit_count):
                        curr_limit = min(batch_size_in, limit_count - result)
                        if curr_limit <= 0:
                            curr_limit = max(batch_size_in, limit_count - result)
                        curr_stmt = sel_stmt.replace("@offset", str(offset_count + result), 1)\
                                            .replace("@limit", str(curr_limit), 1)\
                                            .replace(" FETCH ", " FETCH NEXT ", 1)
                        source_cursor.execute(statement=curr_stmt)
                        rows_in = source_cursor.fetchall()
                    else:
                        # signal end of migration
                        rows_in = []

                # close the cursors
                source_cursor.close()
                target_cursor.close()

                # post-insert handling of identity columns
                if identity_column and result > 0:
                    if target_engine == DbEngine.POSTGRES:
                        from . import postgres_pomes
                        # noinspection PyProtectedMember
                        # ruff: noqa: SLF001
                        postgres_pomes._identity_post_insert(errors=op_errors,
                                                             insert_stmt=insert_stmt,
                                                             conn=target_conn,
                                                             committable=False,
                                                             identity_column=identity_column,
                                                             logger=logger)
                    elif target_engine == DbEngine.SQLSERVER:
                        from . import sqlserver_pomes
                        # noinspection PyProtectedMember
                        # ruff: noqa: SLF001
                        sqlserver_pomes._identity_post_insert(errors=op_errors,
                                                              insert_stmt=insert_stmt,
                                                              conn=target_conn,
                                                              committable=False,
                                                              identity_column=identity_column,
                                                              logger=logger)
                if op_errors:
                    if curr_source_conn:
                        curr_source_conn.rollback()
                else:
                    # commit the transactions
                    if source_committable or not source_conn:
                        curr_source_conn.commit()
                    if target_committable or not target_conn:
                        curr_target_conn.commit()
            except Exception as e:
                # rollback the transactions
                if curr_source_conn:
                    with suppress(Exception):
                        curr_source_conn.rollback()
                if curr_target_conn:
                    with suppress(Exception):
                        curr_target_conn.rollback()
                result = 0
                err_msg = _except_msg(exception=e,
                                      engine=source_engine)
            finally:
                # close the connections, if locally acquired
                if curr_source_conn and not source_conn:
                    curr_source_conn.close()
                if curr_target_conn and not target_conn:
                    curr_target_conn.close()

        # log the migration finish
        if err_msg:
            op_errors.append(err_msg)
            if logger:
                logger.error(msg=err_msg)
        elif logger:
            logger.debug(msg=f"Finished migrating {result} tuples, "
                             f"from {source_engine}.{source_table} "
                             f"to {target_engine}.{target_table}")
    # acknowledge local errors
    if isinstance(errors, list):
        errors.extend(op_errors)

    return result


# ruff: noqa: PLR0912 PLR0915
def db_migrate_lobs(errors: list[str] | None,
                    source_engine: DbEngine,
                    source_table: str,
                    source_lob_column: str,
                    source_pk_columns: list[str],
                    target_engine: DbEngine,
                    target_table: str,
                    target_lob_column: str = None,
                    target_pk_columns: list[str] = None,
                    source_conn: Any = None,
                    target_conn: Any = None,
                    source_committable: bool = None,
                    target_committable: bool = None,
                    where_clause: str = None,
                    orderby_clause: str = None,
                    offset_count: int = None,
                    limit_count: int = None,
                    batch_size: int = None,
                    chunk_size: int = None,
                    accept_empty: bool = None,
                    logger: Logger = None,
                    log_trigger: int = 10000) -> int:
    """
    Migrate large binary objects (LOBs) between databases.

    The origin and destination databases must be in the list of databases configured and
    supported by this package. One or more columns making up a primary key, or a unique row identifier,
    must exist on *source_table* and *target_table*, and be provided in *source_pk_columns*.
    It is assumed that the primary key columns have the same cardinality, and are of respectively
    equivalent types, in both the origin and the destination databases.

    The parameter *offset_count* is used to offset the retrieval of tuples. If it is set to -1,
    its value is set from a *COUNT* operation in *target_table*, with *where_clause* if specified.
    It is ignored for all other values different from a positive integer.

    The parameters *batch_size* and *limit_count* are used to limit the maximum number of tuples
    to retrieve in each SELECT operation. They are ignored if non-integers, or if less than 1.

    Further, if *batch_size* or *offset_count* has been specified, but *orderby_clause* has not,
    then an ORDER BY clause is constructed from the data in *source_pk_columns*.

    The parameters *source_committable* and *target_committable* are relevant only
    if *source_conn* and *target_conn* are provided, respectively, and are otherwise ignored.
    A rollback is always attempted, if an error occurs.

    :param errors: incidental error messages
    :param source_engine: the source database engine type
    :param source_table: the possibly schema-qualified table to read the LOBs from
    :param source_lob_column: the column holding the LOB
    :param source_pk_columns: columns making up a primary key, or a unique identifier for a tuple, in source table
    :param target_engine: the destination database engine type
    :param target_table: the possibly schema-qualified table to write the LOB to
    :param target_lob_column: the column to write the LOB to (defaults to the source column)
    :param target_pk_columns: columns making up a primary key, or a unique identifier for a tuple, in target table
    :param source_conn: the connection to the source database (obtains a new one, if not provided)
    :param target_conn: the connection to the destination database (obtains a new one, if not provided)
    :param source_committable: whether to commit on *source_conn* upon errorless completion
    :param target_committable: whether to commit on *target_conn* upon errorless completion
    :param where_clause: optional criteria for tuple selection
    :param orderby_clause: optional retrieval order
    :param offset_count: number of tuples to skip in source table (defaults to none)
    :param limit_count: maximum number of tuples to migrate (defaults to no limit)
    :param batch_size: maximum number of tuples to read in each batch (defaults to no limit)
    :param chunk_size: size in bytes of the data chunk to read/write (defaults to no limit)
    :param accept_empty: account for all LOBs, even empty ones
    :param logger: optional logger
    :param log_trigger: number of LOBs to trigger logging info on migration (defaults to 10000 LOBs)
    :return: the number of LOBs effectively migrated
    """
    # initialize the return variable
    result: int = 0

    # initialize the local errors list
    op_errors: list[str] = []

    # make sure to have connections to the source and destination databases
    curr_source_conn: Any = source_conn or db_connect(errors=op_errors,
                                                      engine=source_engine,
                                                      logger=logger)
    curr_target_conn: Any = target_conn or db_connect(errors=op_errors,
                                                      engine=target_engine,
                                                      logger=logger)
    if curr_source_conn and curr_target_conn:

        # make sure to have a target column
        if not target_lob_column:
            target_lob_column = source_lob_column

        # make sure to have target PK columns
        if not target_pk_columns:
            target_pk_columns = source_pk_columns

        # define the number of rows to skip
        if offset_count == -1:
            offset_count = db_count(errors=op_errors,
                                    table=target_table,
                                    where_clause=where_clause,
                                    engine=target_engine,
                                    connection=target_conn,
                                    committable=target_committable,
                                    logger=logger)
        else:
            offset_count = __normalize_value(value=offset_count)

        # normalize these parameters
        limit_count = __normalize_value(value=limit_count)
        batch_size = __normalize_value(value=batch_size)
        chunk_size = __normalize_value(value=chunk_size) or -1
        accept_empty = isinstance(accept_empty, bool) and accept_empty

        # buid the SELECT query
        source_pks: str = ", ".join(source_pk_columns)
        sel_stmt: str = f"SELECT {source_pks}, {source_lob_column} FROM {source_table}"
        if where_clause:
            sel_stmt += f" WHERE {where_clause}"
        if not orderby_clause and (offset_count or batch_size):
            orderby_clause = ", ".join(source_pk_columns)
        if orderby_clause:
            sel_stmt += f" ORDER BY {orderby_clause}"

        if offset_count or limit_count > batch_size:
            sel_stmt += " OFFSET @offset ROWS"
        if limit_count or batch_size:
            if source_engine == DbEngine.POSTGRES:
                sel_stmt += " LIMIT @limit"
            elif source_engine == DbEngine.ORACLE:
                sel_stmt += " FETCH @limit ROWS ONLY"
            elif source_engine == DbEngine.SQLSERVER:
                sel_stmt = sel_stmt.replace("SELECT ", "SELECT TOP @limit ", 1)

        # build the UPDATE query
        lob_index: int = len(source_pk_columns)
        target_where_columns: str = _bind_columns(engine=target_engine,
                                                  columns=target_pk_columns,
                                                  concat=" AND ",
                                                  start_index=2)
        if target_engine == DbEngine.ORACLE:
            update_stmt: str = (f"UPDATE {target_table} "
                                f"SET {target_lob_column} = empty_blob() "
                                f"WHERE {target_where_columns} "
                                f"RETURNING {target_lob_column} INTO :{len(target_where_columns) + 2}")
        else:
            lob_bind: str = _bind_columns(engine=target_engine,
                                          columns=[target_lob_column],
                                          concat=", ",
                                          start_index=1)
            update_stmt: str = (f"UPDATE {target_table} "
                                f"SET {target_lob_column} = "
                                f"{target_lob_column} || {lob_bind[len(target_lob_column) + 3:]} "
                                f"WHERE {target_where_columns}")

        # log the migration start
        if logger:
            logger.debug(msg=f"Started migrating LOBs, "
                             f"from {source_engine}.{source_table}.{source_lob_column} "
                             f"to {target_engine}.{target_table}.{target_lob_column}")
        # migrate the LOBs
        log_step: int = 0
        err_msg: str | None = None
        try:
            source_cursor: Any = curr_source_conn.cursor()
            target_cursor: Any = curr_target_conn.cursor()

            # adjust the offset and limit
            curr_stmt: str = sel_stmt
            curr_limit: int = min(batch_size, limit_count)
            if curr_limit == 0:
                curr_limit = max(batch_size, limit_count)
            if offset_count:
                curr_stmt = curr_stmt.replace("@offset", str(offset_count), 1)\
                                     .replace("@limit", str(curr_limit), 1)\
                                     .replace(" FETCH ", " FETCH NEXT ", 1)
            else:
                curr_stmt = curr_stmt.replace(" OFFSET @offset ROWS", "", 1)\
                                     .replace("@limit", str(curr_limit), 1)\
                                     .replace(" FETCH ", " FETCH FIRST ", 1)
            # go for the data
            next_rs: bool = True
            while next_rs:
                next_rs = False

                # execute the query
                # (parameter name is 'statement' for oracle, 'query' for postgres, 'sql' for sqlserver)
                source_cursor.execute(curr_stmt)

                # traverse the result set
                row: tuple = source_cursor.fetchone()
                while row:
                    next_rs = True
                    # retrieve the values of the primary key columns (leave LOB column out)
                    pk_vals: tuple = tuple([row[inx] for inx in range(lob_index)])

                    ora_lob: Any = None
                    if target_engine == DbEngine.ORACLE:
                        # initialize the LOB column with an empty LOB
                        import oracledb
                        lob_var = target_cursor.var(oracledb.DB_TYPE_BLOB)
                        target_cursor.execute(statement=update_stmt,
                                              parameters=(*pk_vals, lob_var))
                        ora_lob = lob_var.getValue()

                    # access the LOB in chunks and write it to database
                    offset: int = 1
                    has_data: bool = accept_empty
                    lob: Any = row[lob_index]
                    lob_data: bytes | str = lob.read(offset=offset,
                                                     amount=chunk_size) if lob else None
                    while lob_data:
                        size: int = len(lob_data)
                        has_data = True
                        match target_engine:
                            case DbEngine.MYSQL:
                                pass
                            case DbEngine.ORACLE:
                                ora_lob.write(data=lob_data,
                                              offset=offset)
                            case DbEngine.POSTGRES:
                                from psycopg2 import Binary
                                # remove append indication on initial update
                                update_pg: str = update_stmt if offset > 1 else \
                                    update_stmt.replace(f"{target_lob_column} || ", "", 1)

                                # string data may come from a LOB (Oracle's NCLOB is a good example)
                                col_data: str | Binary = Binary(lob_data) if isinstance(lob_data, bytes) else lob_data
                                target_cursor.execute(query=update_pg,
                                                      vars=(col_data, *pk_vals))
                            case DbEngine.SQLSERVER:
                                from pyodbc import Binary
                                # remove append indication on initial update
                                update_sqls: str = update_stmt if offset > 1 else \
                                    update_stmt.replace(f"{target_lob_column} || ", "", 1)

                                # string data may come from a LOB (Oracle's NCLOB is a good example)
                                col_data: str | Binary = Binary(lob_data) if isinstance(lob_data, bytes) else lob_data
                                target_cursor.execute(sql=update_sqls,
                                                      params=(col_data, *pk_vals))
                        # read the next chunk
                        offset += size
                        lob_data = lob.read(offset=offset,
                                            amount=chunk_size)

                    # increment the LOB migration counter, if applicable
                    if has_data:
                        result += 1
                        log_step += 1

                    # log partial result at each 'log_trigger' LOBs migrated
                    if logger and log_step >= log_trigger:
                        logger.debug(msg=f"Migrated {result} LOBs, "
                                         f"from {source_engine}.{source_table}.{source_lob_column} "
                                         f"to {target_engine}.{target_table}.{target_lob_column}")
                        log_step = 0

                    # retrieve the next row
                    row = source_cursor.fetchone()

                # adjust the new offset and limit
                if next_rs and (limit_count > result or (batch_size and not limit_count)):
                    curr_limit = min(batch_size, limit_count - result)
                    if curr_limit <= 0:
                        curr_limit = max(batch_size, limit_count - result)
                    curr_stmt = sel_stmt.replace("@offset", str(offset_count + result), 1)\
                                        .replace("@limit", str(curr_limit), 1)\
                                        .replace(" FETCH ", " FETCH NEXT ", 1)
                else:
                    # signal end of migration
                    next_rs = False

            # close the cursors and commit the transactions
            source_cursor.close()
            if source_committable or not source_conn:
                curr_source_conn.commit()
            target_cursor.close()
            if target_committable or not target_conn:
                curr_target_conn.commit()
        except Exception as e:
            # rollback the transactions
            if curr_source_conn:
                with suppress(Exception):
                    curr_source_conn.rollback()
            if curr_target_conn:
                with suppress(Exception):
                    curr_target_conn.rollback()
            result = 0
            err_msg = _except_msg(exception=e,
                                  engine=source_engine)
        finally:
            # close the connections, if locally acquired
            if curr_source_conn and not source_conn:
                curr_source_conn.close()
            if curr_target_conn and not target_conn:
                curr_target_conn.close()

        # log the migration finish
        if err_msg:
            op_errors.append(err_msg)
            if logger:
                logger.error(msg=err_msg)
        elif logger:
            logger.debug(msg=f"Finished migrating {result} LOBs, "
                             f"from {source_engine}.{source_table}.{source_lob_column} "
                             f"to {target_engine}.{target_table}.{target_lob_column}")
    # acknowledge local errors
    if isinstance(errors, list):
        errors.extend(op_errors)

    return result


def db_stream_lobs(errors: list[str] | None,
                   table: str,
                   lob_column: str,
                   pk_columns: list[str],
                   ref_column: str = None,
                   engine: DbEngine = None,
                   connection: Any = None,
                   committable: bool = None,
                   where_clause: str = None,
                   orderby_clause: str = None,
                   offset_count: int = None,
                   limit_count: int = None,
                   batch_size: int = None,
                   chunk_size: int = None,
                   accept_empty: bool = None,
                   logger: Logger = None,
                   log_trigger: int = 10000) -> None:
    """
    Stream data in large binary objects (LOBs) from a database.

    This is accomplished with the implementation of the *generator* pattern, whereby an *iterator*
    is returned, allowing the invoker to iterate over the values being streamed.
    The origin database must be in the list of databases configured and
    supported by this package. One or more columns making up a primary key, or a unique
    row identifier, must exist on *source_table*, and be provided in *source_pk_columns*.

    The parameter *offset_count* is used to offset the retrieval of tuples. If it is set to -1,
    its value is set from a *COUNT* operation in *target_table*, with *where_clause* if specified.
    It is ignored for all other values different from a positive integer.

    Further, if *batch_size* or *offset_count* has been specified, but *orderby_clause* has not,
    then an ORDER BY clause is constructed from the data in *pk_columns*.

    The parameter *committable* is relevant only if *connection* is provided, and is otherwise ignored.
    A rollback is always attempted, if an error occurs.

    :param errors: incidental error messages
    :param table: the table holding the LOBs
    :param lob_column: the column holding the LOB
    :param pk_columns: columns making up a primary key, or a unique identifier for a tuple, in database
    :param ref_column: optional column whose content to return when yielding
    :param engine: the database engine to use (uses the default engine, if not provided)
    :param connection: optional connection to use (obtains a new one, if not provided)
    :param committable: whether to commit upon errorless completion
    :param where_clause: the criteria for tuple selection
    :param orderby_clause: optional retrieval order
    :param offset_count: number of tuples to skip in source table (defaults to none)
    :param limit_count: maximum number of tuples to migrate (defaults to no limit)
    :param batch_size: maximum number of tuples to read in each batch (defaults to no limit)
    :param chunk_size: size in bytes of the data chunk to read/write (defaults to no limit)
    :param accept_empty: account for all LOBs, even empty ones
    :param logger: optional logger
    :param log_trigger: number of tuples to trigger logging info on migration (defaults to 10000 tuples)
    """
    # initialize the local errors list
    op_errors: list[str] = []

    # make sure to have a connection to the source database
    curr_conn: Any = connection or db_connect(errors=op_errors,
                                              engine=engine,
                                              logger=logger)
    if curr_conn:
        # define the number of rows to skip
        if offset_count == -1:
            offset_count = db_count(errors=op_errors,
                                    table=table,
                                    where_clause=where_clause,
                                    engine=engine,
                                    connection=connection,
                                    committable=committable,
                                    logger=logger)
        else:
            offset_count = __normalize_value(value=offset_count)

        # normalize these parameters
        limit_count = __normalize_value(value=limit_count)
        batch_size = __normalize_value(value=batch_size)
        chunk_size = __normalize_value(value=chunk_size) or -1
        accept_empty = isinstance(accept_empty, bool) and accept_empty

        # buid the SELECT query
        ref_columns: list[str] = pk_columns.copy()
        lob_index: int = len(pk_columns)
        sel_stmt: str = f"SELECT {', '.join(pk_columns)}"
        if ref_column and ref_column not in pk_columns:
            sel_stmt += f", {ref_column}"
            lob_index += 1
            ref_columns.append(ref_column)
        sel_stmt += f", {lob_column} FROM {table}"
        if where_clause:
            sel_stmt += f" WHERE {where_clause}"
        if not orderby_clause and (offset_count or batch_size):
            orderby_clause = ", ".join(pk_columns)
        if orderby_clause:
            sel_stmt += f" ORDER BY {orderby_clause}"

        if offset_count or limit_count > batch_size:
            sel_stmt += " OFFSET @offset ROWS"
        if limit_count or batch_size:
            if engine == DbEngine.POSTGRES:
                sel_stmt += " LIMIT @limit"
            elif engine == DbEngine.ORACLE:
                sel_stmt += " FETCH @limit ROWS ONLY"
            elif engine == DbEngine.SQLSERVER:
                sel_stmt = sel_stmt.replace("SELECT ", "SELECT TOP @limit ", 1)

        # log the migration start
        if logger:
            logger.debug(msg=f"Started streaming LOBs "
                             f"from {engine}.{table}.{lob_column}")
        # stream the LOBs
        log_step: int = 0
        lob_count: int = 0
        err_msg: str | None = None
        try:
            source_cursor: Any = curr_conn.cursor()

            # adjust the offset and limit
            curr_stmt: str = sel_stmt
            curr_limit: int = min(batch_size, limit_count)
            if curr_limit == 0:
                curr_limit = max(batch_size, limit_count)
            if offset_count:
                curr_stmt = curr_stmt.replace("@offset", str(offset_count), 1)\
                                     .replace("@limit", str(curr_limit), 1)\
                                     .replace(" FETCH ", " FETCH NEXT ", 1)
            else:
                curr_stmt = curr_stmt.replace(" OFFSET @offset ROWS", "", 1)\
                                     .replace("@limit", str(curr_limit), 1)\
                                     .replace(" FETCH ", " FETCH FIRST ", 1)
            # go for the data
            next_rs: bool = True
            while next_rs:
                next_rs = False

                # execute the query
                # (parameter name is 'statement' for oracle, 'query' for postgres, 'sql' for sqlserver)
                source_cursor.execute(curr_stmt)

                # traverse the result set
                row: tuple = source_cursor.fetchone()
                while row:
                    next_rs = True

                    # retrieve the values of the primary key and reference columns (leave LOB column out)
                    ref_vals: tuple = tuple([row[inx] for inx in range(lob_index)])
                    identifier: dict[str, Any] = {}
                    for inx, pk_val in enumerate(ref_vals):
                        identifier[ref_columns[inx]] = pk_val
                    # send the LOB's metadata
                    yield identifier

                    # access the LOB's bytes in chunks and stream them
                    offset: int = 1
                    has_data: bool = accept_empty
                    lob: Any = row[lob_index]
                    lob_data: bytes | str = lob.read(offset=offset,
                                                     amount=chunk_size) if lob else None
                    while lob_data:
                        # send a data chunk
                        yield lob_data
                        size: int = len(lob_data)
                        has_data = True
                        # read the next chunk
                        offset += size
                        lob_data = lob.read(offset=offset,
                                            amount=chunk_size)
                    if has_data:
                        # increment the LOB migration counter, if applicable
                        lob_count += 1
                        log_step += 1

                    # signal that sending data chunks for the current LOB is finished
                    yield None

                    # log partial result at each 'log_trigger' LOBs migrated
                    if logger and log_step >= log_trigger:
                        logger.debug(msg=f"Streamed {lob_count} LOBs "
                                         f"from {engine}.{table}.{lob_column}")
                        log_step = 0

                    # retrieve the next row
                    row = source_cursor.fetchone()

                # adjust the new offset and limit
                if next_rs and (limit_count > lob_count or (batch_size and not limit_count)):
                    curr_limit = min(batch_size, limit_count - lob_count)
                    if curr_limit <= 0:
                        curr_limit = max(batch_size, limit_count - lob_count)
                    curr_stmt = sel_stmt.replace("@offset", str(offset_count + lob_count), 1)\
                                        .replace("@limit", str(curr_limit), 1)\
                                        .replace(" FETCH ", " FETCH NEXT ", 1)
                else:
                    # signal end of migration
                    next_rs = False

            # close the cursors and commit the transactions
            source_cursor.close()
            if committable or not connection:
                curr_conn.commit()
        except Exception as e:
            # rollback the transactions
            if curr_conn:
                with suppress(Exception):
                    curr_conn.rollback()
            lob_count = 0
            err_msg = _except_msg(exception=e,
                                  engine=engine)
        finally:
            # close the connections, if locally acquired
            if curr_conn and not connection:
                curr_conn.close()

        # log the stream finish
        if err_msg:
            op_errors.append(err_msg)
            if logger:
                logger.error(msg=err_msg)
        elif logger:
            logger.debug(msg=f"Finished streaming {lob_count} LOBs "
                             f"from {engine}.{table}.{lob_column}")

    # acknowledge local errors
    if isinstance(errors, list):
        errors.extend(op_errors)


def __normalize_value(value: int) -> int:
    """
    Normalize *size* to acceptable values.

    :param value: the value to normalized
    :return: the normalized value
    """
    if isinstance(value, int) and \
            not isinstance(value, bool) and value > 0:
        result: int = value
    else:
        result: int = 0

    return result
