from abc import ABC, abstractmethod
from collections import namedtuple
from datetime import datetime, timedelta, timezone
from operator import itemgetter
from typing import AsyncGenerator, Dict, List, Literal, Optional, Tuple

import asyncpg
from asyncpg import Connection, UniqueViolationError
from smb.common import sensors
from smb.common.pgswim import PoolType

from . import sqls
from .db import DB
from .enums import (
    CallEvent,
    CallHistoryEvent,
    ClientGender,
    OrderByField,
    OrderDirection,
    OrderEvent,
    SegmentType,
    Source,
)
from .exceptions import (
    AttemptToUpdateClearedClient,
    BadClientData,
    ClientAlreadyExists,
    NoClientIdFieldsPassed,
    UnknownClient,
)

DbParam = namedtuple("DbParam", "db_field, original_value, db_value")


def _nullable_to_str(value: Optional[int]) -> Optional[str]:
    return None if value is None else str(value)


def _all_are_none(*items):
    return all(map(lambda x: x is None, items))


def _only_one_is_set(*items):
    return len([item for item in items if item is not None]) == 1


class QueryArgs:
    _args: list

    def __init__(self, *args):
        self._args = list(args)

    def add_arg(self, arg):
        self._args.append(arg)

    @property
    def last_arg_position_str(self) -> Optional[str]:
        return f"${len(self._args)}" if self._args else None

    @property
    def args(self) -> tuple:
        return tuple(self._args)


