import logging
import os
import pickle
import threading
from concurrent.futures import (
    Future,
    ThreadPoolExecutor,
)
from datetime import (
    date,
    datetime,
    timedelta,
)
from threading import current_thread
from typing import (
    List,
    Optional,
    Tuple,
)

from ._Boto3S3StorageService import Boto3S3StorageService
from ._DatalakeConfig import DatalakeConfig
from ._DebugOptions import DebugOptions
from ._GoldOutboxOrderPictureService import (
    GoldOutboxOrderPictureService,
    OutboxRead,
)
from ._GoldProcessedDaysRepository import GoldProcessedDaysRepository
from ._NonFatalError import NonFatalError
from ._OrdersBusinessDateProcessor import OrdersBusinessDateProcessor

logger = logging.getLogger(__name__)


class DatalakeByBusinessDateProcessor(object):
    def __init__(
        self,
        s3_client,
        datalake_config: DatalakeConfig,
        max_execution_time_in_seconds: int,
        days_before_today_to_process: int,
        num_threads: int,
        orders_business_date_processor: OrdersBusinessDateProcessor,
        debug_options: DebugOptions = None,
    ):
        self.max_execution_time_in_seconds = max_execution_time_in_seconds
        self.days_before_today_to_process = days_before_today_to_process
        self.num_threads = num_threads
        self.debug_options = debug_options or DebugOptions()
        self.datalake_config = datalake_config

        self.storage_service = Boto3S3StorageService(
            s3_client=s3_client,
            bucket_name=datalake_config.bucket_name,
        )
        self.gold_outbox_service = GoldOutboxOrderPictureService(
            storage_service=self.storage_service,
            datalake_config=datalake_config,
        )
        self.processed_days = GoldProcessedDaysRepository(
            s3_client=s3_client,
            datalake_config=datalake_config,
            gold_outbox_service=self.gold_outbox_service,
        )

        self.start_time = datetime.now()

        current_context = getattr(threading.current_thread(), "_edp_context_name", None)

        def _context_name_initializer():
            if current_context is not None:
                setattr(threading.current_thread(), "_edp_context_name", current_context)

        self.thread_pool = ThreadPoolExecutor(
            max_workers=num_threads,
            thread_name_prefix=f"{current_thread().name}$DatalakeByBusinessDateProcessorThread",
            initializer=_context_name_initializer,
        )

        self.orders_business_date_processor = orders_business_date_processor

    def process_all(self):
        try:
            if self.debug_options.single_target is not None:
                target = self.debug_options.single_target
                logger.info(
                    f"Single target active. Processing [{target.business_date}] of store [{target.store_code}]"
                )
                self._process_business_day_of_store(
                    store_code=target.store_code,
                    business_date=target.business_date,
                )
                return

            active_store_codes = self.processed_days.get_active_store_codes()

            logger.info("Processing latest days of active stores")
            self._process_latest_business_days(active_store_codes=active_store_codes)

            if self.debug_options.do_not_process_retransmissions:
                logger.info("Not processing retransmissions due to debug option")
                return

            logger.info("Processing retransmissions")
            self._process_retransmissions(active_store_codes=active_store_codes)

        except DatalakeByBusinessDateProcessor.ProcessTimeout:
            logger.info("Timed out, finishing")
            return
        finally:
            self.thread_pool.shutdown(
                wait=True,
                cancel_futures=True,
            )
            self.gold_outbox_service.close()

        logger.info("All stores and missing days processed, finishing")

    def _process_latest_business_days(self, active_store_codes: List[str]):
        results: List[Tuple[str, Future]] = []
        for store_code in active_store_codes:
            results.append(
                (
                    store_code,
                    self.thread_pool.submit(self._process_store, store_code),
                )
            )

        failed_stores = []
        timeout_stores = []
        for store_code, future in results:
            try:
                future.result()
            except DatalakeByBusinessDateProcessor.ProcessTimeout:
                timeout_stores.append(store_code)
            except (Exception,):
                failed_stores.append(store_code)
                logger.exception(f"Error processing latest business days of [{store_code}]")

        if failed_stores:
            raise RuntimeError(f"The following stores failed to process latest business days: {failed_stores}")

        if timeout_stores:
            raise DatalakeByBusinessDateProcessor.ProcessTimeout()

    def _process_store(self, store_code):
        self._check_timeout()

        logger.info(f"Processing latest business days of store {store_code}")

        last_business_dt = self._get_last_business_date_of_store(store_code)
        if last_business_dt is None:
            logger.warning("Last business date not found, skipping")
            return

        yesterday = date.today() - timedelta(days=self.days_before_today_to_process)
        while last_business_dt < yesterday:
            last_business_dt += timedelta(days=1)
            logger.info(f"Processing business date {last_business_dt}")

            self._process_business_day_of_store(
                store_code=store_code,
                business_date=last_business_dt,
            )

            if not self.debug_options.do_not_update_last_processed:
                self.processed_days.set_last_processed_day(
                    store_code=store_code,
                    last_processed_day=last_business_dt,
                )

            logger.info(f"Business date {last_business_dt} successfully processed")

        logger.info(f"Successfully processed latest business days of {store_code}")

    def _process_business_day_of_store(
        self,
        store_code: str,
        business_date: date,
    ):
        self._check_timeout()

        outbox_read: Optional[OutboxRead] = None
        if self.debug_options.enable_local_cache:
            cache_dir = os.path.join("file_cache")
            cache_file_name = f"{store_code}_{business_date.isoformat()}.pickle"
            full_cache_file_name = os.path.join(cache_dir, cache_file_name)
            if os.path.exists(full_cache_file_name):
                with open(full_cache_file_name, "rb") as f:
                    outbox_read = pickle.load(f)

        if outbox_read is None:
            outbox_read = self.gold_outbox_service.get_orders_of_store_and_day(
                store_code=store_code,
                business_date=business_date,
            )

            if self.debug_options.enable_local_cache:
                cache_dir = os.path.join("file_cache")
                if not os.path.exists(cache_dir):
                    os.makedirs(cache_dir)

                cache_file_name = f"{store_code}_{business_date.isoformat()}.pickle"
                full_cache_file_name = os.path.join(cache_dir, cache_file_name)
                with open(full_cache_file_name, "wb") as f:
                    pickle.dump(outbox_read, f)

        try:
            self.orders_business_date_processor.process_business_day(
                store_code=store_code,
                business_date=business_date,
                orders=outbox_read.orders,
            )
        except NonFatalError as ex:
            msg = f"Non fatal error processing [{store_code}] - [{business_date}]: {ex.message}"
            logger.warning(msg, exc_info=True)
            return

        if not self.debug_options.do_not_delete_files:
            self.gold_outbox_service.delete_consumed_files(paths=outbox_read.consumed_paths)

    def _get_last_business_date_of_store(self, store_code: str) -> Optional[date]:
        last_business_dt = self.processed_days.get_last_processed_day(store_code=store_code)
        if last_business_dt is not None:
            return last_business_dt

        first_sale = self.gold_outbox_service.get_first_of_store(store_code=store_code)
        if first_sale is None:
            return None

        return first_sale - timedelta(days=1)

    def _process_retransmissions(self, active_store_codes: List[str]):
        results: List[Tuple[str, Future]] = []
        for store_code in active_store_codes:
            results.append(
                (
                    store_code,
                    self.thread_pool.submit(self._process_retransmission, store_code),
                )
            )

        failed_stores = []
        timeout_stores = []
        for store_code, future in results:
            try:
                future.result()
            except DatalakeByBusinessDateProcessor.ProcessTimeout:
                timeout_stores.append(store_code)
            except (Exception,):
                failed_stores.append(store_code)
                logger.exception(f"Error processing retransmission of [{store_code}]")

        if failed_stores:
            raise RuntimeError(f"The following stores failed to process retransmissions: {failed_stores}")

        if timeout_stores:
            raise DatalakeByBusinessDateProcessor.ProcessTimeout()

    def _process_retransmission(self, store_code: str):
        logger.info(f"Processing retransmissions of {store_code}")
        first_of_store = self.gold_outbox_service.get_first_of_store(store_code=store_code)
        if first_of_store is None:
            logger.info("No retransmissions for store")
            return

        logger.info(f"First retransmission date to reprocess {first_of_store}")
        yesterday = date.today() - timedelta(days=1)
        while first_of_store is not None and first_of_store < yesterday:
            self._process_business_day_of_store(
                store_code=store_code,
                business_date=first_of_store,
            )
            first_of_store = self.gold_outbox_service.get_first_of_store(store_code=store_code)
            logger.info(f"Next retransmission date to reprocess {first_of_store}")

    def _check_timeout(self):
        if (datetime.now() - self.start_time).total_seconds() > self.max_execution_time_in_seconds:
            raise DatalakeByBusinessDateProcessor.ProcessTimeout()

    class ProcessTimeout(Exception):
        pass
