# -*- coding: utf-8 -*-

import base64
import click
import logging
import oracledb
import re
import subprocess
import sys
import textwrap
import time

from concurrent.futures import ThreadPoolExecutor, as_completed
from configparser import RawConfigParser
from pathlib import Path
from threading import Lock

CURRENT_TOOLS_DIR = Path(__file__).parent
DEFAULT_CONFIG_FILE = CURRENT_TOOLS_DIR / 'PyExporter4.ini'
DEFAULT_STORES_FILE = CURRENT_TOOLS_DIR / 'PyExporter4.csv'

lock = Lock()


def enable_logging():
    logging.basicConfig(filename='PyExporter4.log',
                        format='%(asctime)s %(levelname)7s: %(message)s ',
                        level=logging.INFO)


def balanced_split(l, n):
    """
    Yield n number of balanced striped chunks of collection l
    """
    for i in range(0, n):
        yield l[i::n]


def decode_base64(encoded_text):
    return base64.b64decode(encoded_text).decode(encoding='ascii')


def remove_password_from_oracle_url(oracle_url: str):
    return re.sub(r'(\w+)(\/.+)(@\w+[:\/\.\w]+)', '\g<1>\g<3>', oracle_url)


def worker(command):
    """
    Invoke specified shell command as separate subprocess
    """
    logging.debug('Invoking within thread: {}'.format(' '.join(command)))

    exporter_task = subprocess.Popen(command)

    with lock:
        logging.debug('Worker thread started: {}'.format(command[-1]))

    rc = exporter_task.wait()

    logging.debug('Worker thread finished: {}'.format(command[-1]))

    if rc != 0:
        logging.error('Worker thread exited with code: {}'.format(rc))

        logging.error('Failed to export data to stores: {}'.format(command[-1]))

    return rc == 0


class Configuration:
    """
    Values from the .ini configuration file, stores file, and database
    """

    def __init__(self):
        self.max_threads = 1
        self.exporter_executable = None
        self.stores_sql = None
        self.database_hash = None
        self.database_use_direct_mode = False
        self.retry_attempts = 0
        self.retry_interval_in_seconds = 0
        self.stores = list()


def _read_configuration_file(config_file):
    logging.debug('Reading exporter configuration from file: {}'
                  .format(config_file))
    config_data = config_file.read_text()
    parser = RawConfigParser(allow_no_value=True)
    parser.read_string(config_data)

    configuration = Configuration()
    configuration.max_threads = int(parser.get('Main', 'Threads'))
    configuration.exporter_executable = parser.get('Main', 'PathToExporter.exe')
    configuration.stores_sql = parser.get('Main', 'SQL')
    configuration.database_hash = parser.get('Main', 'Database')
    configuration.database_use_direct_mode = parser.getboolean('Main', 'Database.UseDirectMode', fallback=False)
    configuration.retry_attempts = int(parser.get('Main', 'RetryAttempts'))
    configuration.retry_interval_in_seconds = int(parser.get('Main', 'RetryIntervalInSeconds'))

    return configuration


def _read_stores_file(stores_file):
    """
    Read store numbers from file and ignore empty newlines
    """
    with open(stores_file.as_posix()) as f:
        stores = [i for i in f.read().splitlines() if i]
    return stores


def _execute_oracle_query(sql, user_hash):
    try:
        connection_string = decode_base64(user_hash)
        con = oracledb.connect(connection_string)
        cur = con.cursor()

        logging.debug('Executing SQL Query: {}'
                      .format(sql))

        cur.execute(sql)

        return cur.fetchall()

    except oracledb.Error as ex:
        message = ('Failed to connect to the database. '
                   'Check the connection parameters and retry: {}'.format(ex))
        logging.error(message)
        raise ExportCriticalError(message)


class ExportCriticalError(Exception):
    pass


