import json
from base64 import b64decode
from datetime import timedelta
from decimal import Decimal
from enum import Enum
from operator import attrgetter, itemgetter
from typing import Dict, List, Optional, Tuple

import maps.doc.proto.yandex.maps.proto.photos.photos2_pb2 as photos2
import snappy
import yandex.maps.proto.atom.atom_pb2 as atom
from google.protobuf.message import Message
from smb.common.aiotvm import HttpClientWithTvm
from smb.common.http_client import collect_errors
from yandex.maps.proto.common2 import geo_object_pb2, response_pb2
from yandex.maps.proto.search import (
    business_images_pb2,
    business_internal_pb2,
    business_pb2,
    business_rating_pb2,
    experimental_pb2,
    hours_pb2,
    kind_pb2,
    metrika_pb2,
    photos_2x_pb2,
)

__all__ = ["GeoSearchClient", "GeoSearchOrgResult", "AddressComponent"]


class AddressComponent(Enum):
    UNKNOWN = "unknown"
    COUNTRY = "country"
    REGION = "region"
    PROVINCE = "province"
    AREA = "area"
    LOCALITY = "locality"
    DISTRICT = "district"
    STREET = "street"
    HOUSE = "house"
    ROUTE = "route"
    STATION = "station"
    METRO_STATION = "metro_station"
    RAILWAY_STATION = "railway_station"
    VEGETATION = "vegetation"
    HYDRO = "hydro"
    AIRPORT = "airport"
    OTHER = "other"
    ENTRANCE = "entrance"


ADDRESS_COMPONENT_PB_ENUM_MAP = {
    kind_pb2.Kind.Value(ac_enum.name): ac_enum for ac_enum in AddressComponent
}


