"""Cookidoo API helpers."""

from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
import json
import logging
import os
from typing import cast
from urllib.parse import urlparse

import aiofiles
import isodate

from cookidoo_api.raw_types import (
    AdditionalItemJSON,
    CalendarDayJSON,
    CalenderDayRecipeJSON,
    CommunityProfileJSON,
    CustomCollectionJSON,
    CustomRecipeContentJSON,
    CustomRecipeJSON,
    CustomRecipeTextJSON,
    DescriptiveAssetJSON,
    IngredientJSON,
    ItemJSON,
    ManagedCollectionJSON,
    QuantityJSON,
    RecipeDetailsJSON,
    RecipeJSON,
    SearchRecipeHitJSON,
    SearchResultJSON,
    SubscriptionJSON,
)
from cookidoo_api.types import (
    CookidooAdditionalItem,
    CookidooCalendarDay,
    CookidooCalendarDayRecipe,
    CookidooCategory,
    CookidooChapter,
    CookidooChapterRecipe,
    CookidooCollection,
    CookidooCookingActivity,
    CookidooCookState,
    CookidooCustomRecipe,
    CookidooDevice,
    CookidooIngredient,
    CookidooIngredientItem,
    CookidooLocalizationConfig,
    CookidooNutrition,
    CookidooNutritionGroup,
    CookidooRecipeCollection,
    CookidooRecipeNutrition,
    CookidooRecipeStep,
    CookidooRecipeStepGroup,
    CookidooSearchRecipeHit,
    CookidooSearchResult,
    CookidooShoppingRecipe,
    CookidooShoppingRecipeDetails,
    CookidooSubscription,
    CookidooUserInfo,
    ThermomixMachineType,
)

_LOGGER = logging.getLogger(__name__)

localization_file_path = os.path.join(os.path.dirname(__file__), "localization.json")


def normalize_list_param(value: str | list[str] | None) -> str | None:
    """Normalize list/string params to comma-separated string."""
    if value is None:
        return None
    if isinstance(value, list):
        return ",".join([v for v in value if v])
    return value


def normalize_tmv_param(
    value: ThermomixMachineType | str | list[ThermomixMachineType | str] | None,
) -> str | None:
    """Normalize TMV param to comma-separated string."""
    if value is None:
        return None
    if isinstance(value, list):
        normalized: list[str] = []
        for item in value:
            normalized.append(
                item.value if isinstance(item, ThermomixMachineType) else str(item)
            )
        return ",".join([v for v in normalized if v])
    return value.value if isinstance(value, ThermomixMachineType) else value


def cookidoo_user_info_from_json(
    profile: CommunityProfileJSON,
) -> CookidooUserInfo:
    """Convert a community profile received from the API to a cookidoo user info."""
    user_info = profile["userInfo"]
    return CookidooUserInfo(
        id=profile["id"],
        username=user_info["username"],
        description=user_info.get("description"),
        picture=user_info["picture"],
    )


def cookidoo_subscription_from_json(
    subscription: SubscriptionJSON,
) -> CookidooSubscription:
    """Convert a subscription received from the API to a cookidoo subscription."""
    return CookidooSubscription(
        active=subscription["active"],
        expires=subscription["expires"],
        start_date=subscription["startDate"],
        status=subscription["status"],
        subscription_level=subscription["subscriptionLevel"],
        subscription_source=subscription["subscriptionSource"],
        type=subscription["type"],
        extended_type=subscription["extendedType"],
    )


def cookidoo_device_from_json(model: str) -> CookidooDevice:
    """Convert a device machine type received from the API to a cookidoo device.

    The devices endpoint returns bare machine-type strings (e.g. ``"TM7"``).
    """
    return CookidooDevice(type=ThermomixMachineType(model))


def _push_timestamp(value: object) -> datetime | None:
    """Parse a push timestamp (ISO-8601 or epoch millis/seconds)."""
    if value is None or value == "":
        return None
    if isinstance(value, (int, float)):
        seconds = value / 1000.0 if value > 1e12 else float(value)
        return datetime.fromtimestamp(seconds, tz=UTC)
    if isinstance(value, str):
        text = value.strip()
        if text.isdigit():
            return _push_timestamp(int(text))
        try:
            return datetime.fromisoformat(text.replace("Z", "+00:00"))
        except ValueError:
            return None
    return None