class BaseDataManager(ABC):
    @abstractmethod
    async def create_client(
        self,
        *,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
        first_name: Optional[str],
        last_name: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def create_clients(
        self, *, biz_id: int, source: Source, label: Optional[str], clients: List[dict]
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def find_clients(
        self,
        biz_id: int,
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def retrieve_client(self, *, biz_id: int, client_id: int) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def client_exists(self, *, client_id: int, biz_id: int) -> bool:
        raise NotImplementedError()

    @abstractmethod
    async def list_clients(
        self,
        *,
        biz_id: int,
        client_ids: Optional[List[int]] = None,
        search_string: Optional[str] = None,
        segment_type: Optional[SegmentType] = None,
        label: Optional[str] = None,
        order_by_field: Optional[OrderByField] = None,
        order_direction: Optional[OrderByField] = None,
        limit: int,
        offset: int,
    ) -> Tuple[int, List[dict]]:
        raise NotImplementedError()

    @abstractmethod
    async def list_suggest_clients(
        self, *, biz_id: int, search_field: OrderByField, search_string: str, limit: int
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def iter_clients_for_export(
        self, chunk_size: int
    ) -> AsyncGenerator[List[dict], None]:
        raise NotImplementedError()

    @abstractmethod
    async def update_client(
        self,
        *,
        client_id: int,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        first_name: Optional[str],
        last_name: Optional[str],
        passport_uid: Optional[int],
        phone: Optional[int],
        email: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def merge_client(
        self,
        *,
        client_id: int,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
        first_name: Optional[str],
        last_name: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def add_order_event(
        self,
        *,
        client_id: int,
        biz_id: int,
        order_id: int,
        event_type: OrderEvent,
        event_timestamp: datetime,
        source: Source,
    ) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def add_call_event(
        self,
        *,
        client_id: int,
        biz_id: int,
        event_type: CallEvent,
        event_value: Optional[str],
        event_timestamp: datetime,
        source: Source,
        # todo(shpak-vadim): required when Facade will be ready
        session_id: Optional[int] = None,
    ) -> None:
        raise NotImplementedError()

    @abstractmethod
    async def list_segments(
        self, biz_id: int
    ) -> Tuple[
        Dict[Literal["current", "previous"], int],
        Dict[SegmentType, Dict[Literal["current_size", "previous_size"], int]],
    ]:
        raise NotImplementedError()

    @abstractmethod
    async def segment_statistics(
        self, biz_id: int, segment_type: Optional[SegmentType], on_datetime: datetime
    ) -> Dict[datetime, int]:
        raise NotImplementedError()

    @abstractmethod
    async def list_clients_by_segment(
        self, biz_id: int, segment: SegmentType, label: Optional[str]
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def list_client_segments(
        self,
        *,
        passport_uid: Optional[int] = None,
        phone: Optional[int] = None,
        email: Optional[str] = None,
        biz_id: Optional[int] = None,
    ) -> List[dict]:
        raise NotImplementedError()

    @abstractmethod
    async def list_client_events(
        self,
        client_id: int,
        biz_id: int,
        datetime_from: Optional[datetime],
        datetime_to: Optional[datetime],
    ) -> dict:
        raise NotImplementedError()

    @abstractmethod
    async def fetch_max_geoproduct_id_for_call_events(self) -> Optional[int]:
        raise NotImplementedError()

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

    @abstractmethod
    async def store_imported_call_events(self, records: List[list]):
        raise NotImplementedError()

    @abstractmethod
    async def upload_imported_call_events(self, clients: List[list]):
        raise NotImplementedError()

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

    @abstractmethod
    async def check_clients_existence_by_passport(self, passport_uid: int) -> bool:
        raise NotImplementedError()

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


class DataManager(BaseDataManager):
    __slots__ = ["_db", "_strict_search_mode"]

    _db: DB
    SEGMENTS_TRACK_PERIOD_DAYS = 90
    PREVIOUS_STATE_LAG_DAYS = 30

    SEGMENT_TO_CTE_MAP = {
        SegmentType.REGULAR: "regular_clients",
        SegmentType.ACTIVE: "active_clients",
        SegmentType.LOST: "lost_clients",
        SegmentType.UNPROCESSED_ORDERS: "unprocessed_orders_clients",
        SegmentType.NO_ORDERS: "no_orders_clients",
        SegmentType.MISSED_LAST_CALL: "missed_last_call_clients",
        SegmentType.SHORT_LAST_CALL: "short_last_call_clients",
    }

    def __init__(
        self, db: DB, empty_search_sensor: Optional[sensors.MetricGroup] = None
    ):
        self._db = db
        self._empty_search_sensor = empty_search_sensor

    async def create_client(
        self,
        *,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
        first_name: Optional[str],
        last_name: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        async with self._db.acquire() as con:
            row = await con.fetchrow(
                sqls.create_client,
                source.value,
                metadata,
                biz_id,
                first_name,
                last_name,
                passport_uid,
                _nullable_to_str(phone),
                email,
                gender,
                comment,
                initiator_id,
            )

        client = dict(row)
        client["source"] = Source[client["source"]]
        client["segments"] = [SegmentType.NO_ORDERS]
        client["statistics"] = {
            "orders": {
                "total": 0,
                "successful": 0,
                "unsuccessful": 0,
                "last_order_timestamp": None,
            }
        }

        return client

    async def create_clients(
        self, *, biz_id: int, source: Source, label: Optional[str], clients: List[dict]
    ) -> dict:
        def _to_record(data: dict) -> list:
            return itemgetter("first_name", "last_name", "phone", "email", "comment")(
                data
            )

        if not clients:
            return {"total_created": 0, "total_merged": 0}

        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                await con.execute(sqls.create_bulk_clients_tmp)
                await con.copy_records_to_table(
                    "bulk_clients_tmp",
                    records=[_to_record(client) for client in clients],
                    columns=["first_name", "last_name", "phone", "email", "comment"],
                )
                await con.execute(sqls.build_indexes_on_bulk_clients_tmp)

                await con.execute(sqls.merge_input_bulk_clients)

                row = await con.fetchrow(
                    sqls.create_clients, biz_id, source.value, label
                )

                return dict(row)

    async def find_clients(
        self,
        biz_id: int,
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
    ) -> List[dict]:
        if _all_are_none(phone, email, passport_uid):
            raise BadClientData(
                "At least one of id fields must be set: phone, email, passport_uid"
            )

        search_params = []
        if phone:
            search_params.append(DbParam("phone", phone, str(phone)))
        if email:
            search_params.append(DbParam("email", email, email))
        if passport_uid:
            search_params.append(DbParam("passport_uid", passport_uid, passport_uid))

        filter_pairs = []
        for param_pos, param in enumerate(search_params, start=2):
            filter_pairs.append(f"{param.db_field}=${param_pos}")

        sql = sqls.find_client.format(filter_params_str=" OR ".join(filter_pairs))

        async with self._db.acquire() as con:
            rows = await con.fetch(
                sql, biz_id, *[param.db_value for param in search_params]
            )
            return [dict(row) for row in rows]

    async def retrieve_client(self, *, biz_id: int, client_id: int) -> dict:
        async with self._db.acquire() as con:
            row = await con.fetchrow(
                sqls.retrieve_client.format(clients_filter_str="AND id=$3"),
                biz_id,
                self._fetch_segment_tracking_dts()[0],
                client_id,
            )

        if not row:
            raise UnknownClient(biz_id=biz_id, client_id=client_id)

        client = dict(row)
        client["segments"] = list(map(SegmentType, client["segments"]))
        client["source"] = Source[client["source"]]
        self._normalize_client_stat(client)

        return client

    async def client_exists(self, *, client_id: int, biz_id: int) -> bool:
        async with self._db.acquire() as con:
            return await con.fetchval(sqls.client_exists, client_id, biz_id)

    async def list_clients(
        self,
        *,
        biz_id: int,
        client_ids: Optional[List[int]] = None,
        search_string: Optional[str] = None,
        segment_type: Optional[SegmentType] = None,
        label: Optional[str] = None,
        order_by_field: Optional[OrderByField] = None,
        order_direction: Optional[OrderDirection] = None,
        limit: int,
        offset: int,
    ) -> Tuple[int, List[dict]]:
        def _prepare_order_stmt() -> str:
            if order_by_field:
                if not order_direction:
                    raise BadClientData("Order direction must be set with order field")
                order_by_expression = (
                    "NULLIF(concat(clients_data.first_name, clients_data.last_name), '')"  # noqa
                    if order_by_field == OrderByField.FIRST_AND_LAST_NAME
                    else order_by_field.value
                )
                direction_expression = (
                    "ASC NULLS LAST"
                    if order_direction == OrderDirection.ASC
                    else "DESC NULLS FIRST"
                )
                return f"""
                    {order_by_expression} {direction_expression},
                    clients_data.created_at DESC
                    """
            elif search_string:
                return "priority DESC"
            else:
                return "clients_data.created_at DESC"

        def _prepare_base_filters_stmt() -> str:
            if client_ids:
                query_args.add_arg(client_ids)
                return f"AND id=ANY({query_args.last_arg_position_str})"
            else:
                return ""

        if label and segment_type:
            raise AssertionError("Only one label or segment_type can be set")

        query_args = QueryArgs(
            biz_id, self._fetch_segment_tracking_dts()[0], limit, offset
        )

        ctes = [
            sqls.clients_with_segments_cte.format(
                clients_filter_str=_prepare_base_filters_stmt()
            )
        ]
        filtered_cte_name = "clients_with_segments"

        if segment_type:
            ctes.append(
                sqls.filtered_by_segment_cte.format(
                    segment_cte_name=self.SEGMENT_TO_CTE_MAP[segment_type]
                )
            )
            filtered_cte_name = "filtered_by_segment"

        if label:
            query_args.add_arg(label)

            ctes.append(
                sqls.filtered_by_label_cte.format(
                    input_cte_name=filtered_cte_name,
                    label_arg_placeholder=query_args.last_arg_position_str,
                )
            )
            filtered_cte_name = "filtered_by_label"

        if search_string:
            query_args.add_arg(search_string)

            ctes.append(
                sqls.filtered_by_search_cte.format(
                    input_cte_name=filtered_cte_name,
                    search_arg_placeholder=query_args.last_arg_position_str,
                )
            )
            filtered_cte_name = "filtered_by_search"

        sql = sqls.list_clients.format(
            ctes=",".join(ctes),
            input_cte_name=filtered_cte_name,
            order_by_stmt=_prepare_order_stmt(),
        )

        async with self._db.acquire() as con:
            rows = await con.fetch(sql, *query_args.args)

        total_count, clients = self._normalize_list_clients_result(rows)
        for client in clients:
            client["segments"] = list(map(SegmentType, client["segments"]))

        if self._empty_search_sensor and search_string and total_count == 0:
            self._empty_search_sensor.take().add(len(search_string))

        return total_count, clients

    async def list_suggest_clients(
        self, *, biz_id: int, search_field: OrderByField, search_string: str, limit: int
    ) -> List[dict]:
        sql = sqls.list_suggest_clients.format(search_field=search_field.value)

        async with self._db.acquire() as con:
            rows = await con.fetch(sql, biz_id, f"{search_string}%", limit)

        return [dict(row) for row in rows]

    async def iter_clients_for_export(
        self, chunk_size: int
    ) -> AsyncGenerator[List[dict], None]:
        async with self._db.acquire(PoolType.replica) as con:
            async with con.transaction():
                cur = await con.cursor(
                    sqls.list_clients_for_export,
                    0,  # Any value will do
                    self._fetch_segment_tracking_dts()[0],
                )

                while True:
                    rows = await cur.fetch(chunk_size)
                    if not rows:
                        break
                    yield list(map(dict, rows))

    async def update_client(
        self,
        *,
        client_id: int,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        first_name: Optional[str],
        last_name: Optional[str],
        passport_uid: Optional[int],
        phone: Optional[int],
        email: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        async with self._db.acquire() as con:
            is_client_cleared = await con.fetchval(
                "SELECT cleared_for_gdpr FROM clients WHERE id = $1 AND biz_id = $2",
                client_id,
                biz_id,
            )

            if is_client_cleared is None:
                raise UnknownClient(id=client_id, biz_id=biz_id)
            elif is_client_cleared is True:
                raise AttemptToUpdateClearedClient

            if any([phone, email]):
                duplicate_exists = await self._check_duplicate_client_exists(
                    biz_id=biz_id,
                    client_id=client_id,
                    passport_uid=passport_uid,
                    phone=phone,
                    email=email,
                    con=con,
                )

                if duplicate_exists:
                    raise ClientAlreadyExists(
                        "Client with same phone or email "
                        "for this biz_id already exists."
                    )

            try:
                row = await con.fetchrow(
                    sqls.update_client.format(clients_filter_str="AND id=$3"),
                    biz_id,
                    self._fetch_segment_tracking_dts()[0],
                    client_id,
                    source.value,
                    metadata,
                    _nullable_to_str(phone),
                    email,
                    passport_uid,
                    first_name,
                    last_name,
                    gender,
                    comment,
                    initiator_id,
                )
            except UniqueViolationError as exc:
                raise ClientAlreadyExists(exc.detail)

        client = dict(row)
        client["segments"] = list(map(SegmentType, client["segments"]))
        client["source"] = Source[client["source"]]
        self._normalize_client_stat(client)

        return client

    async def _check_duplicate_client_exists(
        self,
        biz_id: int,
        client_id: int,
        passport_uid: Optional[int],
        phone: Optional[int],
        email: Optional[str],
        con: Connection,
    ) -> bool:
        search_params = []
        if phone:
            search_params.append(DbParam("phone", phone, str(phone)))
        if email:
            search_params.append(DbParam("email", email, email))

        filter_pairs = []
        for param_pos, param in enumerate(search_params, start=3):
            filter_pairs.append(f"{param.db_field}=${param_pos}")

        if passport_uid:
            passport_uid_clause = "passport_uid IS NULL"
        else:
            passport_uid_clause = "passport_uid IS NOT NULL"

        return await con.fetchval(
            sqls.duplicate_client_exists.format(
                passport_uid_clause=passport_uid_clause,
                search_fields_clause=" OR ".join(filter_pairs),
            ),
            biz_id,
            client_id,
            *[param.db_value for param in search_params],
        )

    async def merge_client(
        self,
        *,
        client_id: int,
        biz_id: int,
        source: Source,
        metadata: Optional[dict],
        phone: Optional[int],
        email: Optional[str],
        passport_uid: Optional[int],
        first_name: Optional[str],
        last_name: Optional[str],
        gender: Optional[ClientGender],
        comment: Optional[str],
        initiator_id: Optional[int],
    ) -> dict:
        merge_params = []
        if phone:
            merge_params.append(DbParam("phone", phone, str(phone)))
        if email:
            merge_params.append(DbParam("email", email, email))
        if passport_uid:
            merge_params.append(DbParam("passport_uid", passport_uid, passport_uid))
        if first_name:
            merge_params.append(DbParam("first_name", first_name, first_name))
        if last_name:
            merge_params.append(DbParam("last_name", last_name, last_name))
        if gender:
            merge_params.append(DbParam("gender", gender, gender))
        if comment:
            merge_params.append(DbParam("comment", comment, comment))

        clients_set_pairs = []
        revision_fields = []
        revision_params = []
        for param_pos, param in enumerate(merge_params, start=7):
            param_substitution = f"${param_pos}"
            clients_set_pairs.append(f"{param.db_field}={param_substitution}")
            revision_fields.append(param.db_field)
            revision_params.append(param_substitution)

        clients_set_str = ",".join(clients_set_pairs)
        revision_fields_str = ",".join(revision_fields)
        revision_params_str = ",".join(revision_params)
        sql = sqls.merge_client.format(
            clients_filter_str="AND id=$3",
            clients_set_str="," + clients_set_str if clients_set_str else "",
            revision_fields="," + revision_fields_str if revision_fields_str else "",
            revision_params="," + revision_params_str if revision_params_str else "",
        )

        async with self._db.acquire() as con:
            row = await con.fetchrow(
                sql,
                biz_id,
                self._fetch_segment_tracking_dts()[0],
                client_id,
                source.value,
                metadata,
                initiator_id,
                *[param.db_value for param in merge_params],
            )

        client = dict(row)
        client["segments"] = list(map(SegmentType, client["segments"]))
        client["source"] = Source[client["source"]]
        self._normalize_client_stat(client)

        return client

    async def add_order_event(
        self,
        *,
        client_id: int,
        biz_id: int,
        order_id: int,
        event_type: OrderEvent,
        event_timestamp: datetime,
        source: Source,
    ) -> None:
        async with self._db.acquire() as con:
            await con.execute(
                sqls.add_order_event,
                client_id,
                biz_id,
                order_id,
                event_type,
                event_timestamp,
                source.value,
            )

    async def add_call_event(
        self,
        *,
        client_id: int,
        biz_id: int,
        event_type: CallEvent,
        event_value: Optional[str] = None,
        event_timestamp: datetime,
        source: Source,
        # todo(shpak-vadim): required when Facade will be ready
        session_id: Optional[int] = None,
    ) -> None:
        async with self._db.acquire() as con:
            await con.execute(
                sqls.add_call_event,
                client_id,
                biz_id,
                event_type,
                event_value,
                event_timestamp,
                source.value,
                session_id,
            )

    async def list_segments(
        self, biz_id: int
    ) -> Tuple[
        Dict[Literal["current", "previous"], int],
        Dict[SegmentType, Dict[Literal["current_size", "previous_size"], int]],
        Dict[str, int],
    ]:
        async with self._db.acquire() as con:
            segments_result = await con.fetchrow(
                sqls.list_segments,
                biz_id,
                *self._fetch_segment_tracking_dts(),
            )
            label_rows = await con.fetch(sqls.list_labels, biz_id)

        return (
            {
                "current": segments_result["total_clients_count_current"],
                "previous": segments_result["total_clients_count_previous"],
            },
            {
                SegmentType.REGULAR: {
                    "current_size": segments_result["regular_count_current"],
                    "previous_size": segments_result["regular_count_previous"],
                },
                SegmentType.ACTIVE: {
                    "current_size": segments_result["active_count_current"],
                    "previous_size": segments_result["active_count_previous"],
                },
                SegmentType.LOST: {
                    "current_size": segments_result["lost_count_current"],
                    "previous_size": segments_result["lost_count_previous"],
                },
                SegmentType.UNPROCESSED_ORDERS: {
                    "current_size": segments_result["unprocessed_orders_count_current"],
                    "previous_size": segments_result[
                        "unprocessed_orders_count_previous"
                    ],
                },
                SegmentType.NO_ORDERS: {
                    "current_size": segments_result["no_orders_count_current"],
                    "previous_size": segments_result["no_orders_count_previous"],
                },
                SegmentType.SHORT_LAST_CALL: {
                    "current_size": segments_result["short_last_call_count_current"],
                    "previous_size": segments_result["short_last_call_count_previous"],
                },
                SegmentType.MISSED_LAST_CALL: {
                    "current_size": segments_result["missed_last_call_count_current"],
                    "previous_size": segments_result["missed_last_call_count_previous"],
                },
            },
            {row["label"]: row["count"] for row in label_rows},
        )

    async def segment_statistics(
        self, biz_id: int, segment_type: Optional[SegmentType], on_datetime: datetime
    ) -> Dict[datetime, int]:
        _sql = {
            SegmentType.REGULAR: sqls.segments.regular_segment_statistics,
            SegmentType.ACTIVE: sqls.segments.active_segment_statistics,
            SegmentType.LOST: sqls.segments.lost_segment_statistics,
            SegmentType.NO_ORDERS: sqls.segments.no_orders_segment_statistics,
            SegmentType.UNPROCESSED_ORDERS: (
                sqls.segments.unprocessed_orders_segment_statistics
            ),
            SegmentType.SHORT_LAST_CALL: (
                sqls.segments.short_last_call_segment_statistics
            ),
            SegmentType.MISSED_LAST_CALL: (
                sqls.segments.missed_last_call_segment_statistics
            ),
        }.get(segment_type, sqls.segments.all_segment_statistics)

        async with self._db.acquire(PoolType.replica) as con:
            got = await con.fetch(_sql, biz_id, on_datetime)

        return {row["slot"]: row["c"] for row in got}

    async def list_clients_by_segment(
        self, biz_id: int, segment: Optional[SegmentType], label: Optional[str]
    ) -> List[dict]:
        segment_cte_map = {
            SegmentType.ACTIVE: (sqls.active_segment_cte, "active_clients"),
            SegmentType.REGULAR: (sqls.regular_segment_cte, "regular_clients"),
            SegmentType.LOST: (sqls.lost_segment_cte, "lost_clients"),
            SegmentType.UNPROCESSED_ORDERS: (
                sqls.unprocessed_segment_cte,
                "unprocessed_orders_clients",
            ),
            SegmentType.NO_ORDERS: (
                sqls.no_orders_segment_cte,
                "no_orders_clients",
            ),
            SegmentType.SHORT_LAST_CALL: (
                sqls.short_last_call_segment_cte.format(clients_filter_str=""),
                "short_last_call_clients",
            ),
            SegmentType.MISSED_LAST_CALL: (
                sqls.missed_last_call_segment_cte.format(clients_filter_str=""),
                "missed_last_call_clients",
            ),
        }

        async with self._db.acquire() as con:
            if segment:
                result = await con.fetch(
                    sqls.list_clients_by_segment.format(
                        clients_filter_str="",
                        segment_cte=segment_cte_map[segment][0],
                        segment_cte_name=segment_cte_map[segment][1],
                    ),
                    biz_id,
                    self._fetch_segment_tracking_dts()[0],
                )
            else:
                result = await con.fetch(sqls.list_clients_by_label, biz_id, label)

            return [dict(row) for row in result]

    async def list_client_segments(
        self,
        *,
        passport_uid: Optional[int] = None,
        phone: Optional[int] = None,
        email: Optional[str] = None,
        biz_id: Optional[int] = None,
    ) -> List[dict]:
        if not any([passport_uid, phone, email]):
            raise NoClientIdFieldsPassed()

        search_params = []
        if phone:
            search_params.append(DbParam("phone", phone, str(phone)))
        if email:
            search_params.append(DbParam("email", email, email))
        if passport_uid:
            search_params.append(DbParam("passport_uid", passport_uid, passport_uid))

        search_params_pairs = []
        for param_pos, param in enumerate(search_params, start=3):
            search_params_pairs.append(f"{param.db_field}=${param_pos}")

        async with self._db.acquire(PoolType.replica) as con:
            rows = await con.fetch(
                sqls.list_client_segments.format(
                    clients_filter_str=f"AND ({' OR '.join(search_params_pairs)})"
                ),
                biz_id,
                self._fetch_segment_tracking_dts()[0],
                *[param.db_value for param in search_params],
            )

        return [
            dict(
                client_id=row["client_id"],
                biz_id=row["biz_id"],
                segments=list(map(SegmentType, row["segments"])),
                labels=row["labels"],
            )
            for row in rows
        ]

    async def list_client_events(
        self,
        client_id: int,
        biz_id: int,
        datetime_from: Optional[datetime],
        datetime_to: Optional[datetime],
    ) -> dict:
        if datetime_from and datetime_to and datetime_from > datetime_to:
            raise BadClientData(
                f"datetime_from={datetime_from} must be "
                f"less or equal than datetime_to={datetime_to}"
            )

        interval_conditions = ""
        if datetime_from:
            interval_conditions += " AND event_timestamp >= $3"

        if datetime_to:
            interval_conditions += " AND event_timestamp <= $4"

        async with self._db.acquire(PoolType.replica) as con:
            if not await con.fetchval(sqls.client_exists, client_id, biz_id):
                raise UnknownClient(id=client_id, biz_id=biz_id)

            events_rows = await con.fetch(
                sqls.list_call_events.format(interval_conditions=interval_conditions),
                client_id,
                biz_id,
                datetime_from,
                datetime_to,
            )

        events = []
        if events_rows[0]["event_timestamp"] is not None:
            for event_row in events_rows:
                event = dict(
                    timestamp=event_row["event_timestamp"],
                    source=Source[event_row["source"]],
                    type=CallHistoryEvent[event_row["history_type"]],
                )

                if event_row["history_type"] == "ACCEPTED":
                    event["accepted_details"] = {
                        "talk_duration": event_row["talk_duration"]
                    }
                elif event_row["history_type"] == "MISSED":
                    event["missed_details"] = {
                        "await_duration": event_row["await_duration"]
                    }

                events.append(event)

        return dict(
            calls=events,
            events_before=events_rows[0]["events_before"],
            events_after=events_rows[0]["events_after"],
        )

    async def fetch_max_geoproduct_id_for_call_events(self) -> Optional[int]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(sqls.fetch_last_call_event_geoproduct_id)

    async def create_table_import_call_events_tmp(self) -> None:
        async with self._db.acquire(PoolType.master) as con:
            await con.execute(sqls.create_table_import_call_events_tmp)

    async def store_imported_call_events(self, records: List[list]):
        async with self._db.acquire(PoolType.master) as con:
            await con.copy_records_to_table(
                "import_call_events_tmp",
                records=records,
                columns=[
                    "geoproduct_id",
                    "permalink",
                    "client_phone",
                    "event_timestamp",
                    "await_duration",
                    "talk_duration",
                    "event_value",
                    "session_id",
                    "record_url",
                ],
            )

    async def upload_imported_call_events(self, clients: List[list]):
        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                await con.execute(sqls.create_table_call_event_clients_tmp)
                await con.copy_records_to_table(
                    "call_event_clients_tmp",
                    records=clients,
                    columns=[
                        "permalink",
                        "client_phone",
                        "biz_id",
                        "client_id",
                    ],
                )
                await con.execute(
                    sqls.upload_imported_call_events,
                    CallEvent.FINISHED,
                    Source.GEOADV_PHONE_CALL.value,
                )

    async def clear_clients_by_passport(self, passport_uid: int) -> List[dict]:
        async with self._db.acquire(PoolType.master) as con:
            rows = await con.fetch(sqls.clear_clients_by_passport, passport_uid, "GDPR")

            return [dict(row) for row in rows]

    async def check_clients_existence_by_passport(self, passport_uid: int) -> bool:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(
                sqls.check_clients_existence_by_passport, passport_uid
            )

    async def list_contacts(self, client_ids: List[int]) -> List[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            rows = await con.fetch(sqls.list_contacts, client_ids)

            return [dict(row) for row in rows]

    @staticmethod
    def _normalize_client_stat(client: dict):
        client["statistics"] = {
            "orders": dict(
                total=client.pop("stat_order_total"),
                successful=client.pop("stat_order_successful"),
                unsuccessful=client.pop("stat_order_unsuccessful"),
                last_order_timestamp=client.pop("stat_order_last_created_ts"),
            )
        }

    @classmethod
    def _normalize_list_clients_result(
        cls, result_rows: List[asyncpg.Record]
    ) -> [int, List[dict]]:
        if not result_rows:
            return 0, []

        total_count = result_rows[0]["total_count"]
        if len(result_rows) == 1 and result_rows[0]["id"] is None:
            return total_count, []

        clients = [dict(row) for row in result_rows]
        for client in clients:
            client.pop("total_count")
            cls._normalize_client_stat(client)
            client["source"] = Source[client["source"]]
        return total_count, clients

    def _fetch_segment_tracking_dts(self) -> Tuple[datetime, datetime, datetime]:
        now = datetime.now(tz=timezone.utc)
        earlier = now - timedelta(days=self.PREVIOUS_STATE_LAG_DAYS)
        return (
            now - timedelta(days=self.SEGMENTS_TRACK_PERIOD_DAYS),
            earlier - timedelta(days=self.SEGMENTS_TRACK_PERIOD_DAYS),
            earlier,
        )
