import decimal
import math
from collections import defaultdict
from datetime import datetime, timezone
from decimal import Decimal
from operator import itemgetter
from typing import List, Optional, Set

import pytz

from maps_adv.adv_store.v2.lib.data_managers import (
    CampaignsDataManager,
    EventsDataManager,
    ModerationDataManager,
)
from maps_adv.adv_store.v2.lib.data_managers.exceptions import CampaignNotFound
from maps_adv.adv_store.api.schemas.enums import (
    CampaignStatusEnum,
    OrderSizeEnum,
    PlatformEnum,
    PublicationEnvEnum,
    ReasonCampaignStoppedEnum,
    RubricEnum,
)
from maps_adv.billing_proxy.client import Client as BillingProxyClient
from maps_adv.billing_proxy.client.lib.enums import (
    AdvRubric,
    OrderSize,
    CreativeType,
)
from maps_adv.common.helpers import validate_custom_page_id
from maps_adv.common.helpers.enums import CampaignTypeEnum
from maps_adv.common.helpers.mappers import EVENT_TYPES
from maps_adv.statistics.dashboard import client as dashboard

__all__ = [
    "BillingTypeChange",
    "BudgetIsTooLow",
    "CampaignsDomain",
    "OrderIdChange",
    "PeriodIntersectingDiscounts",
    "ShowingPeriodEndsInThePast",
    "ShowingPeriodStartLaterThanEnd",
    "WrongCampaignTypeForPlatform",
    "WrongCreativesForCampaignType",
    "WrongPlacingForCampaignType",
    "CampaignsNotFound",
    "InvalidBilling",
    "InvalidCustomPageId",
    "InconsistentCreatives",
]


class CampaignsNotFound(Exception):
    __slots__ = "not_found_campaigns"

    def __init__(self, not_found_campaigns):
        self.not_found_campaigns = not_found_campaigns


class WrongCampaignTypeForPlatform(Exception):
    pass


class WrongCreativesForCampaignType(Exception):
    pass


class WrongPlacingForCampaignType(Exception):
    pass


class PeriodIntersectingDiscounts(Exception):
    pass


class BillingTypeChange(Exception):
    pass


class OrderIdChange(Exception):
    pass


class BudgetIsTooLow(Exception):
    pass


class ShowingPeriodStartLaterThanEnd(Exception):
    pass


class ShowingPeriodEndsInThePast(Exception):
    pass


class InvalidBilling(Exception):
    pass


class InvalidCustomPageId(Exception):
    pass


class PaidTillIsTooSmall(Exception):
    pass


class InconsistentCreatives(Exception):
    pass