def _push_number(value: object) -> float | None:
    """Parse a numeric display field; the app uses ``"---"`` for 'no value'."""
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    text = str(value).strip()
    if not text or set(text) <= {"-", "–", "—"}:
        return None
    try:
        return float(text.replace(",", "."))
    except ValueError:
        return None


def _push_bool(value: object) -> bool:
    """Parse a boolean; push values are strings, so ``"false"`` must be falsy."""
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.strip().lower() in ("true", "1", "yes")
    return bool(value)


def cooking_activity_from_push(
    data: Mapping[str, object],
) -> CookidooCookingActivity:
    """Convert a remote-monitoring push payload into a cooking activity.

    Appliance state is delivered out of band (a Firebase Cloud Messaging data
    message) rather than as an HTTP response, so consumers receive it via their
    own push channel and decode it here. Both the on-the-wire field names
    (``leadingText``/``trailingText``/``completedDate``/``staleDate``/…) and the
    app's parsed names are accepted.
    """

    def first(*keys: str) -> object:
        for key in keys:
            if key in data and data[key] is not None:
                return data[key]
        return None

    remaining_raw = first("remainingDuration")
    remaining: int | None = None
    if isinstance(remaining_raw, (int, str)):
        try:
            remaining = int(remaining_raw)
        except ValueError:
            remaining = None
    completed_at = _push_timestamp(first("completedDate", "completedTimestamp"))
    end_at = _push_timestamp(first("endTimestamp"))
    # The wire payload has no remainingDuration; derive it from the finish time.
    if remaining is None:
        finish = completed_at or end_at
        if finish is not None:
            remaining = max(0, int((finish - datetime.now(UTC)).total_seconds()))

    state_raw = first("state")
    state = CookidooCookState(str(state_raw).upper()) if state_raw is not None else None
    recipe_type = first("recipeType")

    return CookidooCookingActivity(
        device_id=str(first("deviceId") or ""),
        cooking_activity_id=cast("str | None", first("cookingActivityId")),
        state=state,
        recipe_id=cast("str | None", first("recipeId")),
        recipe_type=str(recipe_type).upper() if recipe_type is not None else None,
        recipe_name=cast(
            "str | None", first("leadingText", "leadingInfoText", "infoText")
        ),
        step=cast("str | None", first("trailingText", "trailingInfoText")),
        remaining_seconds=remaining,
        is_time_estimated=_push_bool(data.get("isTimeEstimated", False)),
        current_temperature=_push_number(first("primaryInfo")),
        target_temperature=_push_number(first("secondaryInfo")),
        message_title=cast("str | None", first("messageTitle")),
        message_body=cast("str | None", first("messageBody")),
        message_criticality=cast("str | None", first("messageCriticality")),
        completed_at=completed_at,
        stale_at=_push_timestamp(first("staleDate", "staleTimestamp")),
    )


def cookidoo_collection_from_json(
    collection: CustomCollectionJSON | ManagedCollectionJSON,
) -> CookidooCollection:
    """Convert a collection received from the API to a cookidoo collection."""
    return CookidooCollection(
        id=collection["id"],
        name=collection["title"],
        description=cast(str, collection.get("description", None)),
        chapters=[
            CookidooChapter(
                name=chapter["title"],
                recipes=[
                    CookidooChapterRecipe(
                        id=recipe["id"],
                        name=recipe["title"],
                        total_time=int(float(recipe["totalTime"])),
                    )
                    for recipe in chapter["recipes"]
                ],
            )
            for chapter in collection["chapters"]
        ],
    )


def _process_image_url(url: str) -> tuple[str, str]:
    """Process image URL by replacing transformation placeholders.

    Returns
    -------
    tuple[str, str]
        A tuple of (thumbnail_url, image_url) where:
        - thumbnail_url uses t_web_shared_recipe_221x240 transformation
        - image_url uses t_web_rdp_recipe_584x480_1_5x transformation

    """
    thumbnail = url.replace("{transformation}", "t_web_shared_recipe_221x240")
    image = url.replace("{transformation}", "t_web_rdp_recipe_584x480_1_5x")
    return thumbnail, image


def _extract_images_from_descriptive_assets(
    descriptive_assets: list[DescriptiveAssetJSON],
) -> tuple[str | None, str | None]:
    """Extract thumbnail and image URLs from descriptive assets.

    Returns
    -------
    tuple[str | None, str | None]
        A tuple of (thumbnail_url, image_url) extracted from the first
        available image URL in descriptive assets.

    """
    thumbnail: str | None = None
    image: str | None = None

    # Get the first available image URL from any variant
    for asset in descriptive_assets:
        _LOGGER.debug(asset)
        for variant, url in asset.items():
            _LOGGER.debug(variant)
            if url and variant in ("square", "portrait", "landscape"):
                thumbnail, image = _process_image_url(str(url))
                break
        if thumbnail:
            break

    return thumbnail, image


