"""High level logic for fedibooster."""

import asyncio
import sys
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from typing import Final

import stamina
from diskcache import Cache
from httpx import AsyncClient
from loguru import logger as log
from minimal_activitypub import SearchType
from minimal_activitypub import Status
from minimal_activitypub.client_2_server import ActivityPub
from minimal_activitypub.client_2_server import ConflictError
from minimal_activitypub.client_2_server import ForbiddenError
from minimal_activitypub.client_2_server import GoneError
from minimal_activitypub.client_2_server import NetworkError
from minimal_activitypub.client_2_server import RatelimitError
from minimal_activitypub.client_2_server import UnprocessedError
from pybreaker import CircuitBreakerError
from stamina import retry

from fedibooster import __version__
from fedibooster.boostable import Boostable
from fedibooster.cache_service import CacheService
from fedibooster.circuit_breaker import circuit_breaker_decorator
from fedibooster.circuit_breaker import circuit_breaker_manager
from fedibooster.circuit_breaker import circuit_protected_call
from fedibooster.config import Configuration
from fedibooster.config import Fediverse
from fedibooster.config import NoReblog
from fedibooster.config import determine_config

stamina.instrumentation.set_on_retry_hooks([])

CACHE_MAX_AGE_DEFAULT_30_DAYS: Final[int] = 30
ENABLE_MINIMAL_ACTIVITYPUB_LOGGING: Final[bool] = False

if ENABLE_MINIMAL_ACTIVITYPUB_LOGGING:
    import logging

    log_fmt = "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(%(lineno)d) - %(message)s"
    lib_log = logging.getLogger("Minimal-ActivityPub")
    lib_log.setLevel(logging.DEBUG)
    cons = logging.StreamHandler()
    cons.setFormatter(logging.Formatter(log_fmt))
    lib_log.addHandler(cons)


@log.catch
async def main(config_path: Path, max_posts: int | None) -> None:
    """Read communities and post to fediverse account."""
    log.info(f"Welcome to fedibooster({__version__})")

    # Log initial circuit breaker states
    log_circuit_breaker_stats()

    config = await determine_config(config_path=config_path)
    log.debug(f"{config=}")

    cache = Cache(directory=".")
    cache_service = CacheService(cache=cache)

    client = AsyncClient(http2=True, timeout=30)

    # Load external configs with circuit breaker protection
    @circuit_protected_call("config_loading", operation_description="loading external configurations")
    async def load_configs_with_protection():
        return await config.load_external_configs(client=client, cache=cache)

    try:
        no_reblog, search_tags, reblog_langs = await load_configs_with_protection()
    except CircuitBreakerError:
        log.error("Circuit breaker OPEN for config loading, using empty defaults")
        no_reblog, search_tags, reblog_langs = NoReblog(), [], []

    log.debug(f"{search_tags=}")

    try:
        instance: ActivityPub
        my_username: str
        instance, my_username = await connect(auth=config.fediverse, client=client)
    except (NetworkError, CircuitBreakerError) as error:
        log.info(f"Unable to connect to your Fediverse account with {error=}")
        log.opt(colors=True).info("<red><bold>Can't continue!</bold></red> ... Exiting")
        sys.exit(1)

    my_account = Boostable.expand_local_user(username=my_username, search_host=config.fediverse.domain_name)
    no_reblog.users_list.append(my_account)
    log.debug(f"{no_reblog=}")

    while True:
        # Boost timeline posts
        max_reblogs = min(max_posts, config.max_reblog) if max_posts else config.max_reblog
        try:
            await boost_statuses_with_hashtags(
                instance=instance,
                max_boosts=max_reblogs,
                no_reblog=no_reblog,
                client=client,
                config=config,
                tags=search_tags,
                languages=reblog_langs,
                cache_service=cache_service,
            )
        except (NetworkError, CircuitBreakerError) as error:
            log.warning(f"We've encountered the following error when boosting statuses: {error}")

        if not config.run_continuously:
            break

        # Log circuit breaker stats before waiting
        log_circuit_breaker_stats()

        wait_until = datetime.now(tz=UTC) + timedelta(seconds=config.delay_between_posts)
        log.opt(colors=True).info(
            f"<dim>Waiting until {wait_until:%Y-%m-%d %H:%M:%S %z} "
            f"({config.delay_between_posts}s) before checking again.</>"
        )
        await asyncio.sleep(delay=config.delay_between_posts)

    cache.close()
    await client.aclose()


async def boost_statuses_with_hashtags(  # noqa: PLR0913
    instance: ActivityPub,
    max_boosts: int,
    no_reblog: NoReblog,
    client: AsyncClient,
    config: Configuration,
    tags: list[str],
    languages: list[str],
    cache_service: CacheService,
) -> None:
    """Boost posts on home timeline."""
    max_id: str | None = cache_service.get_max_boosted_id()
    search_on: str = config.fediverse.search_instance if config.fediverse.search_instance else instance.instance
    number_boosted: int = 0

    statuses = await get_statuses_with_tags(search_instance=search_on, tags=tags)
    log.debug(f"Retrieved {len(statuses)} statuses to consider")

    if not statuses:
        return

    for status in reversed(statuses):
        # Check for any reason to skip reblogging this status
        boostable = Boostable(
            status=status,
            cache_service=cache_service,
            reblog_langs=languages,
            no_reblog=no_reblog,
        )

        # Any reason not to boost status ?
        if boostable.has_already_been_boosted() or not boostable.should_be_boosted(
            reblog_sensitive=config.reblog_sensitive,
            search_instance=config.fediverse.search_instance,
            limit_tags=config.max_tags,
        ):
            continue

        # Check Attachments haven't been boosted / rebloged yet
        await boostable.have_attachments_already_been_boosted(client=client)
        if not boostable.attachments_hashes:
            continue

        boosted_id = await attempt_status_boost(boostable=boostable, instance=instance)

        if boosted_id:
            number_boosted += 1
            max_id = boosted_id

        if number_boosted >= max_boosts:
            break

    if max_id is not None:
        cache_service.set_max_boosted_id(max_id)


