import logging
import pgtrigger
from typing import Any, Iterable, TYPE_CHECKING

from console_base.api.serializers import LCModelSerializer
from django.conf import settings
from django.http import HttpRequest
from rest_framework.request import Request

from ..choices import SYNCMODES
from ..constants import SYNC_COMPLETED_KEY, SYNCMODE_KEY
from ..models import ConcordEvents
from ..typehints import EventData, Status

if TYPE_CHECKING:
    from ..models import SyncableModel
    from concordia_tests.latchstring.models import User

logger = logging.getLogger(__name__)

http_request = HttpRequest()
http_request.META = {
    'SERVER_NAME': settings.HOSTNAME,
    'SERVER_PORT': settings.EXTERNAL_PORT,
}


# ---------------------------------------------------------------------------
def mq_payload_from_events(
    record: 'SyncableModel',
    serializer: type[LCModelSerializer],
) -> EventData:
    """
    Get all unsynced ConsoleEvent records for the
    specified Console record that was just changed.

    Merge event data into Concordia MQ payload that
    will be published to the relevant MQ room.
    """
    serialized = serialized_record_data(record, serializer)
    events = ConcordEvents.objects.tracks(record).unsynced().order_by('-pgh_created_at')

    if not record_is_syncable(record):
        return EventData({}, events)

    event_data = []
    for event in events:
        record_diff: dict[str, Any] = event.pgh_diff or {}
        if record_diff:
            diff = {'modified': event.pgh_created_at}
            # Extract new value from record diff and ignore the old value
            for field, values in record_diff.items():
                diff[field] = values[1]

            for field in constraint_requirements(record_diff, record):
                try:
                    diff[field] = serialized[field]
                except KeyError:
                    logger.error(f'Unexpected error! Field: {field} not found in serialized data')
                    continue

        else:
            diff = serialized
            diff['modified'] = event.pgh_created_at

        event_data.append(diff)

    merged_data = merge_event_diff_data(event_data)

    # Ensure that `cid` field is present so the
    # record can be retrieved on Sync Subscriber
    if 'cid' not in merged_data:
        merged_data['cid'] = {'value': record.cid}

    return EventData(convert_pks_to_cids(record, serialized, merged_data), events)


# ---------------------------------------------------------------------------
def convert_pks_to_cids(record: 'SyncableModel', serialized: dict, merged_data: dict) -> dict:
    """
    Convert Foreign Key field value to Canonical ID for REST API compatibility.
    """
    related_fields = [f.name for f in record._meta.fields if f.related_model]

    for field in related_fields:
        if field in merged_data:
            merged_data[field]['value'] = serialized[field]

    return merged_data


# ---------------------------------------------------------------------------
def serialized_record_data(record: 'SyncableModel', serializer: type[LCModelSerializer]) -> dict:
    """
    Serialize record to ensure that data is ready for API use,
    such as FK fields getting converted to Canonical IDs.
    """
    api_request = Request(request=http_request)
    rs = serializer(instance=record, context={'request': api_request})
    readonly_fields = rs.get_readonly_fields()
    return {k: v for k, v in rs.data.items() if k not in readonly_fields}


# ---------------------------------------------------------------------------
def constraint_requirements(record_diff: dict, record: 'SyncableModel') -> set[str]:
    """
    Multi-field constraints require that all fields be present to perform validation.
    So when a ConcordEvent diff doesn't include all the necessary fields, they must be
    added from serialized data and included in the MQ payload, if the values didn't change.

    :param record_diff: The fields that were changed in the local editing event.
    :param record: The database record in question.
    :return: list tuples of fields from Constraints that match against multiple fields.
        Example return values:
          * [('name', 'patterntype', 'category')]
          * [('category', 'action_group')]
          * [('policy', 'code', 'attribute'), ('code', 'attribute')]
    """
    extra_fields: set[str] = set()
    if not (record_constraints := record.get_constraints()):
        return extra_fields

    diff_fields = set(record_diff.keys())

    for model_class, model_constraints in record_constraints:
        # model_constraints are lists of tuples
        # [('name', 'patterntype', 'category')]
        # [('category', 'action_group')]
        # [('policy', 'code', 'attribute'), ('code', 'attribute')]
        for constraint in model_constraints:
            try:
                if len(constraint.fields) > 1:  # type: ignore
                    if not diff_fields.issuperset(set(constraint.fields)):  # type: ignore[attr-defined]
                        extra_fields.update(constraint.fields)  # type: ignore[attr-defined]
            except AttributeError:
                continue

    try:
        return extra_fields - record.LOCAL_CONSTRAINT_FIELDS
    except AttributeError:
        return extra_fields