def _construct_recipe_url(
    localization: CookidooLocalizationConfig | None,
    recipe_id: str,
    path_prefix: str = "recipes/recipe",
) -> str:
    """Construct a recipe URL from localization config and recipe ID.

    Parameters
    ----------
    localization
        The localization config containing the domain and language.
    recipe_id
        The recipe ID to use in the URL.
    path_prefix
        The path prefix for the recipe URL. Defaults to "recipes/recipe".

    Returns
    -------
    str
        The constructed recipe URL, or empty string if localization is None.

    """
    if not localization:
        return ""

    parsed_url = urlparse(localization.url)
    domain = parsed_url.netloc
    return f"https://{domain}/{path_prefix}/{localization.language}/{recipe_id}"


def cookidoo_recipe_from_json(
    recipe: RecipeJSON,
    localization: CookidooLocalizationConfig | None = None,
) -> CookidooShoppingRecipe:
    """Convert a shopping recipe received from the API to a cookidoo shopping recipe."""
    thumbnail, image = None, None
    descriptive_assets = recipe.get("descriptiveAssets")
    if descriptive_assets is not None:
        thumbnail, image = _extract_images_from_descriptive_assets(descriptive_assets)
    url = _construct_recipe_url(localization, recipe["id"])

    return CookidooShoppingRecipe(
        id=recipe["id"],
        name=recipe["title"],
        ingredients=[
            cookidoo_ingredient_from_json(ingredient)
            for ingredient in recipe["recipeIngredientGroups"]
        ],
        thumbnail=thumbnail,
        image=image,
        url=url,
    )


def cookidoo_search_result_from_json(
    data: SearchResultJSON,
    localization: CookidooLocalizationConfig | None = None,
) -> CookidooSearchResult:
    """Convert a search result received from the API to a CookidooSearchResult.

    The API may return recipes in ``data`` (search endpoint) or ``recipes``;
    total is optional and defaults to the number of valid parsed hits.

    Parameters
    ----------
    data
        The raw JSON response from the search API.
    localization
        Optional localization config used to construct recipe URLs.

    Returns
    -------
    CookidooSearchResult
        The parsed search result with recipe hits and total count.

    """
    if "data" in data:
        raw_recipes: list[SearchRecipeHitJSON] = data["data"] or []
    elif "recipes" in data:
        raw_recipes = data["recipes"] or []
    else:
        raw_recipes = []
    recipes_data: list[object] = list(raw_recipes)
    total_raw = data.get("total")
    hits: list[CookidooSearchRecipeHit] = []
    for item in recipes_data:
        if not isinstance(item, dict):
            continue
        recipe_id = item.get("id", "")
        name = item.get("title") or item.get("name", "")
        thumbnail, image = None, None
        descriptive_assets = item.get("descriptiveAssets")
        if descriptive_assets and isinstance(descriptive_assets, list):
            thumbnail, image = _extract_images_from_descriptive_assets(
                descriptive_assets
            )
        url = _construct_recipe_url(localization, recipe_id)
        hits.append(
            CookidooSearchRecipeHit(
                id=recipe_id,
                name=name,
                thumbnail=thumbnail,
                image=image,
                url=url,
            )
        )
    total = total_raw if isinstance(total_raw, int) else len(hits)
    return CookidooSearchResult(recipes=hits, total=total)


def cookidoo_quantity_from_json(
    quantity: QuantityJSON,
) -> str:
    """Convert an quantity received from the API to a str."""
    if "value" in quantity and quantity["value"]:
        return str(quantity["value"])
    elif (
        "from" in quantity and "to" in quantity and quantity["from"] and quantity["to"]
    ):
        return f"{quantity['from']} - {quantity['to']}"
    else:
        return ""


