from typing import Iterable

from ffun.core import logging, utils
from ffun.domain.domain import new_feed_id
from ffun.domain.entities import EntryId, FeedId, TagId, TagUid, UserId
from ffun.domain.urls import url_to_source_uid
from ffun.feeds import domain as f_domain
from ffun.feeds import entities as f_entities
from ffun.feeds_links import domain as fl_domain
from ffun.library import domain as l_domain
from ffun.markers import domain as m_domain
from ffun.meta.settings import settings
from ffun.ontology import cache as o_cache
from ffun.ontology import domain as o_domain
from ffun.ontology import entities as o_entities
from ffun.parsers import entities as p_entities
from ffun.scores import domain as s_domain
from ffun.tags import domain as t_domain
from ffun.tags.entities import TagCategories, TagCategory

logger = logging.get_module_logger()

# TODO: add metrics with the number of removed entities


# Note: fully unlinked entry can be linked again before removing from l_entries
#       we should do something with it in the future, but it is ok for now
#       there should not a lot of such cases
async def remove_entries(entries_ids: Iterable[EntryId]) -> None:
    """Remove entries and all related markers and relations."""
    entries_to_remove = list(entries_ids)

    await m_domain.remove_markers_for_entries(entries_to_remove)
    await o_domain.remove_relations_for_entries(entries_to_remove)
    await l_domain.remove_entries_by_ids(entries_to_remove)


async def add_feeds(feed_infos: list[p_entities.FeedInfo], user_id: UserId) -> list[FeedId]:

    urls_to_sources_uids = {feed_info.url: url_to_source_uid(feed_info.url) for feed_info in feed_infos}

    sources_uids_to_ids = await f_domain.get_source_ids(urls_to_sources_uids.values())

    feeds = [
        f_entities.Feed(
            id=new_feed_id(),
            source_id=sources_uids_to_ids[urls_to_sources_uids[feed_info.url]],
            url=feed_info.url,
            title=feed_info.title,
            description=feed_info.description,
        )
        for feed_info in feed_infos
    ]

    real_feeds_ids = await f_domain.save_feeds(feeds)

    for feed_id in real_feeds_ids:
        await fl_domain.add_link(user_id=user_id, feed_id=feed_id)

    return real_feeds_ids


async def clean_orphaned_entries(chunk: int) -> int:
    orphanes = await l_domain.get_orphaned_entries(limit=chunk)

    await remove_entries(orphanes)

    return len(orphanes)


# There is a very small possibility, that user will link feed
# while we in the process of removing it as an orphaned feed.
# This is look like an almost impossible situation (on the current load), because:
# - the feed must be marked as orphaned before that
# - we wait a timeout before removing orphaned feeds
# - the user should make their operations right at the time we process orphaned feeds
async def clean_orphaned_feeds(chunk: int) -> int:
    loaded_before = utils.now() - settings.delay_before_removing_orphaned_feeds

    orphanes = await f_domain.get_orphaned_feeds(limit=chunk, loaded_before=loaded_before)

    # ensure deterministic order of processing
    orphanes.sort()

    # refactor this loop into a bulk operation calls in case of performance issues
    for orphan_id in orphanes:
        logger.info("removing_orphaned_feed", feed_id=orphan_id)

        # unlink all linked entries
        await l_domain.unlink_feed_tail(orphan_id, offset=0)

        await f_domain.tech_remove_feed(orphan_id)

    # just a protection in case some user linked feed while we were removing it
    # in that case return DB in the consistent state
    await fl_domain.tech_remove_all_links(orphanes)

    return len(orphanes)


# There is a minor probability that we'll remove a tag while a new rule is being created.
# We should track such cases and add detection of broken rules in the rules functionality.
# Also, since we remove orphaned tags and GUI mostly allows to create rules for linked tags,
# we should be fine.
async def clean_orphaned_tags(chunk: int) -> int:
    protected_tags = await s_domain.get_all_tags_in_rules()

    return await o_domain.remove_orphaned_tags(chunk=chunk, protected_tags=list(protected_tags))  # type: ignore