class CampaignsDomain:
    __slots__ = (
        "_billing_proxy_client",
        "_dashboard_api_url",
        "_calculate_display_chance_from_cpm",
        "_dm",
        "_events_dm",
        "_moderation_dm",
    )

    _dm: CampaignsDataManager
    _events_dm: EventsDataManager
    _moderation_dm: ModerationDataManager
    _calculate_display_chance_from_cpm: bool
    _dashboard_api_url: str
    _billing_proxy_client: BillingProxyClient

    def __init__(
        self,
        dm,
        events_dm,
        moderation_dm,
        dashboard_api_url: str,
        billing_proxy_client: BillingProxyClient,
        calculate_display_chance_from_cpm: bool = False,
    ):
        self._dm = dm
        self._events_dm = events_dm
        self._moderation_dm = moderation_dm
        self._calculate_display_chance_from_cpm = calculate_display_chance_from_cpm
        self._dashboard_api_url = dashboard_api_url
        self._billing_proxy_client = billing_proxy_client

    async def create_campaign(
        self,
        *,
        name: str,
        author_id: int,
        publication_envs: List[PublicationEnvEnum],
        campaign_type: CampaignTypeEnum,
        start_datetime: datetime,
        end_datetime: datetime,
        timezone: str,
        platforms: List[PlatformEnum],
        cpm: Optional[dict] = None,
        cpa: Optional[dict] = None,
        fix: Optional[dict] = None,
        order_id: Optional[int] = None,
        manul_order_id: Optional[int] = None,
        creatives: List[dict],
        actions: List[dict],
        placing: dict,
        week_schedule: List[dict],
        targeting: dict,
        status: Optional[CampaignStatusEnum],
        comment: Optional[str] = "",
        rubric: Optional[RubricEnum] = None,
        order_size: Optional[OrderSizeEnum] = None,
        user_daily_display_limit: Optional[int] = None,
        user_display_limit: Optional[int] = None,
        discounts: Optional[List[dict]],
        datatesting_expires_at: Optional[datetime] = None,
        settings: Optional[dict] = None,
        product_id: Optional[int] = None,
    ) -> dict:
        for platform in platforms:
            self._check_campaign_type_against_platforms(campaign_type, platform)
            self._check_creatives_against_campaign(campaign_type, platform, creatives)
        self._check_creatives_consistency(campaign_type, creatives)

        self._check_placing_against_campaign_type(campaign_type, placing)
        self._check_period_intersecting_discounts(discounts)

        if product_id is not None:
            async with self._billing_proxy_client as client:
                real_cpm = await client.calculate_product_cpm(
                    product_id,
                    rubric=AdvRubric[rubric.name] if rubric else None,
                    targeting_query=targeting,
                    dt=start_datetime,
                    order_size=OrderSize[order_size.name] if order_size else None,
                    creative_types=list(
                        map(lambda c: CreativeType[c["type_"].upper()], creatives)
                    ),
                )
                if cpm is not None:
                    cpm["cost"] = real_cpm
                if cpa is not None:
                    cpa["cost"] = real_cpm

        self._check_auto_daily_limit(cpm)
        self._check_auto_daily_limit(cpa)
        self._check_custom_page_id(settings)

        return await self._dm.create_campaign(
            name=name,
            author_id=author_id,
            publication_envs=publication_envs,
            campaign_type=campaign_type,
            start_datetime=start_datetime,
            end_datetime=end_datetime,
            timezone=timezone,
            platforms=platforms,
            cpm=cpm,
            cpa=cpa,
            fix=fix,
            order_id=order_id,
            manul_order_id=manul_order_id,
            creatives=creatives,
            actions=actions,
            placing=placing,
            week_schedule=week_schedule,
            comment=comment,
            targeting=targeting,
            rubric=rubric,
            order_size=order_size,
            user_daily_display_limit=user_daily_display_limit,
            user_display_limit=user_display_limit,
            discounts=discounts,
            status=status,
            datatesting_expires_at=datatesting_expires_at,
            settings=settings,
        )

    async def update_campaign(
        self,
        campaign_id,
        *,
        name: str,
        author_id: int,
        publication_envs: List[PublicationEnvEnum],
        start_datetime: datetime,
        end_datetime: datetime,
        timezone: str,
        platforms: List[PlatformEnum],
        cpm: Optional[dict] = None,
        cpa: Optional[dict] = None,
        fix: Optional[dict] = None,
        order_id: Optional[int] = None,
        manul_order_id: Optional[int] = None,
        creatives: List[dict],
        actions: List[dict],
        placing: dict,
        week_schedule: List[dict],
        targeting: dict,
        comment: Optional[str] = "",
        rubric: Optional[RubricEnum] = None,
        order_size: Optional[OrderSizeEnum] = None,
        user_daily_display_limit: Optional[int] = None,
        user_display_limit: Optional[int] = None,
        discounts: Optional[List[dict]],
        status: Optional[CampaignStatusEnum],
        datatesting_expires_at: Optional[datetime] = None,
        settings: Optional[dict] = None,
        product_id: Optional[int] = None,
    ) -> dict:
        current_data = await self._dm.retrieve_campaign(campaign_id)

        for platform in platforms:
            self._check_campaign_type_against_platforms(
                current_data["campaign_type"], platform
            )
            self._check_creatives_against_campaign(
                current_data["campaign_type"], platform, creatives
            )
        self._check_creatives_consistency(current_data["campaign_type"], creatives)

        self._check_placing_against_campaign_type(
            current_data["campaign_type"], placing
        )

        new_billing = {"cpm": cpm, "cpa": cpa, "fix": fix}
        for billing_type in ("cpm", "cpa", "fix"):
            if billing_type in current_data["billing"]:
                if new_billing[billing_type] is None:
                    raise BillingTypeChange
                else:
                    break

        if current_data["order_id"] != order_id:
            raise OrderIdChange

        if product_id is not None:
            async with self._billing_proxy_client as client:
                dt = start_datetime
                if settings is not None:
                    dt = settings.get("forced_product_version_datetime", dt)
                real_cpm = await client.calculate_product_cpm(
                    product_id,
                    rubric=AdvRubric[rubric.name] if rubric else None,
                    targeting_query=targeting,
                    dt=dt,
                    order_size=OrderSize[order_size.name] if order_size else None,
                    creative_types=list(
                        map(lambda c: CreativeType[c["type_"].upper()], creatives)
                    ),
                )
                if cpm is not None:
                    cpm["cost"] = real_cpm
                if cpa is not None:
                    cpa["cost"] = real_cpm

        self._check_auto_daily_limit(cpm)
        self._check_auto_daily_limit(cpa)
        self._check_custom_page_id(settings)

        updated_campaign = await self._dm.update_campaign(
            campaign_id,
            name=name,
            author_id=author_id,
            publication_envs=publication_envs,
            start_datetime=start_datetime,
            end_datetime=end_datetime,
            timezone=timezone,
            platforms=platforms,
            cpm=cpm,
            cpa=cpa,
            fix=fix,
            order_id=order_id,
            manul_order_id=manul_order_id,
            creatives=creatives,
            actions=actions,
            placing=placing,
            week_schedule=week_schedule,
            targeting=targeting,
            comment=comment,
            rubric=rubric,
            order_size=order_size,
            user_daily_display_limit=user_daily_display_limit,
            user_display_limit=user_display_limit,
            discounts=discounts,
            status=status,
            datatesting_expires_at=datatesting_expires_at,
            settings=settings,
        )

        await self.generate_campaign_events_if_needed(
            campaign_id, author_id, current_data, updated_campaign
        )

        return updated_campaign

    async def generate_campaign_events_if_needed(
        self,
        campaign_id: int,
        author_id: int,
        prev_campaign_data: dict,
        new_campaign_data: dict,
    ):
        # generate events only if campaing goes from DRAFT to REVIEW
        # front always sets status to DRAFT before update
        if not (
            prev_campaign_data["status"] == CampaignStatusEnum.DRAFT
            and new_campaign_data["status"] == CampaignStatusEnum.REVIEW
        ):
            return

        if prev_campaign_data["end_datetime"] != new_campaign_data["end_datetime"]:
            await self._events_dm.create_event_end_datetime_changed(
                campaign_id=campaign_id,
                initiator_id=author_id,
                prev_datetime=prev_campaign_data["end_datetime"],
            )

        # billing type can not change so we just take whichever present
        prev_billing_state = prev_campaign_data["billing"]
        curr_billing = prev_billing_state.get("cpm") or prev_billing_state.get("cpa")
        new_billing_state = new_campaign_data["billing"]
        new_billing = new_billing_state.get("cpm") or new_billing_state.get("cpa")

        if (
            curr_billing is not None
            and curr_billing["budget"] is not None
            and (
                new_billing["budget"] is None
                or new_billing["budget"] < curr_billing["budget"]
            )
        ):
            await self._events_dm.create_event_budget_decreased(
                campaign_id=campaign_id,
                initiator_id=author_id,
                prev_budget=curr_billing["budget"],
            )

    @staticmethod
    def _check_campaign_type_against_platforms(
        campaign_type: CampaignTypeEnum, platform: PlatformEnum
    ):
        valid_combinations = {
            CampaignTypeEnum.PIN_ON_ROUTE: {PlatformEnum.NAVI, PlatformEnum.MAPS},
            CampaignTypeEnum.BILLBOARD: {PlatformEnum.NAVI},
            CampaignTypeEnum.ZERO_SPEED_BANNER: {PlatformEnum.NAVI},
            CampaignTypeEnum.VIA_POINTS: {PlatformEnum.NAVI},
            CampaignTypeEnum.CATEGORY_SEARCH: {PlatformEnum.NAVI, PlatformEnum.MAPS},
            CampaignTypeEnum.ROUTE_BANNER: {PlatformEnum.METRO},
            CampaignTypeEnum.OVERVIEW_BANNER: {PlatformEnum.NAVI},
            CampaignTypeEnum.PROMOCODE: {PlatformEnum.METRO},
        }

        if platform not in valid_combinations[campaign_type]:
            raise WrongCampaignTypeForPlatform

    @staticmethod
    def _check_creatives_against_campaign(
        campaign_type: CampaignTypeEnum, platform: PlatformEnum, creatives: List[dict]
    ):
        creative_types = set(map(itemgetter("type_"), creatives))
        valid_combinations = {
            PlatformEnum.NAVI: {
                CampaignTypeEnum.PIN_ON_ROUTE: (
                    {"pin", "logo_and_text"},
                    {"pin", "banner"},
                ),
                CampaignTypeEnum.BILLBOARD: ({"billboard", "banner"},),
                CampaignTypeEnum.ZERO_SPEED_BANNER: (
                    {"banner"},
                    {"banner", "audio_banner"},
                ),
                CampaignTypeEnum.CATEGORY_SEARCH: ({"icon", "text", "pin_search"},),
                CampaignTypeEnum.VIA_POINTS: ({"via_point"},),
                CampaignTypeEnum.OVERVIEW_BANNER: ({"banner"},),
            },
            PlatformEnum.MAPS: {
                CampaignTypeEnum.PIN_ON_ROUTE: (
                    {"pin", "banner"},
                    {"pin", "logo_and_text", "banner"},
                ),
                CampaignTypeEnum.BILLBOARD: ({"billboard", "banner"},),
                CampaignTypeEnum.CATEGORY_SEARCH: ({"icon", "text", "pin_search"},),
            },
            PlatformEnum.METRO: {
                CampaignTypeEnum.ROUTE_BANNER: ({"banner"},),
                CampaignTypeEnum.PROMOCODE: ({"banner"},),
            },
        }

        if creative_types not in valid_combinations[platform][campaign_type]:
            raise WrongCreativesForCampaignType

    @staticmethod
    def _check_creatives_consistency(
        campaign_type: CampaignTypeEnum, creatives: List[dict]
    ):
        if campaign_type == CampaignTypeEnum.BILLBOARD:
            by_type = defaultdict(list)
            for creative in creatives:
                by_type[creative["type_"]].append(creative)
            if any(
                map(lambda bnr: bool(bnr.get("description")), by_type["banner"])
            ) and not any(
                map(lambda bb: bool(bb.get("images_v2")), by_type["billboard"])
            ):
                raise InconsistentCreatives

            for banner in by_type["banner"]:
                if (
                    len(banner.get("title", "")) > 48
                    or len(banner.get("description", "")) > 260
                    or len(banner.get("disclaimer", "")) > 200
                ):
                    raise InconsistentCreatives

    @staticmethod
    def _check_placing_against_campaign_type(
        campaign_type: CampaignTypeEnum, placing: dict
    ):
        required_placing_type = {
            CampaignTypeEnum.PIN_ON_ROUTE: "organizations",
            CampaignTypeEnum.BILLBOARD: "area",
            CampaignTypeEnum.ZERO_SPEED_BANNER: "area",
            CampaignTypeEnum.CATEGORY_SEARCH: "organizations",
            CampaignTypeEnum.VIA_POINTS: "organizations",
            CampaignTypeEnum.ROUTE_BANNER: "area",
            CampaignTypeEnum.OVERVIEW_BANNER: "area",
            CampaignTypeEnum.PROMOCODE: "area",
        }

        if required_placing_type[campaign_type] not in placing:
            raise WrongPlacingForCampaignType

    @staticmethod
    def _check_period_intersecting_discounts(discounts: List[dict]):
        if len(discounts) < 2:
            return

        sorted_dts = sorted(discounts, key=itemgetter("start_datetime"))

        if not all(
            prev["end_datetime"] < curr["start_datetime"]
            for prev, curr in zip(sorted_dts, sorted_dts[1:])
        ):
            raise PeriodIntersectingDiscounts

    @staticmethod
    def _check_auto_daily_limit(billing):
        if billing is None:
            return
        if billing.get("auto_daily_budget") and billing.get("daily_budget") is None:
            raise InvalidBilling

    @staticmethod
    def _check_custom_page_id(settings):
        if not settings:
            return
        if not validate_custom_page_id(settings.get("custom_page_id")):
            raise InvalidCustomPageId

    async def retrieve_campaign(self, campaign_id: int) -> dict:
        return await self._dm.retrieve_campaign(campaign_id)

    async def set_status(
        self, campaign_id: int, status: CampaignStatusEnum, initiator_id: int, note: str
    ):
        if not await self._dm.campaign_exists(campaign_id):
            raise CampaignNotFound()

        prev_status = await self._dm.get_status(campaign_id)

        metadata = {"comment": note} if note else {}

        await self._dm.set_status(
            campaign_id=campaign_id,
            status=status,
            author_id=initiator_id,
            metadata=metadata,
        )

        if status == CampaignStatusEnum.REVIEW:
            moderation_id = await self._moderation_dm.create_direct_moderation_for_campaign(  # noqa: E501
                campaign_id=campaign_id, reviewer_uid=initiator_id
            )
            await self._dm.set_direct_moderation(
                campaign_id=campaign_id, moderation_id=moderation_id
            )

        if status == CampaignStatusEnum.DRAFT:
            await self._dm.set_direct_moderation(
                campaign_id=campaign_id,
                moderation_id=None,  # reset moderation if present
            )

        stopped_statuses = [CampaignStatusEnum.PAUSED, CampaignStatusEnum.DRAFT]
        if prev_status == CampaignStatusEnum.ACTIVE and status in stopped_statuses:
            await self._events_dm.create_event_stopped_manually(
                timestamp=datetime.now(),
                campaign_id=campaign_id,
                initiator_id=initiator_id,
                metadata=metadata,
            )

    async def set_paid_till(
        self, campaign_id: int, paid_till: Optional[datetime] = None
    ):
        min_allowed_paid_till = (await self._dm.retrieve_campaign(campaign_id))[
            "paid_till"
        ] or datetime.now(timezone.utc)
        if paid_till is not None and paid_till < min_allowed_paid_till:
            raise PaidTillIsTooSmall()
        await self._dm.set_paid_till(campaign_id, paid_till)

    async def list_campaigns_by_orders(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        return await self._dm.list_campaigns_by_orders(order_ids, manul_order_ids)

    async def list_short_campaigns(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        return await self._dm.list_short_campaigns(order_ids, manul_order_ids)

    async def list_campaigns_summary(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        return await self._dm.list_campaigns_summary(order_ids, manul_order_ids)

    async def list_campaigns_for_charger(self, active_at: datetime) -> List[dict]:
        return await self._dm.list_campaigns_for_charger(active_at)

    async def list_campaigns_for_charger_cpa(self, active_at: datetime) -> List[dict]:
        campaigns = await self._dm.list_campaigns_for_charger_cpa(active_at)
        return [
            {
                "paid_events_names": EVENT_TYPES[campaign.pop("campaign_type")],
                **campaign,
            }
            for campaign in campaigns
        ]

    async def list_campaigns_for_charger_fix(self, active_at: datetime) -> List[dict]:
        return await self._dm.list_campaigns_for_charger_fix(active_at)

    async def stop_campaigns(
        self, processed_at: datetime, campaigns: List[dict]
    ) -> None:
        campaign_ids = set(map(itemgetter("campaign_id"), campaigns))
        existing_campaigns = await self._dm.retrieve_existing_campaign_ids(campaign_ids)
        if campaign_ids != existing_campaigns:
            diff = campaign_ids.difference(existing_campaigns)
            raise CampaignsNotFound(diff)

        await self._dm.stop_campaigns(processed_at=processed_at, campaigns=campaigns)

        # log events for campaign stop
        stopped_budget_reached = set(
            campaign["campaign_id"]
            for campaign in campaigns
            if campaign["reason_stopped"] == ReasonCampaignStoppedEnum.BUDGET_REACHED
        )
        await self._events_dm.create_events_stopped_budget_reached(
            timestamp=processed_at, campaign_ids=stopped_budget_reached
        )

    async def list_campaigns_for_export(self) -> List[dict]:
        campaigns = await self._dm.list_campaigns_for_export()

        if self._calculate_display_chance_from_cpm:
            for campaign in campaigns:
                campaign["display_chance"] = None
                if campaign["billing_cpm_cost"] is not None:
                    campaign["display_chance"] = math.ceil(campaign["billing_cpm_cost"])

        return campaigns

    async def refresh_auto_daily_budgets(self) -> None:
        on_datetime = datetime.now(tz=timezone.utc).replace(minute=0, second=0)
        await self._dm.refresh_auto_daily_budgets(on_datetime)

    async def predict_daily_budget(
        self,
        *,
        campaign_id: Optional[int] = None,
        start_datetime: datetime,
        end_datetime: datetime,
        timezone: str,
        budget: Decimal,
    ) -> Decimal:
        tz = pytz.timezone(timezone)
        now_tz = datetime.now(tz)
        start_datetime_tz = start_datetime.astimezone(tz)
        end_datetime_tz = end_datetime.astimezone(tz)

        if end_datetime_tz < now_tz:
            raise ShowingPeriodEndsInThePast()
        elif end_datetime_tz < start_datetime_tz:
            raise ShowingPeriodStartLaterThanEnd()

        already_charged = Decimal("0")
        if campaign_id:
            async with dashboard.Client(self._dashboard_api_url) as client:
                try:
                    got = await client.campaigns_charged_sum(campaign_id)
                except dashboard.NoStatistics:
                    pass
                else:
                    already_charged = got.get(campaign_id)

        predict_from = max(start_datetime_tz, now_tz)
        days_for_prediction = (end_datetime_tz.date() - predict_from.date()).days + 1
        result = (budget - already_charged) / days_for_prediction

        if result <= 0:
            raise BudgetIsTooLow(f"Budget should be greater than {already_charged}")

        return result.quantize(Decimal(".01"), rounding=decimal.ROUND_CEILING)

    async def list_used_audiences(self) -> List[int]:
        targetings = await self._dm.retrieve_targetings()
        final = self._extract_audiences(targetings)
        return sorted(list(final))

    def _extract_audiences(self, targetings: List[dict]) -> Set[int]:
        segments = set()
        for targeting in targetings:
            if targeting["tag"] == "audience":
                segments.add(int(targeting["attributes"]["id"]) + 2000000000)
            elif targeting["tag"] in ("and", "or") and "items" in targeting:
                segments.update(self._extract_audiences(targeting["items"]))
        return segments

    async def list_campaigns_for_budget_analysis(self) -> List[dict]:
        return await self._dm.list_campaigns_for_budget_analysis()

    async def backup_campaigns_change_log(self) -> None:
        await self._dm.backup_campaigns_change_log()

    async def retrieve_campaign_data_for_monitorings(
        self, ids: List[int]
    ) -> List[dict]:
        return await self._dm.retrieve_campaign_data_for_monitorings(ids)