# ---------------------------------------------------------------------------
def mark_events_synced(events: Iterable[ConcordEvents]) -> None:
    """
    Mark all the Concord Events as Synced with Publisher.
    """
    with pgtrigger.ignore():
        for event in events:
            context: dict[str, Any] = event.pgh_context or {}
            context[SYNC_COMPLETED_KEY] = True
            event.pgh_context = context
            event.save(update_fields=['pgh_context'])


# ---------------------------------------------------------------------------
def record_is_syncable(record: 'SyncableModel') -> bool:
    """
    Helper function to determine if a record should be synced or not.
    """
    if not record:
        return False

    try:
        return record.is_syncable()
    except AttributeError:
        pass

    return getattr(record, SYNCMODE_KEY, SYNCMODES.Sync) == SYNCMODES.Sync


# ---------------------------------------------------------------------------
def merge_event_diff_data(events: list[dict]) -> dict:
    """
    Merge all the fields from the list of diff dictionaries,
    into a dict, keeping the latest value.

    This data dict will then be posted to a Sync Publisher or Subscriber
    where each field can be compared, to ensure that the sync operation
    does not stomp on newer changes.

    data = [
        {'name': 'Allen Oxford Exc',
        'comments': 'They are in Ohio',
        'modified': datetime.datetime(2023, 1, 28, 19, 33, 46, 156944, tzinfo=timezone.utc)},

        {'name': 'Allen Oxford Tractors',
        'modified': datetime.datetime(2023, 1, 28, 19, 35, 59, 226902, tzinfo=timezone.utc)}
    ]

    :returns
    {
        'name': {
            'value': 'Allen Oxford Tractors',
            'modified': datetime.datetime(2023, 1, 28, 19, 35, 59, 226902, tzinfo=timezone.utc),
        },
        'comments': {
            'value': 'They are in Ohio',
            'modified': datetime.datetime(2023, 1, 28, 19, 33, 46, 156944, tzinfo=timezone.utc),
        },
    }
    """
    if len(events) > 1:
        # Ensure sort order from newest to oldest, to skip older fields
        events.sort(key=lambda evt: evt['modified'], reverse=True)

    merged_event_data = {}
    for event_diff in events:
        modified = event_diff['modified']
        for field, value in event_diff.items():
            if field not in merged_event_data:
                merged_event_data[field] = {'value': value, 'modified': modified}

    merged_event_data.pop('modified', None)

    return merged_event_data


# ---------------------------------------------------------------------------
def prune_record_fields(record: 'SyncableModel', mq_payload_data: dict) -> dict:
    """
    Takes a Message Queue payload dict of fields with `value` / `modified`
    values, and compares against ConcordEvent history. Return all fields
    in the payload that are newer than the last change for that field.

    data = {
        'name': {
            'value': 'Allen Oxford Tractors',
            'modified': datetime.datetime(2023, 1, 28, 19, 35, 59, 226902, tzinfo=timezone.utc),
        },
        'comments': {
            'value': 'They are in Ohio',
            'modified': datetime.datetime(2023, 1, 28, 19, 33, 46, 156944, tzinfo=timezone.utc),
        },
    }
    """
    record_events = ConcordEvents.objects.tracks(record)
    pruned_fields = {}

    for field, params in mq_payload_data.items():
        # payload will contain CID field value for record retrieval,
        # in which case it won't have the "modified" field, so skip.
        if not (modified := params.get('modified')):
            continue
        if not record_events.filter(
            pgh_created_at__gte=modified,
            pgh_diff__has_key=field,
        ).exists():
            pruned_fields[field] = params['value']

    return pruned_fields


# ---------------------------------------------------------------------------
def persist_changed_fields(
    record: 'SyncableModel',
    serializer: type[LCModelSerializer],
    cleaned_data: dict,
    user: 'User',
) -> Status:
    """
    Save changed fields to database. All relevancy
    checks should be performed before this point.
    """
    if record:
        cleaned_data.pop('cid', None)

    if not cleaned_data:
        return Status(True, {'message': 'No changes to save'})

    api_request = Request(request=http_request)
    api_request.user = user

    model_serializer = serializer()
    readonly_fields = set(model_serializer.get_readonly_fields())
    writable_fields = set(model_serializer.get_fields().keys()) - readonly_fields
    partial_update = bool(writable_fields.difference(set(cleaned_data.keys())))

    # TODO extend serializer to generate MQ-oriented errors
    rs = serializer(
        data=cleaned_data,
        instance=record,
        context={'request': api_request},
        partial=partial_update,
    )

    if not rs.is_valid():
        logger.info(f'Concordia invalid serializer {rs.errors}')
        return Status(False, {'errors': rs.errors})

    updated_record = rs.save()

    return Status(True, {'message': f'Sync operation success for: {updated_record}'})


__all__ = (
    'constraint_requirements',
    'mq_payload_from_events',
    'merge_event_diff_data',
    'mark_events_synced',
    'convert_pks_to_cids',
    'serialized_record_data',
    'prune_record_fields',
    'persist_changed_fields',
)
