import asyncio
from abc import abstractmethod
from datetime import date, datetime, timezone, timedelta
import pytz
from operator import itemgetter
from typing import Awaitable, List, Optional, Set, Union

import janus
import yt.wrapper
from asyncpg import Connection, ForeignKeyViolationError

from maps_adv.adv_store.v2.lib.data_managers import sqls
from maps_adv.adv_store.v2.lib.data_managers.base import BaseDataManager
from maps_adv.adv_store.v2.lib.data_managers.exceptions import (
    CampaignNotFound,
    DirectModerationNotFound,
)
from maps_adv.adv_store.v2.lib.db import DB, db_enum_converter
from maps_adv.adv_store.api.schemas.enums import (
    ActionTypeEnum,
    CampaignStatusEnum,
    FixTimeIntervalEnum,
    OrderSizeEnum,
    OverviewPositionEnum,
    PlatformEnum,
    PublicationEnvEnum,
    ReasonCampaignStoppedEnum,
    ResolveUriTargetEnum,
    RubricEnum,
)
from maps_adv.common.helpers.enums import CampaignTypeEnum
from maps_adv.statistics.dashboard.client import Client, NoStatistics
from smb.common.pgswim.lib.engine import PoolType

_status_by_reason_stopped = {
    ReasonCampaignStoppedEnum.DAILY_BUDGET_REACHED: CampaignStatusEnum.ACTIVE,
    ReasonCampaignStoppedEnum.BUDGET_REACHED: CampaignStatusEnum.DONE,
    ReasonCampaignStoppedEnum.ORDER_LIMIT_REACHED: CampaignStatusEnum.ACTIVE,
}


class CampaignsChangeLogActionName:
    CAMPAIGN_CREATED = "campaign.created"
    CAMPAIGN_UPDATED = "campaign.updated"
    CAMPAIGN_STOPPED = "campaign.stopped"
    CAMPAIGN_UNSTOPPED = "campaign.unstopped"
    CAMPAIGN_REVIEWED = "campaign.reviewed"
    CAMPAIGN_CHANGE_STATUS = "campaign.change_status"
    CAMPAIGN_REFRESH_AUTO_DAILY_BUDGET = "campaign.refresh_auto_daily_budget"
    CAMPAIGN_REFRESH_DISPLAY_PROBABILITY = "campaign.refresh_display_probability"
    CAMPAIGN_PROLONGATED = "campaign.prolongated"

    # deprecated
    CAMPAIGN_MOVE_CHANGE_LOG = "campaign.move_change_log"


class WrongBillingParameters(Exception):
    pass