def cookidoo_recipe_details_from_json(
    recipe: RecipeDetailsJSON,
    localization: CookidooLocalizationConfig | None = None,
) -> CookidooShoppingRecipeDetails:
    """Convert an recipe details received from the API to a cookidoo recipe details."""
    thumbnail, image = None, None
    descriptive_assets = recipe.get("descriptiveAssets")
    if descriptive_assets is not None:
        thumbnail, image = _extract_images_from_descriptive_assets(descriptive_assets)
    url = _construct_recipe_url(localization, recipe["id"])

    active_time = next(
        (
            time_["quantity"]["value"]
            for time_ in recipe["times"]
            if time_["type"] == "activeTime" and time_["quantity"]["value"]
        ),
        None,
    )
    if active_time is None:
        raise ValueError(
            "Recipe details response is missing a non-null 'activeTime' entry in 'times'."
        )
    total_time = next(
        (
            time_["quantity"]["value"]
            for time_ in recipe["times"]
            if time_["type"] == "totalTime" and time_["quantity"]["value"]
        ),
        None,
    )
    if total_time is None:
        raise ValueError(
            "Recipe details response is missing a non-null 'totalTime' entry in 'times'."
        )

    return CookidooShoppingRecipeDetails(
        id=recipe["id"],
        name=recipe["title"],
        ingredients=[
            cookidoo_ingredient_from_json(ingredient)
            for ingredientGroup in recipe["recipeIngredientGroups"]
            for ingredient in ingredientGroup["recipeIngredients"]
        ],
        difficulty=recipe["difficulty"],
        notes=[
            additional_notes["content"]
            for additional_notes in recipe["additionalInformation"]
        ],
        categories=[
            CookidooCategory(
                id=category["id"], name=category["title"], notes=category["subtitle"]
            )
            for category in recipe["categories"]
        ],
        collections=[
            CookidooRecipeCollection(
                id=collection["id"],
                name=collection["title"],
                total_recipes=collection["recipesCount"]["value"],
            )
            for collection in recipe["inCollections"]
        ],
        utensils=[utensil["utensilNotation"] for utensil in recipe["recipeUtensils"]],
        serving_size=recipe["servingSize"]["quantity"]["value"] or 0,
        active_time=active_time,
        total_time=total_time,
        nutrition_groups=[
            CookidooNutritionGroup(
                name=ng["name"],
                recipe_nutritions=[
                    CookidooRecipeNutrition(
                        nutritions=[
                            CookidooNutrition(
                                number=n["number"],
                                type=n["type"],
                                unittype=n["unittype"],
                            )
                            for n in rn["nutritions"]
                        ],
                        quantity=rn["quantity"],
                        unit_notation=rn["unitNotation"],
                    )
                    for rn in ng["recipeNutritions"]
                ],
            )
            for ng in recipe.get("nutritionGroups", [])
        ],
        step_groups=[
            CookidooRecipeStepGroup(
                title=sg["title"],
                recipe_steps=[
                    CookidooRecipeStep(
                        title=step["title"],
                        formatted_text=step["formattedText"],
                    )
                    for step in sg["recipeSteps"]
                ],
            )
            for sg in recipe.get("recipeStepGroups", [])
        ],
        thumbnail=thumbnail,
        image=image,
        url=url,
    )


def cookidoo_custom_recipe_from_json(
    recipe: CustomRecipeJSON,
    localization: CookidooLocalizationConfig | None = None,
) -> CookidooCustomRecipe:
    """Convert a custom recipe received from the API to a cookidoo custom recipe."""
    def _duration_to_seconds(value: str | int | float | None) -> int:
        if value is None:
            return 0

        if isinstance(value, int | float):
            return int(value)

        duration = isodate.parse_duration(value).total_seconds()
        return int(duration) if isinstance(duration, float) else 0

    def _extract_text_list(
        value: Sequence[str | CustomRecipeTextJSON],
    ) -> list[str]:
        return [item["text"] if isinstance(item, dict) else item for item in value]

    recipe_content: CustomRecipeContentJSON = recipe["recipeContent"]
    total_time = _duration_to_seconds(recipe_content.get("totalTime"))
    active_time = _duration_to_seconds(recipe_content.get("prepTime"))

    thumbnail: str | None = None
    image: str | None = None

    image = recipe_content.get("image", None)
    if image:
        thumbnail, image = _process_image_url(image)

    url = _construct_recipe_url(localization, recipe["recipeId"], "created-recipes")
    recipe_yield = (
        recipe_content.get("recipeYield")
        or recipe_content.get("yield")
        or {"value": 0, "unitText": ""}
    )

    return CookidooCustomRecipe(
        id=recipe["recipeId"],
        name=recipe_content["name"],
        ingredients=_extract_text_list(
            recipe_content.get("recipeIngredient")
            or recipe_content.get("ingredients", [])
        ),
        instructions=_extract_text_list(
            recipe_content.get("recipeInstructions")
            or recipe_content.get("instructions", [])
        ),
        serving_size=recipe_yield["value"],
        total_time=total_time,
        active_time=active_time,
        tools=recipe_content.get("tool") or recipe_content.get("tools", []),
        thumbnail=thumbnail,
        image=image,
        url=url,
    )


