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

from .db_common import (
    _DB_LOGGERS, DB_BIND_META_TAG, DbEngine,
    _assert_engine, _except_msg, _remove_ctrlchars
)
from .db_pomes import (
    db_connect, db_select, db_count, db_close,
    db_bulk_delete, db_bulk_insert, db_bulk_update
)


# ruff: noqa: PLR0915 - too many statements
def db_sync_data(source_engine: DbEngine,
                 source_table: str,
                 target_engine: DbEngine,
                 target_table: str,
                 pk_columns: list[str],
                 sync_columns: list[str],
                 identity_column: str = None,
                 ignore_updates: bool = None,
                 offset_count: int = None,
                 limit_count: int = None,
                 batch_size: int = None,
                 has_nulls: bool = None,
                 source_conn: Any = None,
                 source_committable: bool = None,
                 target_conn: Any = None,
                 target_committable: bool = None,
                 errors: list[str] = None,
                 log_trigger: int = 10000) -> tuple[int, int, int] | None:
    """
    Synchronize data in *target_table*, as per the contents of *source_table*.

    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*
    (binary large object), and must be of respectively equivalent types, in both the origin
    and the destination databases. Specifying a list of one or more columns in *pk_columns*
    that uniquely identify the tuples is required.

    If *ignore_updates* is set to *True*, then the synchronization is limited to *INSERT* and *DELETE*
    operations, and content differences in non-PK attributes are ignored.

    Specific handling is required for identity columns (i.e., columns with values generated
    directly by the database - 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 parameters *source_committable* and *target_committable* define whether a commit or rollback
    is performed on the respective connections, if provided.

    :param source_engine: the source database engine
    :param source_table: the possibly schema-qualified table to read the data from
    :param target_engine: the destination database engine
    :param target_table: the possibly schema-qualified table to write the data to
    :param pk_columns: primary key columns, or a unique identifier for a tuple in both tables
    :param sync_columns: the columns to be synchronized (must not include PK columns)
    :param identity_column: column whose values are generated by the database
    :param ignore_updates: whether to ignore tuple updates (defaults to synchronizing with updates)
    :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: the maximum number of tuples to synchronize in each batch, or 0 or *None* for no limit
    :param has_nulls: indicates that one or more string-type source columns have nulls
    :param source_conn: the connection to the source database (obtains a new one, if not provided)
    :param source_committable: whether to commit or rollback on *source_conn*, upon completion
    :param target_conn: the connection to the destination database (obtains a new one, if not provided)
    :param target_committable: whether to commit or rollback on *target_conn*, upon completion
    :param errors: incidental error messages (might be a non-empty list)
    :param log_trigger: number of LOBs to trigger logging info on migration (defaults to 10000 LOBs)
    :return: the number of tuples effectively deleted, inserted, and updated in the target table, or *None* if error
    """
    # helper function to obtain a primary key tuple from a row
    def get_pk_data(row: tuple, pk_cols: list[str]) -> tuple:
        return tuple(row[pos] for pos in range(len(pk_cols)))

    # helper function to obtain a sync data tuple from a row
    def get_sync_data(row: tuple, pk_cols: list[str], sync_cols: list[str]) -> tuple:
        return tuple(row[pos] for pos in range(len(pk_cols), len(pk_cols) + len(sync_cols)))

    # initialize the return variable
    result: tuple[int, int, int] | None = None

    # necessary, lest the state of 'errors' be tested
    curr_errors: list[str] = []

    # assert the database engines
    source_engine = _assert_engine(engine=source_engine,
                                   errors=curr_errors)
    target_engine = _assert_engine(engine=target_engine,
                                   errors=curr_errors)
    # obtain the logger
    logger: Logger = _DB_LOGGERS[source_engine] or _DB_LOGGERS[target_engine]

    from_table: str = f"{source_engine}.{source_table}"
    to_table: str = f"{target_engine}.{target_table}"

    curr_source_conn: Any = None
    curr_target_conn: Any = None
    if pk_columns:
        # make sure to have connections to the source and destination databases
        curr_source_conn = source_conn or db_connect(engine=source_engine,
                                                     errors=curr_errors)
        curr_target_conn = target_conn or db_connect(engine=target_engine,
                                                     errors=curr_errors)
    else:
        err_msg: str = (f"Cannot synchronize tables {source_engine}.{from_table} "
                        f"and {target_engine}{to_table}: "
                        f"no columns to uniquely identify tuples specified")
        if logger:
            logger.error(msg=err_msg)
        curr_errors.append(err_msg)

    if curr_source_conn and curr_target_conn:
        # normalize these parameters
        offset_count = __normalize_value(value=offset_count)
        limit_count = __normalize_value(value=limit_count)
        batch_size = __normalize_value(value=batch_size)

        # buid the SELECT query
        source_cols: str = ", ".join(pk_columns + sync_columns)
        source_sel_stmt: str = (f"SELECT {source_cols} FROM {source_table} "
                                f"ORDER BY {', '.join(pk_columns)}")
        target_cols: str = ", ".join(pk_columns) if ignore_updates else source_cols
        target_sel_stmt: str = (f"SELECT {target_cols} FROM {target_table} "
                                f"ORDER BY {', '.join(pk_columns)}")
        if batch_size or offset_count:
            source_sel_stmt += " OFFSET @offset ROWS"
            target_sel_stmt += " OFFSET @offset ROWS"
        if batch_size or limit_count:
            if source_engine == DbEngine.POSTGRES:
                source_sel_stmt += " LIMIT @limit"
            elif source_engine in [DbEngine.ORACLE, DbEngine.SQLSERVER]:
                source_sel_stmt += " FETCH NEXT @limit ROWS ONLY"
            if target_engine == DbEngine.POSTGRES:
                target_sel_stmt += " LIMIT @limit"
            elif target_engine in [DbEngine.ORACLE, DbEngine.SQLSERVER]:
                target_sel_stmt += " FETCH NEXT @limit ROWS ONLY"

        rows_for_delete: list[tuple] = []
        rows_for_insert: list[tuple] = []
        # not used if 'ignore_updates' is set
        rows_for_update: list[tuple] = []

        # log the synchronization start
        if logger:
            logger.debug(msg=f"Started synchronizing data, "
                             f"from {source_engine}.{from_table}, connection {id(curr_source_conn)}, "
                             f"to {target_engine}.{to_table}, connection {id(curr_target_conn)}")
        # set migration vars
        sel_stmt: str
        log_count: int = 0
        log_step: int = 0
        err_msg: str | None = None

        # process the offsets
        source_offset: int = 0
        target_offset: int = 0
        if offset_count > 0:
            # obtain the source PK values at the given offset
            pk_cols: str = ", ".join(pk_columns)
            source_vals: list[tuple] = db_select(sel_stmt=f"SELECT {pk_cols} FROM {source_table} ",
                                                 orderby_clause=pk_cols,
                                                 offset_count=offset_count - 1,
                                                 engine=source_engine,
                                                 connection=source_conn,
                                                 committable=source_committable if source_conn else True,
                                                 errors=curr_errors)
            if source_vals:
                source_offset = offset_count
                # obtain the target offset for the source PK values
                where_clause = __build_where_count(engine=target_engine,
                                                   pk_columns=pk_columns)
                target_offset = db_count(table=source_table,
                                         where_clause=where_clause,
                                         where_vals=tuple(source_vals[0]),
                                         engine=target_engine,
                                         connection=target_conn,
                                         committable=target_committable if target_conn else True,
                                         errors=curr_errors)
        if not curr_errors:
            # establish the initial limit
            curr_limit: int = min(batch_size, limit_count) or batch_size or limit_count

            try:
                # obtain the cursors
                source_cursor: Any = curr_source_conn.cursor()
                target_cursor: Any = curr_target_conn.cursor()

                # execute the queries
                if source_offset:
                    sel_stmt = source_sel_stmt.replace("@offset", str(source_offset))
                else:
                    sel_stmt = source_sel_stmt.replace("OFFSET @offset ROWS ", "")
                if curr_limit:
                    sel_stmt = sel_stmt.replace("@limit", str(curr_limit))
                else:
                    sel_stmt = sel_stmt.replace("LIMIT @limit ", "")
                # parameter: 'statement' (Oracle), 'query' (PostgreSQL), 'sql' (SQLServer)
                source_cursor.execute(sel_stmt)
                source_rows: list[tuple] = source_cursor.fetchall()
                source_count: int = len(source_rows)
                source_offset += source_count
                log_count = source_count
                total_source: int = source_count
                # log query result
                if logger:
                    logger.debug(msg=f"Read {source_count} tuples "
                                     f"from {source_engine}.{from_table}, "
                                     f"offset {source_offset}, limit {curr_limit}, "
                                     f"connection {id(curr_source_conn)}")
                if has_nulls and target_engine == DbEngine.POSTGRES:
                    _remove_ctrlchars(rows=source_rows)

                if target_offset:
                    sel_stmt = target_sel_stmt.replace("@offset", str(target_offset))
                else:
                    sel_stmt = target_sel_stmt.replace("OFFSET @offset ROWS ", "")
                if curr_limit:
                    sel_stmt = sel_stmt.replace("@limit", str(curr_limit))
                else:
                    sel_stmt = sel_stmt.replace("LIMIT @limit ", "")
                # parameter: 'statement' (Oracle), 'query' (PostgreSQL), 'sql' (SQLServer)
                target_cursor.execute(sel_stmt)
                target_rows: list[tuple] = target_cursor.fetchall()
                target_count = len(target_rows)
                target_offset += target_count
                total_target: int = target_count
                # log query result
                if logger:
                    logger.debug(msg=f"Read {target_count} tuples "
                                     f"from {target_engine}.{target_table}, "
                                     f"offset {target_offset}, limit {curr_limit}, "
                                     f"connection {id(curr_target_conn)}")

                # traverse the result set
                source_row: tuple | None = None
                target_row: tuple | None = None
                source_pks: tuple | None = None
                target_pks: tuple | None = None
                while source_rows or target_rows:
                    if source_rows and not source_row:
                        source_row = source_rows.pop(0)
                        source_pks = get_pk_data(row=source_row,
                                                 pk_cols=pk_columns)
                    if target_rows and not target_row:
                        target_row = target_rows.pop(0)
                        target_pks = get_pk_data(row=target_row,
                                                 pk_cols=pk_columns)
                    if source_row and \
                       (not target_row or source_pks < target_pks):
                        # flag row for insertion
                        rows_for_insert.append(source_row)
                        source_row = None
                    elif target_row and \
                            (not source_row or target_pks < source_pks):
                        # flag row for deletion
                        rows_for_delete.append(target_pks)
                        target_row = None
                    else:
                        if not ignore_updates:
                            source_data: tuple = get_sync_data(row=source_row,
                                                               pk_cols=pk_columns,
                                                               sync_cols=sync_columns)
                            target_data: tuple = get_sync_data(row=target_row,
                                                               pk_cols=pk_columns,
                                                               sync_cols=sync_columns)
                            if source_data != target_data:
                                # flag row for update
                                rows_for_update.append(source_data + source_pks)
                        source_row = None
                        target_row = None

                    if source_offset and not source_rows:
                        if limit_count and total_source >= limit_count:
                            # maximum number of tuples already retrieved from source table
                            source_offset = 0
                        else:
                            sel_stmt = source_sel_stmt.replace("@offset", str(source_offset))
                            curr_limit = min(0, batch_size, limit_count - total_source) \
                                if limit_count else batch_size
                            if curr_limit:
                                sel_stmt = sel_stmt.replace("@limit", str(curr_limit))
                            else:
                                sel_stmt = sel_stmt.replace("LIMIT @limit ", "")
                            # parameter: 'statement' (Oracle), 'query' (PostgreSQL), 'sql' (SQLServer)
                            source_cursor.execute(sel_stmt)
                            source_rows = source_cursor.fetchall()
                            if source_rows:
                                source_count = len(source_rows)
                                source_offset += source_count
                                log_count += source_count
                                log_step += source_count
                                total_source += source_count
                                # log query result
                                if logger:
                                    logger.debug(msg=f"Read {source_count} tuples "
                                                     f"from {source_engine}.{from_table}, "
                                                     f"offset {source_offset}, limit {curr_limit}, "
                                                     f"connection {id(curr_source_conn)}")
                                if has_nulls and target_engine == DbEngine.POSTGRES:
                                    _remove_ctrlchars(rows=source_rows)
                            else:
                                # no more tuples in source table
                                source_offset = 0

                    if target_offset and not target_rows:
                        if limit_count and total_target >= limit_count:
                            # maximum number of tuples already retrieved from target table
                            target_offset = 0
                        else:
                            sel_stmt = target_sel_stmt.replace("@offset", str(target_offset))
                            curr_limit = min(0, batch_size, limit_count - total_target) \
                                if limit_count else batch_size
                            if curr_limit:
                                sel_stmt = sel_stmt.replace("@limit", str(curr_limit))
                            else:
                                sel_stmt = sel_stmt.replace("LIMIT @limit ", "")
                            # parameter: 'statement' (Oracle), 'query' (PostgreSQL), 'sql' (SQLServer)
                            target_cursor.execute(sel_stmt)
                            target_rows = target_cursor.fetchall()
                            if target_rows:
                                target_count = len(target_rows)
                                target_offset += target_count
                                total_target += target_count
                                # log query result
                                if logger:
                                    logger.debug(msg=f"Read {target_count} tuples "
                                                     f"from {target_engine}.{target_table}, "
                                                     f"offset {target_offset}, limit {curr_limit},"
                                                     f" connection {id(curr_target_conn)}")
                            else:
                                # no more tuples in target table
                                target_offset = 0

                    # log partial result
                    if logger and log_step >= log_trigger:
                        logger.debug(msg=f"Synchronizing {log_step} tuples, "
                                         f"from {source_engine}.{from_table}, connection {id(curr_source_conn)}, "
                                         f"to {target_engine}.{to_table}, connection {id(curr_target_conn)}")
                        log_step = 0
                # close the cursors
                source_cursor.close()
                target_cursor.close()

                # process the rows flagged for delete
                if rows_for_delete:
                    # delete the rows
                    db_bulk_delete(target_table=target_table,
                                   where_attrs=pk_columns,
                                   where_vals=rows_for_delete,
                                   engine=target_engine,
                                   connection=curr_target_conn,
                                   committable=False,
                                   errors=curr_errors)

                # process the rows flagged for insert
                if not curr_errors and rows_for_insert:
                    # insert the rows
                    db_bulk_insert(target_table=target_table,
                                   insert_attrs=pk_columns + sync_columns,
                                   insert_vals=rows_for_insert,
                                   engine=target_engine,
                                   connection=curr_target_conn,
                                   committable=target_committable if target_conn else True,
                                   identity_column=identity_column,
                                   errors=curr_errors)

                # process the rows flagged for update
                if not curr_errors and rows_for_update:
                    # update the rows
                    db_bulk_update(target_table=target_table,
                                   set_attrs=sync_columns,
                                   where_attrs=pk_columns,
                                   update_vals=rows_for_update,
                                   engine=target_engine,
                                   connection=curr_target_conn,
                                   committable=target_committable if target_conn else True,
                                   errors=curr_errors)
                if curr_errors:
                    log_count = 0
                else:
                    if source_committable or not source_conn:
                        curr_source_conn.commit()
                    if target_committable or not target_conn:
                        curr_target_conn.commit()
                    result = (len(rows_for_delete), len(rows_for_insert), len(rows_for_update))

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

        # log the synchronization finish
        if err_msg:
            curr_errors.append(err_msg)
            if logger:
                logger.error(msg=err_msg)
        elif not curr_errors and logger:
            logger.debug(msg=f"Synchronized {log_count} tuples from "
                             f"{source_engine}.{from_table} to {target_engine}.{to_table}, "
                             f"{result[0]} deleted, {result[1]} inserted, {result[2]} updated")

    if curr_errors and isinstance(errors, list):
        errors.extend(curr_errors)

    return result


