"""
Custom merge orchestrator for IPFabricIngestion.

Replaces Branch.merge() with an iterative loop that:
- isolates each ObjectChange in its own savepoint so a single failure does not
  abort the entire merge
- records failed changes as IPFabricIngestionIssue(phase='merge')
- reports progress via SyncLogging.increment_statistics()
- keeps branching behaviour as close as possible (signal wiring, BranchEvent,
  branch status transitions, post_merge signal)
"""
import logging
import traceback
from collections import Counter
from functools import partial
from typing import TYPE_CHECKING

from core.exceptions import SyncError
from core.models import ObjectChange as ObjectChange_
from django.db import connection
from django.db import DEFAULT_DB_ALIAS
from django.db.models.signals import post_save
from django.test import RequestFactory
from django.urls import reverse
from django.utils import timezone
from netbox.context_managers import event_tracking
from netbox_branching.choices import BranchEventTypeChoices
from netbox_branching.choices import BranchStatusChoices
from netbox_branching.merge_strategies import get_merge_strategy
from netbox_branching.models import Branch
from netbox_branching.models import BranchEvent
from netbox_branching.signals import post_merge
from netbox_branching.utilities import record_applied_change

from ..choices import IPFabricIngestionPhaseChoices
from ..models import IPFabricIngestionIssue

if TYPE_CHECKING:
    from ..models import IPFabricIngestion
    from ..utilities.logging import SyncLogging

logger = logging.getLogger("ipfabric_netbox.merge")

__all__ = ("merge_branch",)


def merge_branch(
    ingestion: "IPFabricIngestion", sync_logger: "SyncLogging | None" = None
) -> None:
    """
    Apply all ObjectChanges from the ingestion's branch to the main schema
    one change at a time, using a savepoint per change so that individual
    failures do not abort the entire operation.

    Failed changes are recorded as IPFabricIngestionIssue with phase='merge'.
    Successful changes are tracked via SyncLogging.increment_statistics().

    Args:
        ingestion:   IPFabricIngestion instance whose branch should be merged.
        sync_logger: SyncLogging instance (optional). When None only the
                     standard Python logger is used.

    Raises:
        SyncError: If the branch is not ready to merge.
    """
    branch = ingestion.branch
    user = ingestion.sync.user

    logger.info(f"Starting custom merge for branch {branch} ({branch.schema_name})")
    if sync_logger:
        sync_logger.log_info(
            f"Starting merge for branch {branch} ({branch.schema_name})"
        )

    # ------------------------------------------------------------------
    # Guard: branch must be ready
    # ------------------------------------------------------------------
    if not branch.ready:
        raise SyncError(f"Branch {branch} is not ready to merge")

    # ------------------------------------------------------------------
    # Retrieve changes before updating branch status
    # ------------------------------------------------------------------
    changes = branch.get_unmerged_changes().order_by("time")
    total_changes = changes.count()

    if not total_changes:
        logger.info("No changes found; aborting merge.")
        if sync_logger:
            sync_logger.log_info("No changes to merge.")
        return

    logger.info(f"Found {total_changes} changes to merge")
    if sync_logger:
        sync_logger.log_info(f"Found {total_changes} changes to merge.")

    # ------------------------------------------------------------------
    # Pre-initialise merge statistics so progress bars show denominators
    # immediately. Use values_list to avoid fetching full objects twice.
    # ------------------------------------------------------------------
    if sync_logger:
        model_counts: Counter = Counter()
        for app_label, model_name in changes.values_list(
            "changed_object_type__app_label", "changed_object_type__model"
        ):
            model_counts[f"{app_label}.{model_name}"] += 1
        for model_string, count in model_counts.items():
            sync_logger.init_statistics(model_string, total=count)
            sync_logger.log_info(
                f"Going to merge {count} changes for `{model_string}`."
            )

    # ------------------------------------------------------------------
    # Update branch status to MERGING
    # ------------------------------------------------------------------
    Branch.objects.filter(pk=branch.pk).update(status=BranchStatusChoices.MERGING)

    # ------------------------------------------------------------------
    # Wire up the record_applied_change signal (reproduces Branch.merge())
    # ------------------------------------------------------------------
    handler = partial(record_applied_change, branch=branch)
    post_save.connect(handler, sender=ObjectChange_, weak=False)

    # ------------------------------------------------------------------
    # Create a dummy request for event_tracking() – same as Branch.merge()
    # ------------------------------------------------------------------
    request = RequestFactory().get(reverse("home"))

    # ------------------------------------------------------------------
    # Iterative merge loop with per-change savepoints
    # ------------------------------------------------------------------
    models_touched = set()
    failed = 0

    if sync_logger:
        sync_logger.log_info("Starting to merge all changes.")
    try:
        for change in changes:
            model_class = change.changed_object_type.model_class()
            app_label, model_name = change.changed_object_type.natural_key()
            model_string = f"{app_label}.{model_name}"

            sp = connection.savepoint()
            try:
                with event_tracking(request):
                    request.id = change.request_id
                    request.user = change.user
                    change.apply(branch, using=DEFAULT_DB_ALIAS, logger=logger)

                connection.savepoint_commit(sp)
                models_touched.add(model_class)

                logger.debug(
                    f"Applied change {change.pk} ({change.action} {model_string}: {change.changed_object_id})"
                )
                if sync_logger:
                    sync_logger.increment_statistics(model_string)

            except Exception as exc:
                connection.savepoint_rollback(sp)
                failed += 1

                err_msg = (
                    f"Failed to apply change {change.pk} "
                    f"({change.action} {model_string}: {change.changed_object_id}): {exc}"
                )
                logger.error(err_msg, exc_info=True)
                if sync_logger:
                    sync_logger.log_failure(err_msg)

                IPFabricIngestionIssue.objects.create(
                    ingestion=ingestion,
                    phase=IPFabricIngestionPhaseChoices.MERGE,
                    model=model_string,
                    message=err_msg,
                    exception=exc.__class__.__name__,
                    raw_data=traceback.format_exc(),
                )

    finally:
        # Always disconnect the signal, even if an unexpected exception escapes
        post_save.disconnect(handler, sender=ObjectChange_)

    # ------------------------------------------------------------------
    # Post-loop cleanup
    # ------------------------------------------------------------------
    if models_touched:
        strategy_class = get_merge_strategy(branch.merge_strategy)
        strategy_class()._clean(models_touched)

    # ------------------------------------------------------------------
    # Update branch status to MERGED
    # ------------------------------------------------------------------
    branch.status = BranchStatusChoices.MERGED
    branch.merged_time = timezone.now()
    branch.merged_by = user
    branch.save()

    # ------------------------------------------------------------------
    # Record BranchEvent
    # ------------------------------------------------------------------
    BranchEvent.objects.create(
        branch=branch, user=user, type=BranchEventTypeChoices.MERGED
    )

    # ------------------------------------------------------------------
    # Emit post_merge signal (same as Branch.merge())
    # ------------------------------------------------------------------
    post_merge.send(sender=Branch, branch=branch, user=user)

    failed_msg = "no failed."
    if failed:
        failed_msg = f"{failed} skipped (recorded as ingestion issues)."
    summary = f"Merge completed: {total_changes - failed} applied, {failed_msg}"
    logger.info(summary)
    if sync_logger:
        sync_logger.log_info(summary)