def cookidoo_ingredient_from_json(
    ingredient: IngredientJSON | ItemJSON,
) -> CookidooIngredient:
    """Convert an ingredient received from the API to a cookidoo ingredient."""
    return CookidooIngredient(
        id=ingredient["localId"] if "localId" in ingredient else ingredient["id"],  # type: ignore[typeddict-item]
        name=ingredient["ingredientNotation"],
        description=f"{cookidoo_quantity_from_json(ingredient['quantity'])} {ingredient['unitNotation']}"
        if "unitNotation" in ingredient
        and ingredient["unitNotation"]
        and "quantity" in ingredient
        and ingredient["quantity"]
        else cookidoo_quantity_from_json(ingredient["quantity"])
        if "quantity" in ingredient and ingredient["quantity"]
        else "",
    )


def cookidoo_ingredient_item_from_json(
    item: ItemJSON,
) -> CookidooIngredientItem:
    """Convert an ingredient item received from the API to a cookidoo item."""
    return CookidooIngredientItem(
        id=item["id"],
        name=item["ingredientNotation"],
        is_owned=item["isOwned"],
        description=f"{cookidoo_quantity_from_json(item['quantity'])} {item['unitNotation']}"
        if "unitNotation" in item
        and item["unitNotation"]
        and "quantity" in item
        and item["quantity"]
        else str(cookidoo_quantity_from_json(item["quantity"]))
        if "quantity" in item and item["quantity"]
        else "",
    )


def cookidoo_additional_item_from_json(
    item: AdditionalItemJSON,
) -> CookidooAdditionalItem:
    """Convert an additional item received from the API to a cookidoo item."""
    return CookidooAdditionalItem(
        id=item["id"],
        name=item["name"],
        is_owned=item["isOwned"],
    )


def cookidoo_calendar_day_from_json(
    calendar_day: CalendarDayJSON,
    localization: CookidooLocalizationConfig | None = None,
) -> CookidooCalendarDay:
    """Convert a calendar day received from the API to a cookidoo item."""

    def _to_day_recipe(recipe: CalenderDayRecipeJSON) -> CookidooCalendarDayRecipe:
        assets = recipe["assets"]
        thumbnail, image = None, None
        descriptive_assets = [assets["images"]] if assets and assets["images"] else None
        if descriptive_assets is not None:
            thumbnail, image = _extract_images_from_descriptive_assets(
                descriptive_assets
            )

        url = _construct_recipe_url(localization, recipe["id"])

        return CookidooCalendarDayRecipe(
            id=recipe["id"],
            name=recipe["title"],
            total_time=recipe["totalTime"],
            thumbnail=thumbnail,
            image=image,
            url=url,
        )

    regular_recipes = [_to_day_recipe(recipe) for recipe in calendar_day["recipes"]]
    custom_recipes = [
        _to_day_recipe(recipe) for recipe in calendar_day.get("customerRecipes", [])
    ]

    return CookidooCalendarDay(
        id=calendar_day["id"],
        title=calendar_day["title"],
        recipes=[*regular_recipes, *custom_recipes],
        customer_recipe_ids=list(calendar_day.get("customerRecipeIds", [])),
    )


async def __get_localization_options(
    country: str | None = None,
    language: str | None = None,
) -> list[CookidooLocalizationConfig]:
    async with aiofiles.open(localization_file_path, encoding="utf-8") as file:
        options_ = cast(list[dict[str, str]], json.loads(await file.read()))
        options = (CookidooLocalizationConfig(**x) for x in options_)
        filtered_options = filter(
            lambda option: (
                (not country or option.country_code == country)
                and (not language or option.language == language)
            ),
            options,
        )
        return list(cast(list[CookidooLocalizationConfig], filtered_options))


async def get_localization_options(
    country: str | None = None,
    language: str | None = None,
) -> list[CookidooLocalizationConfig]:
    """Get a list of possible localization options."""
    return await __get_localization_options(country, language)


async def get_country_options() -> list[str]:
    """Get a list of possible country options."""
    return list({option.country_code for option in await get_localization_options()})


async def get_language_options() -> list[str]:
    """Get a list of possible language options."""
    return list({option.language for option in await get_localization_options()})