def __normalize_value(value: int) -> int:
    """
    Normalize *value* to a consistent value.

    :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


def __build_where_count(engine: DbEngine,
                        pk_columns: list[str]) -> str:
    """
    Build the *WHERE* clause for the SQL count statement.

    Suppose *pk_columns* is the list *['pk1', 'pk_2'. 'pk_3']*, and the bind placeholder symbol is *%?*.
    The value returned is:
    ::
      - for SQLServer (no support for row comparisons):
        pk_1 < %?
        OR (pk_1 = %? AND pk_2 < %?)
        OR (pk_1 = %? AND pk_2 = %? AND pk_3 < %?)
      - for the other database engines (full support for row comparisons):
        (pk_1, pk_2, pk_3) < (%?, %?, %?)

    Note that, in due time, the placeholder *%?* will be replaced by the the one specific to *engine*
    (namely, *:n*, *?s*, and *?*, respectively for Oracle, PostgreSQL, and MySQL or SQLServer).

    :param engine: the database engine
    :param pk_columns: primery key columns
    :return: the *WHERE* clause for the SQL count statement
    """
    # declare the return variable
    result: str

    if engine == DbEngine.SQLSERVER:
        # SQLServer does not support row comparisons
        result = f"{pk_columns[0]} < {DB_BIND_META_TAG}"
        if len(pk_columns) > 1:
            for i in range(len(pk_columns) - 1):
                pk_prevs: list[str] = [f"{c} = {DB_BIND_META_TAG}" for c in pk_columns[:i+1]]
                result += f" OR ({' AND '.join(pk_prevs)} AND {pk_columns[i+1]} < {DB_BIND_META_TAG})"
    else:
        result = f"({', '.join(pk_columns)}) < ({', '.join([DB_BIND_META_TAG] * len(pk_columns))})"

    return result