class GeoSearchOrgResult:
    geo_object: geo_object_pb2.GeoObject
    metas: Dict[str, Message]

    def __init__(self, geo_object: geo_object_pb2.GeoObject):
        self.geo_object = geo_object
        self.metas = {}
        self._extract_metas()

    def _extract_metas(self):
        ext_map = {
            business_pb2.GEO_OBJECT_METADATA: "business",
            photos_2x_pb2.GEO_OBJECT_METADATA: "photos",
            business_images_pb2.GEO_OBJECT_METADATA: "images",
            business_rating_pb2.GEO_OBJECT_METADATA: "rating",
            metrika_pb2.GEO_OBJECT_METADATA: "metrika",
            experimental_pb2.GEO_OBJECT_METADATA: "experimental",
        }
        for meta in self.geo_object.metadata:
            for ext_name, local_name in ext_map.items():
                if meta.HasExtension(ext_name):
                    self.metas.setdefault(local_name, meta.Extensions[ext_name])

    @property
    def permalink(self) -> str:
        return self.metas["business"].id

    @property
    def name(self) -> str:
        return self.metas["business"].name

    @property
    def address_components(self) -> Dict[AddressComponent, str]:
        components = self.metas["business"].address.component
        return {
            ADDRESS_COMPONENT_PB_ENUM_MAP[kind]: item.name
            for item in components
            for kind in item.kind
        }

    @property
    def formatted_address(self) -> str:
        return self.metas["business"].address.formatted_address

    @property
    def country_code(self) -> str:
        return self.metas["business"].address.country_code

    @property
    def postal_code(self) -> str:
        return self.metas["business"].address.postal_code

    @property
    def categories_names(self) -> List[str]:
        return list(cat.name for cat in self.metas["business"].category)

    @property
    def formatted_phones(self) -> List[str]:
        return list(phone.formatted for phone in self.metas["business"].phone)

    @property
    def formatted_callable_phones(self) -> List[str]:
        return list(
            phone.formatted
            for phone in self.metas["business"].phone
            if phone.type
            in (business_pb2.Phone.Type.PHONE, business_pb2.Phone.Type.PHONE_FAX)
        )

    @property
    def links(self) -> List[str]:
        return list(link.link.href for link in self.metas["business"].link)

    @property
    def own_links(self) -> List[str]:
        return list(
            link.link.href for link in self.metas["business"].link if link.tag == "self"
        )

    @property
    def social_links(self) -> Dict[str, str]:
        result = {}

        for link in self.metas["business"].link:
            if link.tag == "social":
                result.setdefault(link.aref.lstrip("#"), link.link.href)

        return result

    @property
    def emails(self) -> List[str]:
        result = []

        if self.metas["business"].HasExtension(business_internal_pb2.COMPANY_INFO):
            result = list(
                email
                for email in self.metas["business"]
                .Extensions[business_internal_pb2.COMPANY_INFO]
                .email
            )

        return result

    @property
    def tz_offset(self) -> Optional[timedelta]:
        if self.metas["business"].HasField("open_hours") and self.metas[
            "business"
        ].open_hours.HasField("tz_offset"):
            return timedelta(seconds=self.metas["business"].open_hours.tz_offset)

    @property
    def open_hours(self) -> Optional[List[Tuple[int, int]]]:
        if not self.metas["business"].HasField("open_hours"):
            return None

        open_hours = []
        for hours in self.metas["business"].open_hours.hours:
            # Transform to: Monday is 0, Sunday is 6
            days = self._parse_pb_days(hours)

            for day in days:
                open_hours.extend(
                    self._parse_pb_open_hours_for_day(hours=hours, day=day)
                )

        return self._compose_schedule_from_open_hours(
            sorted(open_hours, key=itemgetter(0))
        )

    @staticmethod
    def _parse_pb_days(hours: hours_pb2.Hours) -> List[int]:
        # Transform to: Monday is 0, Sunday is 6
        return (
            list(range(0, 7))
            if hours_pb2.DayOfWeek.Value("EVERYDAY") in hours.day
            else list((day - 1) % 7 for day in hours.day)
        )

    @staticmethod
    def _parse_pb_open_hours_for_day(
        hours: hours_pb2.Hours, day: int
    ) -> List[Tuple[int, int]]:
        open_hours = []

        day_start, day_end = 86400 * day, 86400 * (day + 1)
        for time_range in hours.time_range:
            if time_range.HasField("all_day") and time_range.all_day:
                open_hours.append((day_start, day_end))
                break

            from_, to = attrgetter("from", "to")(time_range)
            if from_ < to:
                open_hours.append((day_start + from_, day_start + to))
            elif from_ > to:
                if day != 6:
                    open_hours.append((day_start + from_, day_end + to))
                else:
                    open_hours.append((day_start + from_, day_end))
                    open_hours.append((0, to))

        return open_hours

    @staticmethod
    def _compose_schedule_from_open_hours(
        open_hours: List[Tuple[int, int]]
    ) -> Optional[List[Tuple[int, int]]]:
        if not open_hours:
            return None

        consolidated_open_hours = []

        prev_start, prev_end = open_hours[0]
        for hours in open_hours[1:]:
            start, end = hours
            if start == prev_end:
                prev_end = end
            else:
                consolidated_open_hours.append((prev_start, prev_end))
                prev_start, prev_end = start, end

        consolidated_open_hours.append((prev_start, prev_end))

        # will have (0, 0) interval if working hours are like
        # EVERYDAY from 12 till 0. Need to remove it.
        return (
            consolidated_open_hours
            if consolidated_open_hours[0] != (0, 0)
            else consolidated_open_hours[1:]
        )

    @property
    def coords(self) -> Optional[Tuple[Decimal, Decimal]]:
        try:
            geometry = self.geo_object.geometry[0]
        except IndexError:
            return None

        if not geometry.HasField("point"):
            return None

        return Decimal(str(geometry.point.lat)), Decimal(str(geometry.point.lon))

    @property
    def latitude(self) -> Optional[Decimal]:
        return self.coords[0] if self.coords else None

    @property
    def longitude(self) -> Optional[Decimal]:
        return self.coords[1] if self.coords else None

    @property
    def photos(self) -> List[dict]:
        experimental_metas = self.metas.get("experimental")
        if not experimental_metas or not self.metas["experimental"].HasField(
            "experimental_storage"
        ):
            return []

        photos = []
        for item in experimental_metas.experimental_storage.item:
            if item.key == "sprav_proto_photos":
                feed = atom.Feed()
                feed.ParseFromString(snappy.uncompress(b64decode(item.value)))
                for entry in feed.entry:
                    photo_entry = entry.Extensions[photos2.ATOM_ENTRY]

                    if photo_entry.pending:
                        # skip not published
                        continue

                    has_logo = False
                    for tag in photo_entry.tag:
                        if tag == "Logo":
                            has_logo = True

                    # Altay photo id is placed after the very last :
                    photo_id = entry.id.split(":")[-1]

                    if not has_logo:
                        photos.append({"id": photo_id, "url": photo_entry.url_template})

        return photos

    @property
    def bookings(self) -> List[dict]:
        experimental_metas = self.metas.get("experimental")
        if not experimental_metas or not self.metas["experimental"].HasField(
                "experimental_storage"
        ):
            return []

        return [
            json.loads(item.value)
            for item in experimental_metas.experimental_storage.item
            if item.key == "bookings/1.x"
        ]

    @property
    def logo(self) -> Optional[str]:
        try:
            meta = self.metas["images"]
        except KeyError:
            return None

        if meta.HasField("logo"):
            return self.metas["images"].logo.url_template

    @property
    def cover(self) -> Optional[str]:
        try:
            meta = self.metas["photos"]
        except KeyError:
            return None

        # Photos with links field are special,
        # like: https://wiki.yandex-team.ru/hevil/maps/panoramas/
        photos = list(photo.url_template for photo in meta.photo if not photo.link)
        return photos[0] if len(photos) > 0 else None

    @property
    def rating(self) -> Optional[dict]:
        try:
            meta = self.metas["rating"]
        except KeyError:
            return None

        return {
            "ratings": meta.ratings,
            "reviews": meta.reviews,
            "score": Decimal(meta.score) if meta.HasField("score") else None,
        }

    @property
    def _features(self) -> List[dict]:
        result = []

        for feature in self.metas["business"].feature:
            feat = {"id": feature.id}
            if feature.HasField("name"):
                feat["name"] = feature.name
            if feature.HasField("aref"):
                feat["aref"] = feature.aref
            if feature.value.HasField("boolean_value"):
                feat["value"] = feature.value.boolean_value
            elif feature.value.text_value:
                feat["value"] = feature.value.text_value
            elif feature.value.enum_value:
                feat["value"] = []
                for ev in feature.value.enum_value:
                    feat["value"].append({"id": ev.id, "name": ev.name})
                    if ev.HasField("image_url_template"):
                        feat["value"][-1]["image_url_template"] = ev.image_url_template

            result.append(feat)

        return result

    @property
    def snippet_features(self) -> List[dict]:
        if not self.metas["business"].HasField("snippet"):
            return []

        features_by_id = {feature["id"]: feature for feature in self._features}

        features = []
        for feature_id in self.metas["business"].snippet.feature_ref:
            try:
                features.append(features_by_id[feature_id])
            except KeyError:
                pass

        return features

    @property
    def features(self) -> List[dict]:
        features = self.snippet_features

        for feature in self._features:
            if feature not in features:
                features.append(feature)

        return features

    @property
    def metrika_counter(self):
        try:
            meta = self.metas["metrika"]
        except KeyError:
            return None

        return meta.counter if meta.HasField("counter") else None

    @property
    def is_online(self):
        if not self.metas["business"].HasField("properties"):
            return False

        return any(
            prop_item.key == "is_online" and prop_item.value == "1"
            for prop_item in self.metas["business"].properties.item
        )

    @property
    def permalink_moved_to(self) -> Optional[str]:
        if not self.metas["business"].HasField("properties"):
            return None

        for prop_item in self.metas["business"].properties.item:
            if prop_item.key == "moved_to":
                return prop_item.value

    @property
    def service_area(self) -> Optional[dict]:
        if not self.metas.get("experimental") or not self.metas[
            "experimental"
        ].HasField("experimental_storage"):
            return

        service_area = None
        for item in self.metas["experimental"].experimental_storage.item:
            if item.key == "online_snippets/1.x":
                service_area = json.loads(item.value)
                break

        if not service_area:
            return

        if "service_radius_km" in service_area:
            return {"service_radius_km": service_area["service_radius_km"]}
        elif "geo_ids" in service_area:
            return {"geo_ids": service_area["geo_ids"]}

    @property
    def chain_id(self) -> Optional[int]:
        for chain in self.metas["business"].chain:
            return chain.id