class BaseCampaignsDataManager(BaseDataManager):
    __slots__ = ()

    @abstractmethod
    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,
        actions: List[dict],
        creatives: List[dict],
        placing: dict,
        week_schedule: List[dict],
        targeting: str,
        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,
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def set_status(
        self,
        campaign_id: int,
        *,
        author_id: int,
        status: CampaignStatusEnum,
        change_log_action_name: str = CampaignsChangeLogActionName.CAMPAIGN_CHANGE_STATUS,  # noqa: E501
        metadata: Optional[dict] = None,
        con: Optional[Connection] = None,
    ) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def get_status(self, campaign_id: int) -> CampaignStatusEnum:
        raise NotImplementedError()

    @abstractmethod
    async def set_paid_till(
        self,
        campaign_id: int,
        paid_till: Optional[datetime] = None,
    ) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def campaign_exists(self, campaign_id: int) -> bool:
        raise NotImplementedError()

    @abstractmethod
    async def retrieve_campaign(
        self, campaign_id: int, con: Optional[Connection] = None
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    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: str,
        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,
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def list_campaigns_by_orders(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def list_short_campaigns(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def list_campaigns_summary(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def list_campaigns_for_charger(self, active_at: datetime) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def refresh_auto_daily_budgets(self, on_date: date) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def retrieve_existing_campaign_ids(
        self, campaign_ids: Set[int], con: Optional[Connection] = None
    ) -> Set[int]:
        raise NotImplementedError()

    @abstractmethod
    async def stop_campaigns(
        self,
        processed_at: datetime,
        campaigns_to_stop: List[dict],
        con: Optional[Connection] = None,
    ) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def retrieve_targetings(self, con: Optional[Connection] = None) -> list:
        raise NotImplementedError()

    @abstractmethod
    async def list_campaigns_for_budget_analysis(self) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def set_direct_moderation(self, campaign_id: int, moderation_id: int) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def backup_campaigns_change_log(self) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def retrieve_campaign_data_for_monitorings(
        self, ids: List[int]
    ) -> List[dict]:
        raise NotImplementedError()


class CampaignsDataManager(BaseCampaignsDataManager):
    __slots__ = ("dashboard_api_url", "yt_cluster", "yt_token", "change_logs_table")

    QUEUE_TIMEOUT = 60
    QUEUE_SIZE = 10000

    def __init__(
        self,
        db: Optional[DB],
        dashboard_api_url: str,
        yt_cluster: str,
        yt_token: str,
        change_logs_table: str,
    ):
        super().__init__(db)
        self.dashboard_api_url = dashboard_api_url
        self.yt_cluster = yt_cluster
        self.yt_token = yt_token
        self.change_logs_table = change_logs_table

    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: str,
        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,
    ) -> dict:
        billing_sql, _, billing_args = self.__make_billing_sql(cpm, cpa, fix)

        async with self.connection() as con:
            async with con.transaction():
                billing_id = await con.fetchval(billing_sql, *billing_args)
                campaign_id = await con.fetchval(
                    sqls.create_campaign,
                    name,
                    author_id,
                    db_enum_converter.from_enum(publication_envs),
                    db_enum_converter.from_enum(campaign_type),
                    start_datetime,
                    end_datetime,
                    timezone,
                    billing_id,
                    db_enum_converter.from_enum(platforms),
                    order_id,
                    manul_order_id,
                    comment,
                    targeting,
                    db_enum_converter.from_enum(rubric),
                    db_enum_converter.from_enum(order_size),
                    user_daily_display_limit,
                    user_display_limit,
                    datatesting_expires_at,
                    self._prepare_settings(settings),
                    db_enum_converter.from_enum(status),
                )
                await self._insert_creatives(con, campaign_id, creatives)
                await self._insert_actions(con, campaign_id, actions)
                await self._insert_week_schedule(con, campaign_id, week_schedule)
                await self._insert_placing(con, campaign_id, placing)
                await self._insert_discounts(con, campaign_id, discounts)

                await self._insert_campaign_change_log(
                    con,
                    campaign_id=campaign_id,
                    author_id=author_id,
                    action_name=CampaignsChangeLogActionName.CAMPAIGN_CREATED,
                )

                return await self.retrieve_campaign(campaign_id, con)

    async def _insert_creatives(
        self, con: Optional[Connection], campaign_id: int, creatives: List[dict]
    ):
        insert_map = {
            "pin": (sqls.insert_creative_pin, ("images", "title", "subtitle")),
            "billboard": (
                sqls.insert_creative_billboard,
                ("images", "images_v2", "title", "description"),
            ),
            "icon": (sqls.insert_creative_icon, ("images", "position", "title")),
            "pin_search": (
                sqls.insert_creative_pin_search,
                ("images", "title", "organizations"),
            ),
            "logo_and_text": (sqls.insert_creative_logo_and_text, ("images", "text")),
            "text": (sqls.insert_creative_text, ("text", "disclaimer")),
            "via_point": (
                sqls.insert_creative_via_point,
                ("images", "button_text_active", "button_text_inactive", "description"),
            ),
            "banner": (
                sqls.insert_creative_banner,
                (
                    "images",
                    "disclaimer",
                    "show_ads_label",
                    "description",
                    "title",
                    "terms",
                ),
            ),
            "audio_banner": (
                sqls.insert_creative_audio_banner,
                ("images", "left_anchor", "audio_file_url"),
            ),
        }

        for creative in creatives:
            try:
                sql, fields = insert_map[creative["type_"]]
            except KeyError:
                raise RuntimeError("Unknown creative type '%s'", creative["type_"])

            data = tuple(map(lambda field: creative.get(field), fields))

            await con.execute(sql, campaign_id, *data)

    async def _insert_actions(
        self, con: Optional[Connection], campaign_id: int, actions: List[dict]
    ):
        insert_map = {
            "search": (
                sqls.insert_action_search,
                ("title", "organizations", "history_text", "main"),
                {},
            ),
            "open_site": (sqls.insert_action_open_site, ("title", "url", "main"), {}),
            "phone_call": (
                sqls.insert_action_phone_call,
                ("title", "phone", "main"),
                {},
            ),
            "download_app": (
                sqls.insert_action_download_app,
                ("title", "google_play_id", "app_store_id", "url", "main"),
                {},
            ),
            "promocode": (sqls.insert_action_promocode, ("promocode", "main"), {}),
            "resolve_uri": (
                sqls.insert_action_resolve_uri,
                ("uri", "action_type", "target", "dialog", "main"),
                {
                    "action_type": db_enum_converter.from_enum,
                    "target": db_enum_converter.from_enum,
                },
            ),
            "add_point_to_route": (
                sqls.insert_action_add_point_to_route,
                ("latitude", "longitude", "main"),
                {},
            ),
        }

        for action in actions:
            try:
                sql, fields, converters = insert_map[action["type_"]]
            except KeyError:
                raise RuntimeError("Unknown action type '%s'", action["type_"])

            if converters:
                action = {
                    k: converters[k](v) if converters.get(k) else v
                    for k, v in action.items()
                }

            data = itemgetter(*fields)(action)
            if len(fields) == 1:
                data = (data,)
            await con.execute(sql, campaign_id, *data)

    async def _insert_week_schedule(
        self, con: Connection, campaign_id: int, schedules: List[dict]
    ):
        values = list(
            (campaign_id, schedule["start"], schedule["end"]) for schedule in schedules
        )

        if values:
            await con.execute(sqls.insert_week_schedules, values)

    async def _insert_placing(self, con: Connection, campaign_id: int, placing: dict):
        if "organizations" in placing:
            await con.execute(
                sqls.insert_placing_organizations,
                campaign_id,
                placing["organizations"]["permalinks"],
            )
        if "area" in placing:
            await con.execute(
                sqls.insert_placing_area,
                campaign_id,
                placing["area"]["areas"],
                placing["area"]["version"],
            )

    async def _insert_discounts(
        self, con: Connection, campaign_id: int, discounts: List[dict]
    ):
        await con.executemany(
            sqls.insert_discounts,
            list(
                (
                    campaign_id,
                    discount["start_datetime"],
                    discount["end_datetime"],
                    discount["cost_multiplier"],
                )
                for discount in discounts
            ),
        )

    async def campaign_exists(self, campaign_id: int) -> bool:
        async with self.connection() as con:
            return await con.fetchval(sqls.campaign_exists, campaign_id)

    async def retrieve_campaign(
        self, campaign_id: int, con: Optional[Connection] = None
    ) -> dict:
        async with self.connection(con) as con:
            campaign_data = await con.fetchrow(
                sqls.retrieve_campaign,
                campaign_id,
            )
            if not campaign_data:
                raise CampaignNotFound()

            campaign_discounts = await con.fetch(
                sqls.retrieve_campaign_discounts, campaign_id
            )

        campaign_data = dict(campaign_data)
        campaign_data["publication_envs"] = db_enum_converter.to_enum(
            PublicationEnvEnum, campaign_data["publication_envs"]
        )
        campaign_data["platforms"] = db_enum_converter.to_enum(
            PlatformEnum, campaign_data["platforms"]
        )
        campaign_data["campaign_type"] = db_enum_converter.to_enum(
            CampaignTypeEnum, campaign_data["campaign_type"]
        )
        campaign_data["rubric"] = db_enum_converter.to_enum(
            RubricEnum, campaign_data["rubric"]
        )
        campaign_data["order_size"] = db_enum_converter.to_enum(
            OrderSizeEnum, campaign_data["order_size"]
        )
        campaign_data["status"] = db_enum_converter.to_enum(
            CampaignStatusEnum, campaign_data["status"]
        )
        campaign_data["discounts"] = list(map(dict, campaign_discounts))

        self._transform_billing(campaign_data)
        campaign_data["actions"] = self._transform_actions(campaign_data["actions"])

        if "overview_position" in campaign_data["settings"]:
            campaign_data["settings"]["overview_position"] = db_enum_converter.to_enum(
                OverviewPositionEnum, campaign_data["settings"]["overview_position"]
            )
        if "forced_product_version_datetime" in campaign_data["settings"]:
            campaign_data["settings"][
                "forced_product_version_datetime"
            ] = datetime.utcfromtimestamp(
                campaign_data["settings"]["forced_product_version_datetime"]
            ).replace(
                tzinfo=timezone.utc
            )

        return campaign_data

    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: str,
        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,
    ) -> dict:
        async with self.connection() as con:
            async with con.transaction():
                change_log_id = await self._insert_campaign_change_log(
                    con,
                    campaign_id=campaign_id,
                    author_id=author_id,
                    action_name=CampaignsChangeLogActionName.CAMPAIGN_UPDATED,
                )
                await con.execute(
                    """
                        UPDATE campaign
                        SET
                            name=$2,
                            publication_envs=$3,
                            start_datetime=$4,
                            end_datetime=$5,
                            timezone=$6,
                            platforms=$7,
                            rubric=$8,
                            order_size=$9,
                            targeting=$10,
                            user_daily_display_limit=$11,
                            user_display_limit=$12,
                            comment=$13,
                            order_id=$14,
                            manul_order_id=$15,
                            datatesting_expires_at=$16,
                            settings=$17
                        WHERE id = $1
                    """,
                    campaign_id,
                    name,
                    db_enum_converter.from_enum(publication_envs),
                    start_datetime,
                    end_datetime,
                    timezone,
                    db_enum_converter.from_enum(platforms),
                    db_enum_converter.from_enum(rubric),
                    db_enum_converter.from_enum(order_size),
                    targeting,
                    user_daily_display_limit,
                    user_display_limit,
                    comment,
                    order_id,
                    manul_order_id,
                    datatesting_expires_at,
                    self._prepare_settings(settings),
                )

                _, billing_sql, billing_args = self.__make_billing_sql(cpm, cpa, fix)

                await con.execute(billing_sql, campaign_id, *billing_args)

                await con.execute(sqls.remove_campaign_creatives, campaign_id)
                await self._insert_creatives(con, campaign_id, creatives)

                await con.execute(sqls.remove_campaign_actions, campaign_id)
                await self._insert_actions(con, campaign_id, actions)

                await con.execute(sqls.remove_campaign_placing, campaign_id)
                await self._insert_placing(con, campaign_id, placing)

                await con.execute(sqls.remove_campaign_week_schedules, campaign_id)
                await self._insert_week_schedule(con, campaign_id, week_schedule)

                await self._set_status(
                    con, campaign_id=campaign_id, author_id=author_id, status=status
                )

                await self._refresh_campaigns_change_logs(con, ids=[change_log_id])

            return await self.retrieve_campaign(campaign_id, con)

    @staticmethod
    def _transform_billing(data: dict):
        billing_type = data.pop("_billing_type")

        if billing_type == "cpm":
            billing_data = {
                "cost": data["_cpm_cost"],
                "budget": data["_cpm_budget"],
                "daily_budget": data["_cpm_daily_budget"],
                "auto_daily_budget": data["_cpm_auto_daily_budget"],
            }
        elif billing_type == "cpa":
            billing_data = {
                "cost": data["_cpa_cost"],
                "budget": data["_cpa_budget"],
                "daily_budget": data["_cpa_daily_budget"],
                "auto_daily_budget": data["_cpa_auto_daily_budget"],
            }
        elif billing_type == "fix":
            billing_data = {
                "cost": data["_fix_cost"],
                "time_interval": db_enum_converter.to_enum(
                    FixTimeIntervalEnum, data["_fix_time_interval"]
                ),
            }
        else:
            raise RuntimeError("Broken campaign billing")

        temp_fields = (
            "_cpm_cost",
            "_cpm_budget",
            "_cpm_daily_budget",
            "_cpm_auto_daily_budget",
            "_cpa_cost",
            "_cpa_budget",
            "_cpa_daily_budget",
            "_cpa_auto_daily_budget",
            "_fix_cost",
            "_fix_time_interval",
        )

        for field in temp_fields:
            del data[field]

        data["billing"] = {billing_type: billing_data}

    @staticmethod
    def _transform_actions(data: List[dict]) -> List[dict]:
        for action in data:
            if action["type_"] == "resolve_uri":
                action["action_type"] = db_enum_converter.to_enum(
                    ActionTypeEnum, action["action_type"]
                )
                action["target"] = db_enum_converter.to_enum(
                    ResolveUriTargetEnum, action["target"]
                )
        return data

    @staticmethod
    def _prepare_settings(settings: Optional[dict]) -> dict:
        settings = dict(settings) if settings is not None else dict()

        if "overview_position" in settings:
            settings["overview_position"] = db_enum_converter.from_enum(
                settings["overview_position"]
            )
        if "forced_product_version_datetime" in settings:
            settings["forced_product_version_datetime"] = settings[
                "forced_product_version_datetime"
            ].timestamp()

        return settings

    async def set_status(
        self,
        campaign_id: int,
        *,
        author_id: int,
        status: CampaignStatusEnum,
        change_log_action_name: str = CampaignsChangeLogActionName.CAMPAIGN_CHANGE_STATUS,  # noqa: E501
        metadata: Optional[dict] = None,
        con: Optional[Connection] = None,
    ) -> None:
        async with self.connection(con) as con:
            async with con.transaction():
                change_log_id = await self._insert_campaign_change_log(
                    con,
                    campaign_id=campaign_id,
                    author_id=author_id,
                    action_name=change_log_action_name,
                )
                await self._set_status(
                    con,
                    campaign_id=campaign_id,
                    author_id=author_id,
                    status=status,
                    metadata=metadata,
                )
                await self._refresh_campaigns_change_logs(con, ids=[change_log_id])

    async def _set_status(
        self,
        con: Connection,
        *,
        campaign_id: int,
        author_id: int,
        status: CampaignStatusEnum,
        metadata: Optional[dict] = None,
    ) -> None:
        metadata = metadata or {}
        await con.execute(
            sqls.insert_campaign_status_history_entry,
            campaign_id,
            author_id,
            db_enum_converter.from_enum(status),
            metadata,
        )

    async def get_status(self, campaign_id: int) -> CampaignStatusEnum:
        async with self.connection() as con:
            status = await con.fetchval(sqls.retrieve_campaign_status, campaign_id)

        return db_enum_converter.to_enum(CampaignStatusEnum, status)

    async def set_paid_till(
        self, campaign_id: int, paid_till: Optional[datetime] = None
    ) -> None:
        async with self.connection() as con:
            await con.execute(sqls.update_paid_till, campaign_id, paid_till)

    def __make_billing_sql(
        self,
        cpm: Optional[dict] = None,
        cpa: Optional[dict] = None,
        fix: Optional[dict] = None,
    ) -> List[Union[str, tuple]]:
        # Codes were not so useless
        if fix:
            fix = fix.copy()
            fix["time_interval"] = db_enum_converter.from_enum(fix["time_interval"])

        _billings = list(
            filter(
                itemgetter(0),
                [
                    (
                        cpm,
                        sqls.insert_cpm,
                        sqls.replace_billing_with_cpm,
                        itemgetter(
                            "cost", "budget", "daily_budget", "auto_daily_budget"
                        ),
                    ),
                    (
                        cpa,
                        sqls.insert_cpa,
                        sqls.replace_billing_with_cpa,
                        itemgetter(
                            "cost", "budget", "daily_budget", "auto_daily_budget"
                        ),
                    ),
                    (
                        fix,
                        sqls.insert_fix,
                        sqls.replace_billing_with_fix,
                        itemgetter("time_interval", "cost"),
                    ),
                ],
            )
        )
        if len(_billings) != 1:
            raise WrongBillingParameters()

        _billing = _billings[0]

        return _billing[1], _billing[2], _billing[3](_billing[0])

    async def list_campaigns_by_orders(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        async with self.connection() as con:
            rows = [
                dict(row)
                for row in await con.fetch(
                    sqls.list_campaigns_by_orders, order_ids, manul_order_ids
                )
            ]

        for row in rows:
            row.pop("metadata")
            row["publication_envs"] = db_enum_converter.to_enum(
                PublicationEnvEnum, row["publication_envs"]
            )
            row["status"] = db_enum_converter.to_enum(CampaignStatusEnum, row["status"])

        return rows

    async def list_short_campaigns(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        async with self.connection() as con:
            rows = await con.fetch(
                sqls.list_short_campaigns, order_ids, manul_order_ids
            )

        result = [dict(row) for row in rows]

        for item in result:
            for field in "order_id", "manul_order_id":
                if item[field] is None:
                    del item[field]

        return result

    async def list_campaigns_summary(
        self, order_ids: List[int], manul_order_ids: List[int]
    ) -> List[dict]:
        async with self.connection(pool_type=PoolType.replica) as con:
            results = await con.fetch(
                sqls.list_campaigns_summary,
                order_ids,
                manul_order_ids,
            )

            results = list(map(dict, results))

            # Add zeros for orders with no campaigns
            known_orders = set(map(itemgetter("order_id", "manul_order_id"), results))
            results.extend(
                {"order_id": order_id, "active": 0, "total": 0}
                for order_id in order_ids
                if (order_id, None) not in known_orders
            )
            results.extend(
                {"manul_order_id": manul_order_id, "active": 0, "total": 0}
                for manul_order_id in manul_order_ids
                if (None, manul_order_id) not in known_orders
            )

            # Clean unneeded keys
            for result in results:
                if result.get("order_id") is None:
                    result.pop("order_id", None)
                if result.get("manul_order_id") is None:
                    result.pop("manul_order_id", None)

            return results

    async def list_campaigns_for_charger(self, active_at: datetime) -> List[dict]:
        async with self.connection() as con:
            results = await con.fetch(sqls.list_campaigns_for_charger, active_at)

        return list(map(dict, results))

    async def list_campaigns_for_charger_cpa(self, active_at: datetime) -> List[dict]:
        async with self.connection() as con:
            results = await con.fetch(sqls.list_campaigns_for_charger_cpa, active_at)

        results = list(map(dict, results))

        for row in results:
            row["campaign_type"] = db_enum_converter.to_enum(
                CampaignTypeEnum, row["campaign_type"]
            )

        return results

    async def list_campaigns_for_charger_fix(self, active_at: datetime) -> List[dict]:
        async with self.connection() as con:
            results = await con.fetch(sqls.list_campaigns_for_charger_fix, active_at)

        campaigns = []

        for campaign in results:
            campaign = dict(campaign)
            campaign["time_interval"] = db_enum_converter.to_enum(
                FixTimeIntervalEnum, campaign["time_interval"]
            )
            campaigns.append(campaign)

        return campaigns

    async def stop_campaigns(
        self,
        processed_at: datetime,
        campaigns: List[dict],
        con: Optional[Connection] = None,
    ) -> None:
        async with self.connection(con) as con:
            async with con.transaction():
                change_log_ids = list()
                values = list()
                for campaign in campaigns:
                    status = _status_by_reason_stopped[campaign["reason_stopped"]]
                    metadata = dict(
                        processed_at=int(processed_at.timestamp()),
                        reason_stopped=db_enum_converter.from_enum(
                            campaign["reason_stopped"]
                        ),
                    )

                    values.append(
                        (
                            campaign["campaign_id"],
                            0,
                            db_enum_converter.from_enum(status),
                            metadata,
                        )
                    )

                    change_log_ids.append(
                        await self._insert_campaign_change_log(
                            con,
                            campaign_id=campaign["campaign_id"],
                            action_name=CampaignsChangeLogActionName.CAMPAIGN_STOPPED,
                            **metadata,
                        )
                    )

                await con.executemany(sqls.insert_campaign_status_history_entry, values)
                await self._refresh_campaigns_change_logs(con, ids=[change_log_ids])

    async def retrieve_existing_campaign_ids(
        self, campaign_ids: Set[int], con: Optional[Connection] = None
    ) -> Set[int]:
        async with self.connection(con) as con:
            rows = await con.fetch(sqls.list_existing_campaign_ids, campaign_ids)

            return set(row["id"] for row in rows)

    async def list_campaigns_for_export(self) -> List[dict]:
        async with self.connection(pool_type=PoolType.replica) as con:
            rows = [
                dict(row) for row in await con.fetch(sqls.list_campaigns_for_export)
            ]

        for row in rows:
            campaign_type = row["campaign_type"] = db_enum_converter.to_enum(
                CampaignTypeEnum, row["campaign_type"]
            )
            row["publication_envs"] = db_enum_converter.to_enum(
                PublicationEnvEnum, row["publication_envs"]
            )
            row["platforms"] = db_enum_converter.to_enum(PlatformEnum, row["platforms"])

            if campaign_type == CampaignTypeEnum.CATEGORY_SEARCH and not row["placing"]:
                row["placing"] = {"organizations": {"permalinks": []}}

            row["actions"] = self._transform_actions(row["actions"])

            if "overview_position" in row["settings"]:
                row["settings"]["overview_position"] = db_enum_converter.to_enum(
                    OverviewPositionEnum, row["settings"]["overview_position"]
                )
            if "forced_product_version_datetime" in row["settings"]:
                row["settings"][
                    "forced_product_version_datetime"
                ] = datetime.utcfromtimestamp(
                    row["settings"]["forced_product_version_datetime"]
                ).replace(
                    tzinfo=timezone.utc
                )

            row["cost"] = row["adjusted_cpm_cost"] or row["adjusted_cpa_cost"]
            del row["adjusted_cpm_cost"]
            del row["adjusted_cpa_cost"]

            _calculate_display_times(row)

        return rows

    async def refresh_auto_daily_budgets(self, on_datetime: datetime) -> None:
        async with self.connection() as con:
            async with con.transaction():
                campaign_ids = await con.fetchval(
                    sqls.list_campaigns_to_refresh_auto_daily_budgets, on_datetime
                )
                if not campaign_ids:
                    return

                async with Client(self.dashboard_api_url) as client:
                    try:
                        campaigns_charges = await client.campaigns_charged_sum(
                            *campaign_ids, on_datetime=on_datetime
                        )
                    except NoStatistics:
                        campaigns_charges = {}

                await con.execute(sqls.create_campaigns_charged_sums_tmp_table)
                await con.copy_records_to_table(
                    "campaigns_charged_sums_tmp",
                    records=list(campaigns_charges.items()),
                )

                change_log_ids = []
                for campaign_id in campaigns_charges.keys():
                    change_log_ids.append(
                        await self._insert_campaign_change_log(
                            con,
                            campaign_id=campaign_id,
                            action_name=CampaignsChangeLogActionName.CAMPAIGN_REFRESH_AUTO_DAILY_BUDGET,  # noqa: E501
                        )
                    )

                await con.execute(
                    sqls.refresh_campaigns_auto_daily_budgets, on_datetime, campaign_ids
                )
                await self._refresh_campaigns_change_logs(con, ids=change_log_ids)

    async def retrieve_targetings(self, con: Optional[Connection] = None) -> List[dict]:
        now = datetime.now()
        async with self.connection(con) as con:
            rows = [
                row["targeting"]
                for row in await con.fetch(
                    sqls.list_targetings_for_not_ended_campaigns, now
                )
            ]

        return rows

    @classmethod
    async def _insert_campaign_change_log(
        cls,
        con: Connection,
        *,
        campaign_id: int,
        author_id: int = 0,
        action_name: str,
        **kwargs,
    ) -> int:
        return await con.fetchval(
            sqls.insert_campaigns_change_log,
            campaign_id,
            author_id,
            {"action": action_name, **kwargs},
        )

    @classmethod
    async def _refresh_campaigns_change_logs(cls, con: Connection, *, ids: List[int]):
        await con.execute(sqls.refresh_campaigns_change_logs_status_and_states, ids)

    async def list_campaigns_for_budget_analysis(self) -> List[dict]:
        async with self.connection(pool_type=PoolType.replica) as con:
            results = await con.fetch(sqls.list_campaigns_for_budget_analysis)

        return list(map(dict, results))

    async def set_direct_moderation(self, campaign_id: int, moderation_id: int) -> None:

        try:
            async with self.connection() as con:
                await con.execute(
                    sqls.set_direct_moderation_for_campaign, campaign_id, moderation_id
                )
            return moderation_id

        except ForeignKeyViolationError:
            raise DirectModerationNotFound

    def _start_yt_writer(
        self, cluster: str, table: str, queue: janus.Queue
    ) -> Awaitable:
        def convert(item):
            if isinstance(item, list):
                return [convert(value) for value in item]
            elif isinstance(item, dict):
                return {key: convert(value) for key, value in item.items()}
            elif isinstance(item, datetime):
                return item.timestamp()
            else:
                return item

        def dump_to_yt(queue):
            def records():
                while True:
                    item = queue.sync_q.get(timeout=self.QUEUE_TIMEOUT)
                    if item is None:
                        queue.sync_q.task_done()
                        break
                    yield convert(item)
                    queue.sync_q.task_done()

            yt.wrapper.YtClient(self.yt_cluster, token=self.yt_token).write_table(
                yt.wrapper.TablePath(table, append=True), records()
            )

        return asyncio.get_running_loop().run_in_executor(None, dump_to_yt, queue)

    async def backup_campaigns_change_log(self) -> None:
        queue = janus.Queue(maxsize=self.QUEUE_SIZE)
        writer_fut = self._start_yt_writer(
            self.yt_cluster, self.change_logs_table, queue
        )

        async with self.connection() as con:
            async with con.transaction():
                async for row in con.cursor(sqls.clear_campaigns_change_logs):
                    await asyncio.wait_for(
                        queue.async_q.put(dict(row)), timeout=self.QUEUE_TIMEOUT
                    )

                await asyncio.wait_for(
                    queue.async_q.put(None), timeout=self.QUEUE_TIMEOUT
                )
                await writer_fut

        queue.close()
        await queue.wait_closed()

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

        async with self.connection() as con:
            results = await con.fetch(sqls.list_campaigns_for_monitorings, ids)

        rows = list(map(dict, results))
        for row in rows:
            row["campaign_type"] = db_enum_converter.to_enum(
                CampaignTypeEnum, row["campaign_type"]
            )

        return rows


def _calculate_display_times(campaign):
    def _calculate_period_intersection(a_start, a_end, b_start, b_end):
        latest_start = max(a_start, b_start)
        earliest_end = min(a_end, b_end)
        return (
            0
            if latest_start >= earliest_end
            else (earliest_end - latest_start).total_seconds() // 60
        )

    utc_at = datetime.now(tz=timezone.utc)
    tz = pytz.timezone(campaign["timezone"])
    local_at = utc_at.astimezone(tz)
    local_day_start = datetime(
        year=local_at.year,
        month=local_at.month,
        day=local_at.day,
        tzinfo=local_at.tzinfo,
    )
    local_day_end = local_day_start + timedelta(days=1)

    if not campaign["schedules"]:
        campaign["total_display_minutes_today"] = _calculate_period_intersection(
            campaign["start_datetime"].astimezone(tz),
            campaign["end_datetime"].astimezone(tz),
            local_day_start,
            local_day_end,
        )
        campaign["total_display_minutes_left_today"] = _calculate_period_intersection(
            campaign["start_datetime"].astimezone(tz),
            campaign["end_datetime"].astimezone(tz),
            local_at,
            local_day_end,
        )
    else:
        local_monday_datetime = local_at - timedelta(days=local_at.weekday())
        local_monday = datetime(
            year=local_monday_datetime.year,
            month=local_monday_datetime.month,
            day=local_monday_datetime.day,
            tzinfo=local_monday_datetime.tzinfo,
        )

        campaign["total_display_minutes_today"] = 0
        campaign["total_display_minutes_left_today"] = 0

        for sch_start_offset, sch_end_offset in campaign["schedules"]:
            sch_start = local_monday + timedelta(minutes=sch_start_offset)
            sch_end = local_monday + timedelta(minutes=sch_end_offset)
            campaign["total_display_minutes_today"] += _calculate_period_intersection(
                sch_start,
                sch_end,
                local_day_start,
                local_day_end,
            )
            campaign[
                "total_display_minutes_left_today"
            ] += _calculate_period_intersection(
                sch_start,
                sch_end,
                local_at,
                local_day_end,
            )

    del campaign["start_datetime"]
    del campaign["end_datetime"]
    del campaign["schedules"]