async def find_status_to_reblog(instance: ActivityPub, status_url: str) -> Status | None:
    """Search for a status by URL and return the status dict if found."""
    search_result = await instance.search(query=status_url, query_type=SearchType.STATUSES, resolve=True)
    statuses = search_result.get("statuses")
    return statuses[0] if statuses else None


async def reblog_status(instance: ActivityPub, status_id: str, status_url: str) -> tuple[bool, bool]:
    """Attempt to reblog a status with retry logic and circuit breaker protection.

    Returns a tuple of (success, should_record).
    - success: True if reblog was successful
    - should_record: True if status should be recorded (success or permanent failure)
    """

    @circuit_protected_call("reblog_operation", operation_description=f"reblogging status {status_url}")
    async def protected_reblog():
        retry_caller = stamina.AsyncRetryingCaller(attempts=3)
        try:
            await retry_caller(NetworkError, instance.reblog, status_id=status_id)
        except (ForbiddenError, GoneError, ConflictError, UnprocessedError) as error:
            log.opt(colors=True).info(f"<cyan>{status_url}</> <red>NOT</> boosted... got {error}")
            return False, True  # Permanent failure, record to avoid retrying
        except NetworkError:
            # NetworkError will be raised after all retries fail
            return False, False  # Transient failure, don't record
        else:
            log.opt(colors=True).info(f"Boosted <cyan>{status_url}</>")
            return True, True  # Success, record

    try:
        return await protected_reblog()
    except CircuitBreakerError:
        # Circuit breaker is OPEN for reblog operations
        log.warning(f"Circuit breaker OPEN for reblog operations, skipping {status_url}")
        return False, False  # Treat as transient failure, don't record


async def attempt_status_boost(boostable: Boostable, instance: ActivityPub) -> str | None:
    """Attempt to boost a status and return its id if boost was successful.

    This function orchestrates the boosting process by:
    1. Searching for the status by URL
    2. Attempting to reblog it with retry logic
    3. Recording the result appropriately
    """
    status_url = boostable.status.get("url", "")
    status_to_reblog = await find_status_to_reblog(instance=instance, status_url=status_url)

    if not status_to_reblog:
        return None

    reblog_id = status_to_reblog.get("id")
    if not reblog_id:
        return None

    success, should_record = await reblog_status(instance=instance, status_id=reblog_id, status_url=status_url)

    if should_record:
        boostable.record()

    if success:
        return reblog_id

    return None


@circuit_breaker_decorator("search_instance", fallback_value=[])
async def get_statuses_with_tags(search_instance: str, tags: list[str]) -> list[Status]:
    """Get statuses found on search_instance with tags."""
    statuses: list[Status] = []

    retry_caller = stamina.AsyncRetryingCaller(attempts=3)
    try:
        statuses = await retry_caller(NetworkError, get_hashtag_timeline, search_instance=search_instance, tags=tags)
    except NetworkError as error:
        log.opt(colors=True).info(f"<dim>encountered {error=}</dim>")
    except RatelimitError:
        log.opt(colors=True).info("<dim>We've been rate limited... waiting for 30 minutes</dim>")
        await asyncio.sleep(1800)
    except CircuitBreakerError:
        log.warning(f"Circuit breaker OPEN for search instance '{search_instance}', returning empty list")
        return []

    return statuses


def log_circuit_breaker_stats() -> None:
    """Log current circuit breaker statistics for monitoring."""
    stats = circuit_breaker_manager.get_all_stats()

    if not stats:
        log.debug("No circuit breakers configured")
        return

    for breaker_name, breaker_stats in stats.items():
        state = breaker_stats.get("state", "unknown")
        fail_counter = breaker_stats.get("fail_counter", 0)
        fail_max = breaker_stats.get("fail_max", 0)

        if state == "open":
            log.warning(f"Circuit breaker '{breaker_name}' is OPEN (failures: {fail_counter}/{fail_max})")
        elif state == "half-open":
            log.info(f"Circuit breaker '{breaker_name}' is HALF-OPEN (testing recovery)")
        else:  # closed
            log.debug(f"Circuit breaker '{breaker_name}' is CLOSED (failures: {fail_counter}/{fail_max})")


@circuit_breaker_decorator("authentication")
@retry(on=NetworkError, attempts=3)
async def connect(auth: Fediverse, client: AsyncClient) -> tuple[ActivityPub, str]:
    """Connect to fediverse instance server and initialise some values."""
    activity_pub = ActivityPub(
        instance=auth.domain_name,
        access_token=auth.api_token,
        client=client,
    )
    await activity_pub.determine_instance_type()

    user_info = await activity_pub.verify_credentials()

    log.info(f"Successfully authenticated as @{user_info['username']} on {auth.domain_name}")

    return activity_pub, user_info["username"]


@circuit_breaker_decorator("search_instance", fallback_value=[])
@stamina.retry(on=NetworkError, attempts=3)
async def get_hashtag_timeline(search_instance: str, tags: list[str]) -> list[Status]:
    """Search for statuses with 'tags' on 'search_instance'."""
    first_tag = tags[0]
    any_other_tags = tags[1:] if len(tags) > 1 else None
    async with AsyncClient(http2=True, timeout=30) as client:
        search_on = ActivityPub(instance=search_instance, client=client)
        results: list[Status] = await search_on.get_hashtag_timeline(
            hashtag=first_tag,
            any_tags=any_other_tags,
            only_media=True,
            limit=40,
        )

    return results
