import json
import logging
from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import timedelta
from decimal import Decimal
from typing import AsyncGenerator, Dict, List, Optional

from asyncpg import Record, UniqueViolationError
from smb.common.pgswim import PoolType, SwimEngine

from maps_adv.geosmb.clients.market import ClientAction

from . import sqls
from .enums import Feature, LandingVersion, ServiceItemType
from .exceptions import NoDataForBizId, SlugInUse, VersionDoesNotExist, ToMuchSlugs
from .timing import async_time_diff

MAX_NUM_OF_BRANCHES = 12


class BaseDataManager(ABC):
    __slots__ = ()

    @abstractmethod
    async def fetch_biz_state(
        self, biz_id: int
    ) -> Optional[dict]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_biz_state_by_slug(
        self, slug: str
    ) -> Optional[dict]:
        raise NotImplementedError

    @abstractmethod
    async def check_slug_is_free(
        self, slug: str, biz_id: int
    ) -> bool:
        raise NotImplementedError

    @abstractmethod
    async def create_biz_state(self, biz_id: int, slug: str, permalink: str) -> None:
        raise NotImplementedError

    @abstractmethod
    async def update_biz_state_slug(self, biz_id: int, slug: str) -> None:
        raise NotImplementedError

    @abstractmethod
    async def update_biz_state_permalink(self, biz_id: int, permalink: str) -> None:
        raise NotImplementedError

    @abstractmethod
    async def update_permalink_from_geosearch(
        self, old_permalink: str, new_permalink: str
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def update_biz_state_set_blocked(
        self, biz_id: int, is_blocked: bool, blocking_data: Optional[dict]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def fetch_published_slugs(self, offset: int, limit: int) -> List[str]:
        raise NotImplementedError

    @abstractmethod
    async def save_landing_data_for_biz_id(
        self,
        biz_id: int,
        landing_data: dict,
        version: LandingVersion,
        updated_from_geosearch: Optional[bool] = False,
    ) -> dict:
        raise NotImplementedError

    @abstractmethod
    async def delete_landing_by_biz_id(self, biz_id: int) -> None:
        raise NotImplementedError

    @abstractmethod
    async def fetch_landing_data_for_crm(
        self, biz_id: int, version: LandingVersion
    ) -> dict:
        raise NotImplementedError

    @abstractmethod
    async def fetch_landing_data_by_slug(
        self, slug: str, version: LandingVersion
    ) -> Optional[dict]:
        raise NotImplementedError

    @abstractmethod
    async def set_landing_publicity(self, biz_id: int, is_published: bool):
        raise NotImplementedError

    @abstractmethod
    async def import_promos_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_promoted_cta_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_promoted_service_lists_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_promoted_services_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_call_tracking_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_google_counters_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_market_int_services_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_tiktok_pixels_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_goods_data_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_vk_pixels_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def sync_permalinks_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def fetch_org_promos(self, biz_id: int) -> Dict[str, List[dict]]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_promoted_cta(self, biz_id: int) -> Optional[dict]:
        raise NotImplementedError

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

    @abstractmethod
    async def fetch_substitution_phone(self, biz_id: int) -> Optional[str]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_cached_landing_config(self) -> Optional[dict]:
        raise NotImplementedError

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

    @abstractmethod
    async def fetch_cached_landing_config_feature(
        self, feature: Feature
    ) -> Optional[str]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_google_counters_for_permalink(
        self, permalink: int
    ) -> Optional[dict]:
        raise NotImplementedError

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

    @abstractmethod
    async def fetch_tiktok_pixels_for_permalink(self, permalink: int) -> Optional[dict]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_vk_pixels_for_permalink(self, permalink: int) -> Optional[dict]:
        raise NotImplementedError

    @abstractmethod
    async def create_instagram_landing(
        self,
        biz_id: int,
        permalink: int,
        slug: str,
        instagram: dict,
        preferences: dict,
        settings: Optional[dict] = None,
        contacts: dict = {},
    ) -> bool:
        raise NotImplementedError

    @abstractmethod
    async def save_instagram_landing(
        self,
        landing_id: int,
        instagram: Optional[dict] = None,
        preferences: Optional[dict] = None,
        settings: Optional[dict] = None,
        contacts: Optional[dict] = None,
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def import_avatars_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def fetch_avatars(self, source_urls: List[str]) -> Dict[str, tuple]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_landing_phone(self, permalink: str) -> Optional[str]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_all_published_permalinks(
        self, offset: Optional[int] = 0, limit: Optional[int] = 100
    ) -> List[str]:
        raise NotImplementedError

    @abstractmethod
    async def fetch_branches_for_permalink(
        self, permalink: str, version: int, chain_id: int
    ) -> List[dict]:
        raise NotImplementedError

    async def update_landing_data_with_geosearch(
        self,
        permalink: str,
        chain_id: int,
        contacts: dict,
        schedule: dict,
        photos: list,
        metrika_counter: int,
    ) -> None:
        raise NotImplementedError

    async def update_instagram_landing_data_with_geosearch(
        self,
        permalink: str,
        geo: dict,
        metrika_counter: int,
    ) -> None:
        raise NotImplementedError

    async def fetch_landing_photos(
        self,
        biz_id: int,
        version: LandingVersion,
    ) -> List[dict]:
        raise NotImplementedError

    async def hide_landing_photos(
        self,
        biz_id: int,
        version: LandingVersion,
        photo_id_to_hidden: Dict[str, bool],
    ) -> None:
        raise NotImplementedError

    @abstractmethod
    async def fetch_goods_data_for_permalink(self, permalink: int) -> Optional[List]:
        raise NotImplementedError

    def filter_photos(self, photos, settings):
        if photos:
            settings = settings or {}
            hidden_photo_ids = set(
                photo_id for photo_id in settings.get("hidden_ids", [])
            )
            normalized_photos = []
            for photo in photos:
                if isinstance(photo, str):
                    normalized_photos.append(photo)
                elif isinstance(photo, dict) and photo["id"] not in hidden_photo_ids:
                    normalized_photos.append(photo["url"])
            return normalized_photos
        else:
            return photos


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

    _db: SwimEngine

    def __init__(self, db: SwimEngine):
        self._db = db

    @async_time_diff
    async def fetch_biz_state(
        self, biz_id: int
    ) -> Optional[dict]:
        if biz_id is None:
            raise ValueError("biz_id is required")

        async with self._db.acquire(PoolType.replica) as con:
            row = await con.fetchrow(sqls.fetch_biz_state_by_biz_id, biz_id)

        return dict(row) if row else None

    @async_time_diff
    async def fetch_biz_state_by_slug(
        self, slug: str
    ) -> Optional[dict]:
        if slug is None:
            raise ValueError("slug is required")

        async with self._db.acquire(PoolType.replica) as con:
            row = await con.fetchrow(sqls.fetch_biz_state_by_slug, slug)

        return dict(row) if row else None

    async def check_slug_is_free(
        self, slug: str, biz_id: int
    ) -> bool:
        if slug is None:
            raise ValueError("slug is required")

        async with self._db.acquire(PoolType.replica) as con:
            return not await con.fetchval(sqls.check_slug_is_in_use, slug, biz_id)

    async def create_biz_state(self, biz_id: int, slug: str, permalink: str) -> None:
        async with self._db.acquire(PoolType.master) as con:
            return await con.fetchval(sqls.create_biz_state, biz_id, slug, permalink)

    async def update_biz_state_slug(self, biz_id: int, slug: str) -> None:
        async with self._db.acquire(PoolType.master) as con:
            row = await con.fetchrow(sqls.alias_for_slug, slug, biz_id)
            if row:
                raise SlugInUse

            cnt = await con.fetchval(sqls.aliases_cnt, biz_id)
            if cnt and cnt > 4:
                raise ToMuchSlugs

            try:
                row_updated = await con.fetchval(
                    sqls.update_biz_state_slug, biz_id, slug
                )
            except UniqueViolationError:
                raise SlugInUse

        if not row_updated:
            raise NoDataForBizId

    @async_time_diff
    async def update_biz_state_permalink(self, biz_id: int, permalink: str) -> None:
        async with self._db.acquire(PoolType.master) as con:
            row_updated = await con.fetchval(
                sqls.update_biz_state_permalink, biz_id, permalink
            )

            if not row_updated:
                raise NoDataForBizId

    async def update_biz_state_set_blocked(
        self, biz_id: int, is_blocked: bool, blocking_data: Optional[dict]
    ) -> None:
        async with self._db.acquire(PoolType.master) as con:
            row_updated = await con.fetchval(
                sqls.update_biz_state_set_blocked,
                biz_id,
                is_blocked,
                blocking_data,
            )

            if not row_updated:
                raise NoDataForBizId

    async def update_permalink_from_geosearch(
        self, old_permalink: str, new_permalink: str
    ) -> None:
        async with self._db.acquire(PoolType.master) as con:
            await con.fetchval(
                sqls.update_permalink_from_geosearch, old_permalink, new_permalink
            )

    async def fetch_published_slugs(self, offset: int, limit: int) -> List[str]:
        async with self._db.acquire(PoolType.replica) as con:
            rows = await con.fetch(sqls.fetch_published_slugs, offset, limit)
            return [r["slug"] for r in rows]

    async def save_landing_data_for_biz_id(
        self,
        biz_id: int,
        landing_data: dict,
        version: LandingVersion,
        updated_from_geosearch: Optional[bool] = False,
    ) -> dict:
        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                row = await con.fetchrow(
                    sqls.fetch_landing_extra_data, biz_id, version.value
                )
                old_data = dict(row) if row else {}

                landing_data: dict = self._prepare_landing_data(landing_data, old_data)
                updated_data = await con.fetchrow(
                    sqls.set_landing_data,
                    biz_id,
                    version.value,
                    landing_data["name"],
                    landing_data["categories"],
                    landing_data.get("description"),
                    landing_data.get("logo"),
                    landing_data.get("cover"),
                    landing_data["contacts"],
                    landing_data.get("extras", {}),
                    landing_data["preferences"],
                    landing_data["blocks_options"],
                    old_data.get("photos"),
                    old_data.get("photo_settings"),
                    old_data.get("is_updated_from_geosearch") or updated_from_geosearch,
                    old_data.get("schedule"),
                )

                if not updated_data:
                    raise NoDataForBizId

        updated_data = self._compose_landing_details(updated_data)
        updated_data["version"] = LandingVersion(updated_data["version"])

        return {
            "slug": updated_data.pop("slug"),
            "landing_details": updated_data,
            "is_published": updated_data.pop("published"),
        }

    async def delete_landing_by_biz_id(self, biz_id: int) -> None:
        async with self._db.acquire(PoolType.master) as con:
            row_updated = await con.fetchval(
                sqls.delete_landing,
                biz_id,
            )

            if not row_updated:
                raise NoDataForBizId

    async def fetch_landing_data_for_crm(
        self, biz_id: int, version: LandingVersion
    ) -> dict:
        async with self._db.acquire(PoolType.replica) as con:
            row = await con.fetchrow(
                sqls.fetch_landing_data_for_crm, biz_id, version.value
            )

        if row is None:
            raise NoDataForBizId

        if row["name"] is None:
            raise VersionDoesNotExist

        row = self._compose_landing_details(row)
        row["version"] = version
        return {
            "slug": row.pop("slug"),
            "landing_details": row,
            "is_published": row.pop("published"),
        }

    @async_time_diff
    async def fetch_landing_data_by_slug(
        self, slug: str, version: LandingVersion
    ) -> Optional[dict]:
        # Use master for unstable version fetching because it might be created
        # very recently and might not be replicated yet
        pool_type = (
            PoolType.master if version is LandingVersion.UNSTABLE else PoolType.replica
        )

        async with self._db.acquire(pool_type) as con:
            row = await con.fetchrow(sqls.fetch_landing_data, slug, version.value)

        if row is None:
            return None

        return self._compose_landing_details(row)

    async def set_landing_publicity(self, biz_id: int, is_published: bool):
        async with self._db.acquire(PoolType.master) as con:
            await con.execute(sqls.set_landing_publicity, biz_id, is_published)

    async def import_promos_from_yt(self, generator: AsyncGenerator[List[tuple], None]):
        await self._import_data(
            "promotions",
            [
                "promotion_id",
                "biz_id",
                "announcement",
                "description",
                "date_from",
                "date_to",
                "banner_img",
                "link",
            ],
            generator,
            sequence_name="promotions_id_seq",
            seq_dependent_column="id",
        )

    async def import_promoted_cta_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ):
        await self._import_data(
            "promoted_cta",
            ["cta_id", "biz_id", "title", "link"],
            generator,
            sequence_name="promoted_cta_id_seq",
            seq_dependent_column="id",
        )

    @async_time_diff
    async def fetch_org_promos(self, biz_id: int) -> Dict[str, List[dict]]:
        async with self._db.acquire(PoolType.replica) as con:
            rows = await con.fetch(sqls.fetch_org_promos, biz_id)

            return {"promotion": [dict(r) for r in rows]}

    @async_time_diff
    async def fetch_promoted_cta(self, biz_id: int) -> Optional[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            row = await con.fetchrow(sqls.fetch_promoted_cta, biz_id)

            return dict(row) if row else None

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

            return [{"type": ServiceItemType.PROMOTED, **r} for r in rows]

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

    async def import_promoted_service_lists_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "promoted_service_lists",
            ["list_id", "biz_id", "services"],
            generator,
            sequence_name="promoted_service_lists_id_seq",
            seq_dependent_column="id",
        )

    async def import_promoted_services_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "promoted_services",
            [
                "service_id",
                "biz_id",
                "title",
                "cost",
                "image",
                "url",
                "description",
            ],
            generator,
            sequence_name="promoted_services_id_seq",
            seq_dependent_column="id",
        )

    async def import_call_tracking_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "call_tracking",
            ["phone_id", "biz_id", "formatted_phone"],
            generator,
            sequence_name="call_tracking_id_seq",
            seq_dependent_column="id",
        )

    async def import_google_counters_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "google_counters",
            ["permalink", "counter_data"],
            generator,
            jsonb_column="counter_data",
            sequence_name="google_counters_id_seq",
            seq_dependent_column="id",
        )

    async def import_market_int_services_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "market_int_services",
            ["service_id", "biz_id", "service_data"],
            generator,
            jsonb_column="service_data",
        )

    async def import_tiktok_pixels_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "tiktok_pixels",
            ["permalink", "pixel_data"],
            generator,
            jsonb_column="pixel_data",
        )

    async def import_goods_data_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "goods_data",
            ["permalink", "categories"],
            generator,
            jsonb_column="categories",
        )

    async def import_vk_pixels_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "vk_pixels",
            ["permalink", "pixel_data"],
            generator,
            jsonb_column="pixel_data",
        )

    async def sync_permalinks_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        tmp_table_name = "tmp_sync_permalinks_from_yt"
        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                await con.execute(
                    f"""
                    CREATE TEMPORARY TABLE {tmp_table_name} (
                        biz_id bigint not null,
                        permalink bigint not null)
                    ON COMMIT DROP
                    """
                )
                async for records in generator:
                    await con.copy_records_to_table(
                        tmp_table_name,
                        records=records,
                        columns=["biz_id", "permalink"],
                    )
                res = await con.execute(sqls.sync_permalinks.format(tmp_table_name=tmp_table_name))
                logging.getLogger(__name__).info("Synced permalinks: %s", res)

    async def _import_data(
        self,
        table_name: str,
        columns: List[str],
        generator: AsyncGenerator[List[tuple], None],
        jsonb_column: Optional[str] = None,
        sequence_name: Optional[str] = None,
        seq_dependent_column: Optional[str] = None,
    ) -> None:
        tmp_table_name = table_name + "_tmp"
        new_table_name = table_name + "_new"

        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                if jsonb_column is not None:
                    # copy_records_to_table does not work well with jsonb
                    # so we use a temporary table to save the data, then convert it
                    await con.execute(
                        f"""
                            CREATE TEMPORARY TABLE {tmp_table_name} (LIKE {table_name} INCLUDING ALL)
                            ON COMMIT DROP
                        """
                    )
                    await con.execute(
                        f"ALTER TABLE {tmp_table_name} ALTER COLUMN {jsonb_column} TYPE text"
                    )

                await con.execute(
                    f"CREATE TABLE {new_table_name} (LIKE {table_name} INCLUDING ALL)"
                )

                async for records in generator:
                    await con.copy_records_to_table(
                        tmp_table_name if jsonb_column is not None else new_table_name,
                        records=records,
                        columns=columns,
                    )

                if jsonb_column is not None:
                    await con.execute(
                        f"""
                            INSERT INTO {new_table_name}({",".join(columns)})
                            SELECT {",".join([column_name if column_name != jsonb_column
                                                          else column_name+"::jsonb"
                                              for column_name in columns])}
                            FROM {tmp_table_name}
                        """
                    )

                await con.execute(
                    f"ALTER TABLE {table_name} RENAME TO {table_name}_old"
                )
                await con.execute(
                    f"ALTER TABLE {new_table_name} RENAME TO {table_name}"
                )
                if sequence_name is not None:
                    await con.execute(
                        f"ALTER SEQUENCE {sequence_name} OWNED BY {table_name}.{seq_dependent_column}"
                    )
                await con.execute(f"DROP TABLE {table_name}_old")

    def _compose_landing_details(self, row: Record) -> dict:
        data = dict(row)
        data["contacts"].setdefault("geo", {})["permalink"] = data["permalink"]
        if data["contacts"]["geo"].get("lat") is not None:
            data["contacts"]["geo"]["lat"] = Decimal(data["contacts"]["geo"]["lat"])
            data["contacts"]["geo"]["lon"] = Decimal(data["contacts"]["geo"]["lon"])
        if (
            data.get("schedule") is not None
            and data["schedule"].get("tz_offset") is not None
        ):
            hours, minutes, seconds = map(
                float, data["schedule"]["tz_offset"].split(":")
            )
            data["schedule"]["tz_offset"] = timedelta(
                hours=hours, minutes=minutes, seconds=seconds
            )

        data["photos"] = self.filter_photos(
            photos=data.get("photos") or [], settings=data.get("photo_settings") or {}
        )

        return data

    async def fetch_cached_landing_config(self) -> Optional[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval("SELECT data from cached_landing_config")

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

    @async_time_diff
    async def create_instagram_landing(
        self,
        biz_id: int,
        permalink: int,
        slug: str,
        instagram: dict,
        preferences: dict,
        settings: dict = {},
        contacts: dict = {},
    ) -> bool:
        logging.getLogger(__name__).info("create_instagram_landing %d %s", biz_id, slug)
        try:

            async with self._db.acquire(PoolType.master) as con:
                async with con.transaction():
                    await con.execute(
                        sqls.create_instagram_landing,
                        biz_id,
                        slug,
                        settings.get("name") or "",
                        instagram,
                        preferences,
                        settings.get("logo"),
                        settings.get("description"),
                        str(permalink),
                        contacts,
                    )
                    return True
        except UniqueViolationError:
            return False

    @async_time_diff
    async def save_instagram_landing(
        self,
        landing_id: int,
        instagram: Optional[dict] = None,
        preferences: Optional[dict] = None,
        settings: Optional[dict] = None,
        contacts: Optional[dict] = None,
    ) -> None:
        logging.getLogger(__name__).info("save_instagram_landing %d", landing_id)
        settings = settings or {}
        async with self._db.acquire(PoolType.master) as con:
            await con.execute(
                sqls.save_instagram_landing,
                landing_id,
                instagram,
                preferences,
                settings.get("name"),
                settings.get("description"),
                settings.get("logo"),
                contacts,
            )

    async def import_avatars_from_yt(
        self, generator: AsyncGenerator[List[tuple], None]
    ) -> None:
        await self._import_data(
            "avatars",
            ["source_url", "avatars_group_id", "avatars_name"],
            generator,
        )

    async def fetch_avatars(self, source_urls: List[str]) -> Dict[str, tuple]:
        async with self._db.acquire(PoolType.replica) as con:
            rows = await con.fetch(sqls.fetch_avatars, source_urls)
            return {row[0]: (row[1], row[2]) for row in rows}

    async def fetch_cached_landing_config_feature(
        self, feature: Feature
    ) -> Optional[str]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(
                "SELECT data->'features'->>$1 from cached_landing_config", feature.value
            )

    async def fetch_google_counters_for_permalink(
        self, permalink: int
    ) -> Optional[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(sqls.fetch_google_counters, permalink)

    async def fetch_tiktok_pixels_for_permalink(self, permalink: int) -> Optional[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(sqls.fetch_tiktok_pixels, permalink)

    async def fetch_vk_pixels_for_permalink(self, permalink: int) -> Optional[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(sqls.fetch_vk_pixels, permalink)

    async def fetch_landing_phone(self, permalink: str) -> Optional[str]:
        async with self._db.acquire(PoolType.replica) as con:
            return await con.fetchval(sqls.fetch_landing_phone, permalink)

    async def fetch_all_published_permalinks(
        self, offset: Optional[int] = 0, limit: Optional[int] = 100
    ) -> List[str]:
        async with self._db.acquire(PoolType.replica) as con:
            result = await con.fetch(sqls.fetch_all_published_permalinks, offset, limit)
            return [rec["permalink"] for rec in result]

    async def fetch_branches_for_permalink(
        self, permalink: str, version: LandingVersion, chain_id: int
    ) -> List[dict]:

        async with self._db.acquire(PoolType.replica) as con:
            result = [
                self._compose_landing_details(record)
                for record in await con.fetch(
                    sqls.fetch_branches_for_permalink,
                    permalink,
                    version.value,
                    chain_id,
                    MAX_NUM_OF_BRANCHES,
                )
            ]
            return result

    async def update_landing_data_with_geosearch(
        self,
        permalink: str,
        chain_id: int,
        contacts: dict,
        schedule: dict,
        photos: list,
        metrika_counter: str,
    ) -> None:
        def default_type_error_handler(obj):
            if isinstance(obj, Decimal) or isinstance(obj, timedelta):
                return str(obj)
            raise TypeError

        logging.getLogger(__name__).info(
            f"UPDATE GEOSEARCH DATA for {permalink}: contacts {contacts} sch {schedule} pho {photos}"
        )

        schedule_val: str = json.loads(
            json.dumps(schedule, default=default_type_error_handler)
        )

        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                rows = await con.fetch(sqls.fetch_landings_for_permalink, permalink)
                for row in rows:
                    data_id = row["id"]
                    contacts = self._compose_contacts(dict(row["contacts"]), contacts)
                    preferences = self._compose_preferences(
                        dict(row["preferences"]), contacts, metrika_counter
                    )

                    await con.fetch(
                        sqls.update_landing_data_with_geosearch,
                        data_id,
                        chain_id,
                        json.loads(
                            json.dumps(contacts, default=default_type_error_handler)
                        ),
                        schedule_val,
                        photos,
                        json.loads(
                            json.dumps(preferences, default=default_type_error_handler)
                        ),
                    )
                return

    async def update_instagram_landing_data_with_geosearch(
        self,
        permalink: str,
        geo: dict,
        metrika_counter: str,
    ) -> None:
        def default_type_error_handler(obj):
            if isinstance(obj, Decimal) or isinstance(obj, timedelta):
                return str(obj)
            raise TypeError

        logging.getLogger(__name__).info(
            f"UPDATE INSTA GEOSEARCH DATA for {permalink}: geo {geo}"
        )

        async with self._db.acquire(PoolType.master) as con:
            return await con.fetch(
                sqls.update_instagram_landing_data_with_geosearch,
                permalink,
                json.loads(json.dumps(geo, default=default_type_error_handler)),
                metrika_counter,
            )

    @staticmethod
    def _normalize_photo(photo: dict, hidden_photo_ids: set) -> dict:
        if isinstance(photo, str):
            # TODO: remove this clause once all landing_data.photos
            #  get transformed into the structure with photo id's
            photo = {"url": photo}
        if photo_id := photo.get("id"):
            photo["hidden"] = photo_id in hidden_photo_ids
        else:
            photo["hidden"] = False
        return photo

    async def fetch_landing_photos(
        self, biz_id: int, version: LandingVersion
    ) -> List[dict]:
        async with self._db.acquire(PoolType.replica) as con:
            row = await con.fetchrow(sqls.fetch_landing_photos, biz_id, version.value)

            if not row:
                raise NoDataForBizId

            photos = row.get("photos") or []
            settings = row.get("photo_settings") or {}
            hidden_photo_ids = set(
                photo_id for photo_id in settings.get("hidden_ids", [])
            )
            return [self._normalize_photo(photo, hidden_photo_ids) for photo in photos]

    async def hide_landing_photos(
        self,
        biz_id: int,
        version: LandingVersion,
        photo_id_to_hidden: Dict[str, bool],
    ) -> None:
        async with self._db.acquire(PoolType.master) as con:
            async with con.transaction():
                row = await con.fetchrow(
                    sqls.fetch_landing_photos, biz_id, version.value
                )

                if not row:
                    raise NoDataForBizId

                settings = row.get("photo_settings") or {}
                cur_hidden_photo_ids = settings.get("hidden_ids") or set()
                hidden_photo_ids = set(cur_hidden_photo_ids)

                for photo_id, hidden in photo_id_to_hidden.items():
                    if not hidden and photo_id in hidden_photo_ids:
                        hidden_photo_ids.remove(photo_id)
                    elif hidden:
                        hidden_photo_ids.add(photo_id)

                if cur_hidden_photo_ids != hidden_photo_ids:
                    return await con.fetchval(
                        sqls.hide_landing_photos,
                        biz_id,
                        version.value,
                        {"hidden_ids": list(hidden_photo_ids)}
                        if hidden_photo_ids
                        else None,
                    )

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

            return [self._compose_market_int_service(r) for r in rows]

    @staticmethod
    def _compose_market_int_service(row: Record) -> dict:
        data = dict(row["service_data"])
        data["type"] = ServiceItemType.MARKET
        data["min_cost"] = Decimal(data["min_cost"]) if data.get("min_cost") else None
        data["min_duration"] = (
            Decimal(data["min_duration"]) if data.get("min_duration") else None
        )
        data["action_type"] = (
            ClientAction(data["action_type"].upper())
            if data.get("action_type")
            else None
        )

        return data

    async def fetch_goods_data_for_permalink(self, permalink: int) -> Optional[List]:
        async with self._db.acquire(PoolType.replica) as con:
            result = await con.fetchval(sqls.fetch_goods_data, permalink)
            return {
                "goods_available": result is not None,
                **(dict(result) if result else {}),
            }

    @staticmethod
    def _prepare_landing_data(landing_data: dict, old_data: dict) -> dict:
        old_geo: Optional[dict] = (old_data.get("contacts") or {}).get("geo")
        old_external_metrika_code = (old_data.get("preferences") or {}).get(
            "external_metrika_code"
        )

        contacts: Optional[dict] = landing_data.get("contacts")
        preferences: dict = landing_data["preferences"]

        if contacts:
            phone: Optional[str] = contacts.pop("phone", None)
            phones: Optional[List[str]] = contacts.get("phones")
            geo = contacts.pop("geo", None) or old_geo
            if geo:
                contacts["geo"] = geo
            if not phones:
                phone = None
            elif phone is None or phone not in phones:
                phone = phones[0]
            # else keep phone as is
            contacts["phone"] = phone
            landing_data["contacts"] = contacts

            cta: Optional[dict] = preferences.get("cta_button")

            if phone and cta and cta.get("predefined") == "CALL":
                cta["value"] = phone
                preferences["cta_button"] = cta

        if old_external_metrika_code:
            preferences["external_metrika_code"] = old_external_metrika_code

        landing_data["preferences"] = preferences

        return landing_data

    @staticmethod
    def _compose_contacts(old_contacts: Optional[dict], new_contacts: dict):
        phones: Optional[List[str]] = new_contacts.get("phones")
        old_phone: Optional[str] = old_contacts.get("phone")
        contacts = deepcopy(new_contacts)

        if phones and old_phone in phones:
            contacts["phone"] = old_phone

        return contacts

    @staticmethod
    def _compose_preferences(
        old_preferences: dict, contacts: Optional[dict], metrika_counter: str
    ) -> dict:
        preferences: dict = deepcopy(old_preferences)
        cta: Optional[dict] = preferences.get("cta_button")
        phone: Optional[str] = contacts.get("phone")

        if cta and cta.get("predefined") == "CALL" and phone:
            cta["value"] = phone
            preferences["cta_button"] = cta

        preferences["external_metrika_code"] = metrika_counter

        return preferences