# We expect, that when this function is called, all logic (workers, api) is already working on the new configs
# => there will be no case when a new tag is created in the not normalized form
#    (besides native feeds tags, but we'll handle them separately)
# => we can load tags from rules once and use their cached list
async def renormalize_tags(tag_ids: list[TagId]) -> None:
    logger.info("renormalization_started", tag_ids_number=len(tag_ids))

    all_tag_propertries = await o_domain.get_tags_properties(tag_ids)
    all_tag_propertries = [
        property for property in all_tag_propertries if property.type == o_entities.TagPropertyType.categories
    ]
    all_tag_propertries.sort(key=lambda p: p.tag_id)

    total_properties = len(all_tag_propertries)

    logger.info("renormalization_properties_found", properties_number=total_properties)

    old_tags_cache = o_cache.TagsCache()

    for i, property in enumerate(all_tag_propertries):
        old_tag_id = property.tag_id
        old_uids = await old_tags_cache.uids_by_ids([old_tag_id])

        if old_tag_id not in old_uids:
            # Tag could be removed between loading properties and loading uids
            continue

        old_tag_uid = old_uids[old_tag_id]

        logger.info(
            "try_to_renormalize_tag", step=i, steps_total=total_properties, tag_id=old_tag_id, tag_uid=old_tag_uid
        )

        await _renormalize_tag(
            old_tag_id=old_tag_id,
            old_tag_uid=old_tag_uid,
            processor_id=property.processor_id,
            categories={TagCategory(name) for name in property.value.split(",")},
        )

    logger.info("renormalization_finished")


async def _renormalize_tag(
    processor_id: int, old_tag_id: TagId, old_tag_uid: TagUid, categories: set[TagCategory]
) -> None:

    logger.info(
        "renormalizing_tag",
        processor_id=processor_id,
        old_tag_id=old_tag_id,
        old_tag_uid=old_tag_uid,
        categories=categories,
    )

    keep_old_tag, new_tags = await _normalize_tag_uid(
        old_tag_uid=old_tag_uid,
        categories=categories,
        processor_id=processor_id,
    )

    logger.info("renormalizing_candidates_found", old_tag_id=old_tag_id, keep_old_tag=keep_old_tag, new_tags=new_tags)

    for new_tag_id in new_tags:
        await _apply_renormalized_tags(processor_id=processor_id, old_tag_id=old_tag_id, new_tag_id=new_tag_id)

    if not keep_old_tag:
        await remove_tags([old_tag_id])


async def _normalize_tag_uid(
    old_tag_uid: TagUid, categories: TagCategories, processor_id: int
) -> tuple[bool, list[TagId]]:
    if not categories:
        return False, []

    raw_tag = o_entities.RawTag(raw_uid=old_tag_uid, link=None, categories=categories)

    normalized_tags = await t_domain.normalize([raw_tag])

    new_tags_cache = o_cache.TagsCache()

    new_tag_ids = await new_tags_cache.ids_by_uids([tag.uid for tag in normalized_tags])

    # we update new properties for all tags, even for the old one
    # because it at this place logic should not know which properties and how can be changed
    # => it is easier to just update everything
    new_properties = []
    for tag in normalized_tags:
        new_properties.extend(tag.build_properties_for(tag_id=new_tag_ids[tag.uid], processor_id=processor_id))
    await o_domain.apply_tags_properties(new_properties)

    original_tag_exists = False

    tags_to_copy = []

    for normalized_tag in normalized_tags:
        if normalized_tag.uid == raw_tag.raw_uid:
            original_tag_exists = True
            continue

        tags_to_copy.append(new_tag_ids[normalized_tag.uid])

    return original_tag_exists, tags_to_copy


async def _apply_renormalized_tags(processor_id: int, old_tag_id: TagId, new_tag_id: TagId) -> None:
    await o_domain.copy_tag_properties(processor_id=processor_id, old_tag_id=old_tag_id, new_tag_id=new_tag_id)
    await o_domain.copy_relations(processor_id=processor_id, old_tag_id=old_tag_id, new_tag_id=new_tag_id)
    await s_domain.clone_rules_for_replacements({old_tag_id: new_tag_id})


async def remove_tags(tag_ids: list[TagId]) -> None:
    # we clean only tag relations here,
    # tags itself will be removed later by cleaner
    await o_domain.remove_relations_for_tags(tag_ids)
    await s_domain.remove_rules_with_tags(tag_ids)
