from logging import Logger
from typing import Any

from .db_common import (
    _bind_columns, _bind_marks, _log, _except_msg, _remove_nulls
)
from .db_pomes import (
    db_connect, db_bulk_delete, db_bulk_insert, db_bulk_update
)


def db_sync_data(errors: list[str] | None,
                 source_engine: str,
                 source_table: str,
                 target_engine: str,
                 target_table: str,
                 sync_columns: list[str],
                 pk_columns: list[str],
                 source_conn: Any = None,
                 target_conn: Any = None,
                 source_committable: bool = None,
                 target_committable: bool = None,
                 identity_column: str = None,
                 batch_size: int = None,
                 has_nulls: bool = None,
                 logger: Logger = None) -> tuple[int, int, 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 with values generated directly
    by the database), and thus they must be identified by *identity_column*, and ommited from *sync_columns*,
    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* 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 origin table
    :param target_engine: the destination database engine type
    :param target_table: the possibly schema-qualified table to write the data to
    :param sync_columns: the columns to be synchronized
    :param pk_columns: columns making up a primary key, or a unique identifier for a tuple in both tables
    :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 identity_column: column whose values are generated by the database
    :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 logger: optional logger
    :return: the number of tuples effectively deleted, inserted, and updated in the target table
    """
    # 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] = (0, 0, 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:
        # buid the SELECT query
        all_cols: str = ", ".join(pk_columns + sync_columns)
        sel_stmt: str = (f"SELECT {all_cols} FROM {source_table} "
                         f"ORDER BY {', '.join(pk_columns)}")
        if batch_size:
            if source_engine == "postgres":
                sel_stmt += f" OFFSET @offset ROWS LIMIT {batch_size}"
            elif source_engine in ["oracle", "slqserver"]:
                sel_stmt += f" OFFSET @offset ROWS FETCH NEXT {batch_size} ROWS ONLY"
        rows_for_delete: list[tuple] = []
        rows_for_insert: list[tuple] = []
        rows_for_update: list[tuple] = []

        # log the synchronization start
        _log(logger=logger,
             engine=source_engine,
             stmt=(f"Started synchronizing data, "
                      f"from {source_engine}.{source_table} "
                      f"to {target_engine}.{target_table}"))

        # migrate the data
        err_msg: str | None = None
        log_count: int = 0
        log_step: int = 10000
        try:
            # obtain the cursors
            source_cursor: Any = curr_source_conn.cursor()
            target_cursor: Any = curr_target_conn.cursor()

            # execute the queries
            source_cursor.execute(statement=sel_stmt.replace("OFFSET @offset ROWS ", ""))
            source_rows: list[tuple] = source_cursor.fetchall()
            if has_nulls and target_engine == "postges":
                source_rows = _remove_nulls(rows=source_rows)
            source_offset: int = len(source_rows)
            target_cursor.execute(statement=sel_stmt.replace("OFFSET @offset ROWS ", ""))
            target_rows: list[tuple] = target_cursor.fetchall()
            target_offset: int = len(target_rows)

            # 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:
                log_count += source_offset
                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:
                    source_data: tuple = get_sync_data(row=source_row,
                                                       pk_cols=pk_columns,
                                                       sync_cols=sync_columns)
                    target_data: tuple = get_sync_data(row=source_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:
                    source_cursor.execute(statement=sel_stmt.replace("@offset", str(source_offset)))
                    source_rows: list[tuple] = source_cursor.fetchall()
                    if source_rows:
                        source_offset += len(source_rows)
                        if has_nulls and target_engine == "postges":
                            source_rows = _remove_nulls(rows=source_rows)
                    else:
                        # no more tuples in source table
                        source_offset = 0

                if target_offset and not target_rows:
                    target_cursor.execute(statement=sel_stmt.replace("@offset", str(target_offset)))
                    target_rows: list[tuple] = target_cursor.fetchall()
                    if target_rows:
                        target_offset += len(target_rows)
                    else:
                        # no more tuples in target table
                        target_offset = 0

                # log partial result
                if log_count % log_step == 0:
                    _log(logger=logger,
                         engine=source_engine,
                         stmt=(f"Synchronizing {log_count} tuples, "
                                  f"from {source_engine}.{source_table} "
                                  f"to {target_engine}.{target_table}"))
            # close the cursors
            source_cursor.close()
            target_cursor.close()

            # process the rows flagged for insert
            if rows_for_insert:
                # build the INSERT query
                if target_engine == "postgres":
                    values: str = "VALUES %s"
                else:
                    bind_marks: str = _bind_marks(engine=target_engine,
                                                  start=1,
                                                  finish=len(all_cols)+1)
                    values: str = f"VALUES({bind_marks})"
                insert_stmt: str = (f"INSERT INTO {target_table} "
                                    f"({all_cols}) {values}")
                # insert the rows
                db_bulk_insert(errors=op_errors,
                               insert_stmt=insert_stmt,
                               insert_vals=rows_for_insert,
                               engine=target_engine,
                               connection=curr_target_conn,
                               committable=False,
                               identity_column=identity_column,
                               logger=logger)

            # process the rows flagged for delete
            if not op_errors and rows_for_delete:
                # build the DELETE query
                where_cols: str = _bind_columns(engine=target_engine,
                                                columns = pk_columns,
                                                concat=" AND ",
                                                start_index=1)
                delete_stmt: str = (f"DELETE FROM {target_table} "
                                    f"WHERE {where_cols}")
                # delete the rows
                db_bulk_delete(errors=op_errors,
                               delete_stmt=delete_stmt,
                               where_vals=rows_for_delete,
                               engine=target_engine,
                               connection=curr_target_conn,
                               committable=False,
                               logger=logger)

            # process the rows flagged for update
            if not op_errors and rows_for_update:
                # build the UPDATE query
                value_cols: str = _bind_columns(engine=target_engine,
                                                columns = sync_columns,
                                                concat=", ",
                                                start_index=1)
                where_cols: str = _bind_columns(engine=target_engine,
                                                columns = pk_columns,
                                                concat=", ",
                                                start_index=len(sync_columns)+1)
                update_stmt: str = (f"UPDATE {target_table} "
                                    f"SET {value_cols} "
                                    f"WHERE {where_cols}")
                # update the rows
                db_bulk_update(errors=op_errors,
                               update_stmt=update_stmt,
                               update_vals=rows_for_update,
                               engine=target_engine,
                               connection=curr_target_conn,
                               committable=False,
                               logger=logger)

            if op_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 curr_source_conn:
                curr_source_conn.rollback()
            if curr_target_conn:
                curr_target_conn.rollback()
            log_count = 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 synchronization finish
        _log(logger=logger,
             engine=source_engine,
             err_msg=err_msg,
             errors=op_errors,
             stmt=(f"Synchronized {log_count} 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