class ExportManager:
    """
    Reads the configuration files and handles multi-threaded exports to stores
    """

    class _State:
        """
        Current state of the exporter, volatile values used at runtime
        """

        def __init__(self):
            self.stores_to_export = list()
            self.retry_attempts = 0

    def __init__(self,
                 config_file=DEFAULT_CONFIG_FILE,
                 stores_file=DEFAULT_STORES_FILE):
        """
        Loads .ini configuration file
        Reads target store numbers list from file or database if it's missing
        """
        logging.debug('Initializing ExportManager instance')

        config_file = config_file or DEFAULT_CONFIG_FILE
        self.configuration = _read_configuration_file(config_file)

        stores_file = stores_file or DEFAULT_STORES_FILE
        self.configuration.stores = self._get_stores(stores_file)

        logging.debug('ExportManager has been initialized')

    def _get_stores(self, stores_file):
        if stores_file.exists():
            logging.info('Reading store numbers list from file: {}'
                         .format(stores_file))
            return _read_stores_file(stores_file)
        else:
            logging.debug('Specified store numbers list file {} does not exist'
                          .format(stores_file))
            logging.info('Reading store numbers list from database: {user}'
                         .format(user=self.configuration.database_hash))

            stores = _execute_oracle_query(self.configuration.stores_sql,
                                           self.configuration.database_hash)

            return [str(item) for t in stores for item in t]

    def _get_num_worker_threads(self, threads=None):
        """
        Get maximum thread count that does not exceed the number of stores
        """
        num_threads = threads or self.configuration.max_threads

        if num_threads > len(self.configuration.stores):
            num_threads = len(self.configuration.stores)

        return num_threads

    def _get_worker_thread_command(self, stores):
        oracle_url = decode_base64(self.configuration.database_hash)
        cmd = [
            self.configuration.exporter_executable,
            '--url', remove_password_from_oracle_url(oracle_url),
        ]
        if self.configuration.database_use_direct_mode:
            cmd.append('--direct')
        cmd.extend([
            '-t', 'cnps',
            '-l', ','.join(stores)
        ])
        return cmd

    def _spawn_threads(self, target, state):
        """
        Assign groups of stores to threads
        Clear the list of stores (threads will return the elements to it)
        Wait for all threads to finish
        """
        num_remaining_tasks = len(state.stores_to_export)

        if num_remaining_tasks == 0:
            logging.warn('No stores found to export.')
            return

        with ThreadPoolExecutor(max_workers=num_remaining_tasks) as executor:
            futures = {executor.submit(target,
                                       self._get_worker_thread_command(stores)):
                       stores for stores in state.stores_to_export}
            state.stores_to_export.clear()
            for future in as_completed(futures):
                stores = futures[future]
                success = False
                try:
                    success = future.result()
                except Exception as ex:
                    logging.error('Failed to retrieve result from thread.', ex)
                if not success:
                    state.stores_to_export.append(stores)

    def execute(self, num_threads=None, action=worker):
        """
        Distribute store export tasks evenly between n threads, then execute them.
        Handle retry attempts, if necessary.
        """
        state = self._State()
        thread_count = self._get_num_worker_threads(num_threads)

        state.stores_to_export = list(balanced_split(self.configuration.stores,
                                                     thread_count))

        logging.info('Starting execution of CNPS export '
                     'to {num_stores} stores, assigned threads: {num_threads}'
                     .format(num_stores=len(self.configuration.stores),
                             num_threads=thread_count))

        self._spawn_threads(target=action, state=state)

        for attempt in range(1, self.configuration.retry_attempts + 1):
            if not state.stores_to_export:
                break

            state.retry_attempts = attempt

            interval = self.configuration.retry_interval_in_seconds

            if interval > 0:
                logging.info('Retrying in {} seconds...'
                             .format(interval))
                time.sleep(interval)

            logging.warning('Retry attempt {num_attempt}: Restarting tasks, '
                            'affected threads: {num_threads}'
                            .format(num_attempt=attempt,
                                    num_threads=len(state.stores_to_export)))

            self._spawn_threads(target=action, state=state)

        if not state.stores_to_export:
            logging.info('Exporter threads have successfully '
                         'finished execution. All stores have been exported ')
        else:
            failed_stores = ','.join([i for lst in state.stores_to_export for i in lst])
            message = ('Exporter encountered an error and could not recover '
                       'from it in {attempts} consecutive retry attempts. '
                       'Affected stores: {failed_stores} '
                       .format(attempts=state.retry_attempts,
                               failed_stores=failed_stores))
            logging.error(message)
            raise ExportCriticalError(message)

        return state

    def validate(self):
        """
        Ensure that all stores have recorded exports in the last 24 hours
        """
        stores_sql = textwrap.dedent("""
            select
                f.skrnazwa store_number
                , coalesce(data.exports_in_24h, 0) exports_in_24h
            from
            (
                select
                    e.ldofirmaref store_id
                    , count(1) exports_in_24h
                from export e
                where styp = 'CNPS'
                    and e.dczasexportu > borlandsysdate - 1
                group by e.ldofirmaref
            ) data, firma f
            where f.lfirmaref = data.store_id(+)
                and bytypfirmy = 0 and bystatusaktywnosci = 0
""")
        result_stores = _execute_oracle_query(stores_sql,
                                              self.configuration.database_hash)
        stores_with_failed_exports = list()

        for store_number, num_exports in result_stores:
            if num_exports > 0:
                continue

            if store_number in self.configuration.stores:
                logging.error('Validation: Store {number} has no recorded CNPS exports '
                              'in the last 24 hours, and will require manual action.'
                              .format(number=store_number))
                stores_with_failed_exports.append(store_number)
            else:
                logging.warning('Validation: Store {number} has no recorded CNPS exports '
                                'in the last 24 hours but the exporter was not tasked to export to it.'
                                .format(number=store_number))

        if stores_with_failed_exports:
            raise ExportCriticalError('Validation failed - missing exports. '
                                      'Manual action is required for stores: {}'
                                      .format(', '.join(stores_with_failed_exports)))


@click.command()
@click.option('-c', '--config',
              type=click.Path(),
              help='A file containing the exporter configuration. '
                   'PyExporter4.ini will be used if this option is omitted.')
@click.option('-s', '--stores',
              type=click.Path(),
              help='A file containing the list of all stores. '
                   'Config values will be used if this option is omitted.')
@click.option('-t', '--threads',
              type=int,
              help='Maximum number of worker threads. '
                   'Config values will be used if this option is omitted.')
@click.option('-n', '--novalidate', 'validate',
              is_flag=True,
              flag_value=False,
              default=True,
              help='Disables the post-export validation of the database contents.')
@click.option('-q', '--quiet',
              is_flag=True,
              help='Disables the logging completely, including logging to file.')
@click.option('-d', '--debug',
              is_flag=True,
              help='Explicitly sets logging level to DEBUG.')
def main(config, stores, threads, validate, quiet, debug):
    if not quiet:
        enable_logging()

    logging.info('PyExporterME v4.0 (market2@forcom.com.pl)')

    config = Path(config) if config else DEFAULT_CONFIG_FILE
    stores = Path(stores) if stores else DEFAULT_STORES_FILE

    logging.getLogger().level = logging.DEBUG if debug else logging.INFO

    manager = ExportManager(config_file=config, stores_file=stores)

    manager.execute(num_threads=threads)

    if validate:
        manager.validate()


if __name__ == '__main__':
    try:
        main()
    except ExportCriticalError as ex:
        sys.stderr.write(str(ex))