class GeoSearchClient(HttpClientWithTvm):
    @collect_errors
    async def resolve_org(self, permalink: int) -> Optional[GeoSearchOrgResult]:
        params = {
            "ms": "pb",
            "lang": "ru",
            "type": "biz",
            "origin": "maps-adv-bookings-yang",
            "business_oid": str(permalink),
            "show_online_orgs": "both",
            "snippets": ",".join(
                [
                    "businessimages/1.x",
                    "businessrating/1.x",
                    "photos/2.x",
                    "metrika_snippets/1.x",
                    "online_snippets/1.x",
                    "sprav_proto_photos",
                    "bookings/1.x",
                ]
            ),
        }

        resp_body = await self.request(
            method="GET",
            uri="",
            expected_statuses=[200],
            params=params,
            metric_name="resolve_org",
        )

        pb_resp = response_pb2.Response.FromString(resp_body)

        if not pb_resp.HasField("reply") or not pb_resp.reply.geo_object:
            return None

        return GeoSearchOrgResult(pb_resp.reply.geo_object[0])

    @collect_errors
    async def resolve_orgs(self, permalinks: List[int]) -> List[GeoSearchOrgResult]:
        params = {
            "ms": "pb",
            "lang": "ru",
            "type": "biz",
            "origin": "maps-adv-bookings-yang",
            "business_oid": permalinks,
            "show_online_orgs": "both",
            "snippets": ",".join(
                [
                    "businessimages/1.x",
                    "businessrating/1.x",
                    "photos/2.x",
                    "metrika_snippets/1.x",
                    "online_snippets/1.x",
                    "sprav_proto_photos",
                    "bookings/1.x",
                ]
            ),
        }

        resp_body = await self.request(
            method="GET",
            uri="",
            expected_statuses=[200],
            params=params,
            metric_name="resolve_orgs",
        )

        pb_resp = response_pb2.Response.FromString(resp_body)

        if not pb_resp.HasField("reply") or not pb_resp.reply.geo_object:
            return []

        return [
            GeoSearchOrgResult(geo_object) for geo_object in pb_resp.reply.geo_object
        ]
