import datetime
from collections import defaultdict

from django.db.models import Prefetch
from rest_framework import serializers, status

from api.helper.resource_map_helper import helper_recalculate_and_get_redis_resource_map
from api.models import TtSetting, TtWeekPattern, TtAvailability, TtStartPreference, \
    TtUsagePreference, TtActivityTemplate, TtActivity, TtAcademicTerm, TtWeek, TtSuitability, TtStaff, TtLocation
from api.models.audit_trail import AuditTrail
from api.models.audit_trail_details import AuditTrailDetails
from api.views.admin.base import AdminApiBase
from api.validator import BaseValidator
from api.utils import decrypt_aes_128_cbc, encrypt_aes_128_cbc, generate_kafka_data_update, log_critical_error, get_exception_detail, \
    get_add_new_data, get_ip, get_old_new_data, bulk_sync_to_redis, get_multiple_old_new_data, search_old_new_data, \
    time_to_slot, convert_to_redis_week, check_availability_available, check_resource_map_available, \
    get_resource_map_redis_data
from rest_framework.exceptions import ValidationError
from api.translation import __
from backend.kafka import send_request
from backend.redis_client import redis_client


class ResourcesUpdateRequirement(AdminApiBase):
    def validate_request(self,request):
        # wont update module id, just want based on module id get the activity template
        rules = {
            "activity_id": "required|array|exists:api.TtActivity,id",
        }
        # if need rename field, can put here
        attribute = {
            # "email": __("attr.email"),
        }
        # validate id first, cause need get activity by id, if wrong id direct return error
        validator = BaseValidator(request.data,rules,attribute)
        error = validator.validate()
        if error:
            raise ValidationError(error)

        edit_activity = (
            TtActivity.objects
            .select_related("activity_template", "department", "academic_term", "week_pattern","module", "activity_type", "zone", "availability","start_preference", "usage_preference")
            .prefetch_related(
                "week",
                "week_pattern__week",
                "staff_preset",
                "staff_suitability",
                "location_preset",
                "location_suitability",
                # tags means the relation add in models, __tag means the relation table tag_id get the tag object #2
            )
            .filter(id__in=request.data.get("activity_id"))
        )

        # 2026-03-16 Schedule activity cant update requirement
        for a in edit_activity:
            if a.scheduled:
                raise ValidationError({
                    "error": __('validation.not_available_for_scheduled_activity'),
                    "errors": {"id": __("validation.not_available_for_scheduled_activity")}
                })


        rules = {
            "suggested_day": "nullable",
            "suggested_time": "nullable",
            "staff_requirement": "nullable|integer",
            "staff_preset": "nullable|array|exists:api.TtStaff,id",
            "staff_suitability": "nullable|array|exists:api.TtSuitability,id",
            "location_requirement": "nullable|integer",
            "location_preset": "nullable|array|exists:api.TtLocation,id",
            "location_suitability": "nullable|array|exists:api.TtSuitability,id",
        }

        # if need rename field, can put here
        attribute = {
            # "email": __("attr.email"),
        }

        validator = BaseValidator(request.data,rules,attribute)
        error = validator.validate()

        # custom error after basic validation

        if error:
            raise ValidationError(error)

        return edit_activity


    def post(self, request):
        # Validate input
        try:
            edit_activity = self.validate_request(request)
            setting_params = {
                "minute_per_slot",
                "slot_per_day",
                "slot_per_week"
            }
            settings = TtSetting.get_multiple_setting(setting_params)
            minute_per_slot = int(settings["minute_per_slot"])
            slot_per_day = int(settings["slot_per_day"])
            slot_per_week = int(settings["slot_per_week"])
            first_activity = edit_activity.first()

            academic_term_id = first_activity.academic_term_id
            academic_term = TtAcademicTerm.objects.filter(id=academic_term_id).prefetch_related(Prefetch("week", queryset=TtWeek.objects.order_by("week"))).first()

            # put as array first, incase can do for bulk update
            activity_ids = request.data.get("activity_id")
            action="resource_update_requirement" # this is for audit log only

            # defined as None first
            staff_preset = request.data.get("staff_preset")
            staff_suitability = request.data.get("staff_suitability")
            location_preset = request.data.get("location_preset")
            location_suitability = request.data.get("location_suitability")
            staff_requirement = None
            location_requirement = None

            # this is handle for update activity table data, when got pass in only need update
            if "staff_requirement" in request.data:
                staff_requirement = int(request.data.get("staff_requirement")) or 0
            if "location_requirement" in request.data:
                location_requirement = int(request.data.get("location_requirement")) or 0

            valid_fields = {f.column for f in TtActivity._meta.fields}
            updated = False
            update_fields = {}
            # not allow update academic_term, so even FE got give also ignore
            ignore_fields = ['id','timestamp','signature']
            
            # suggested_time_slot just used to store data in redis
            suggested_time_slot = None
            for key, val in request.data.items():
                if key not in ignore_fields and key in valid_fields:
                    updated = True
                    match key:
                        case "staff_requirement" | "location_requirement":
                            # got extra handle the value for these key, so the param same name as the key, so can using this code to handle
                            update_fields[key] = locals()[key]
                        case "suggested_time":
                            if val:
                                suggested_time = datetime.datetime.strptime(request.data["suggested_time"], "%H:%M")
                                suggested_time_slot = time_to_slot(suggested_time, minute_per_slot)
                            # no val means passing null from FE, need remove
                            update_fields['suggested_time_slot'] = suggested_time_slot
                            update_fields[key] = val
                        case _:
                            # default the key will same as db column name
                            update_fields[key] = val

            # FE got pass in either 1 also will update the requirement_type
            # if only 1 activity, need cater many scenario, using this handle
            # if multiple activity, just based on FE pass in data to update
            if len(activity_ids) == 1:
                if "staff_preset" in request.data or "staff_suitability" in request.data or "staff_requirement" in request.data:
                    """
                    used case for request.data.get("staff_suitability") or ("staff_suitability" not in request.data and first_activity.staff_suitability.exists())
                    
                    request.data.get("staff_suitability") 
                    FE got pass in value for staff_suitability
                    
                    OR
                    ("staff_suitability" not in request.data and first_activity.staff_suitability.exists())
                    FE no pass in param for staff_suitability, and current the activity have preset for staff_suitability
                    
                    this case will remain type as "suitability"
                    but if FE got pass in param staff_suitability but is None or [], the type will change to wildcard
                    """
                    if request.data.get("staff_preset") or ("staff_preset" not in request.data and first_activity.staff_preset.exists()):
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['preset']
                        # if got pass in staff key, ignore FE requirement, BE self handle it
                        if request.data.get("staff_preset"):
                            staff_requirement = len(request.data.get("staff_preset") or [])
                        else:
                            staff_requirement = len(first_activity.staff_preset.all())
                        update_fields['staff_requirement'] = staff_requirement
                    elif (
                        request.data.get("staff_suitability") or
                        ("staff_suitability" not in request.data and first_activity.staff_suitability.exists()) or
                        request.data.get("staff_requirement") or
                        ("staff_requirement" not in request.data and first_activity.staff_requirement > 0)
                    ):
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['wildcard']
                    else:
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['none']

                if "location_preset" in request.data or "location_suitability" in request.data or "location_requirement" in request.data:
                    if request.data.get("location_preset") or ("location_preset" not in request.data and first_activity.location_preset.exists()):
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['preset']
                        # if got pass in location key, ignore FE requirement, BE self handle it
                        if request.data.get("location_preset"):
                            location_requirement = len(request.data.get("location_preset") or [])
                        else:
                            location_requirement = len(first_activity.location_preset.all())
                        update_fields['location_requirement'] = location_requirement
                    elif (
                        request.data.get("location_suitability") or
                        ("location_suitability" not in request.data and first_activity.location_suitability.exists()) or
                        request.data.get("location_requirement") or
                        ("location_requirement" not in request.data and first_activity.location_requirement > 0)
                    ):
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['wildcard']
                    else:
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['none']
            else:
                # handle for bulk update activity
                # handle for requirement type
                # if got requirement, set as wildcard or not first
                if staff_requirement is not None:
                    if staff_requirement > 0:
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['wildcard']
                    else:
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['none']
                # if got suitability, can set as wildcard, even requirement is 0
                if staff_suitability:
                    update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['wildcard']
                if staff_preset is not None:
                    if staff_preset:
                        update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['preset']
                        # when got preset pass in, requirement always follow preset count
                        staff_requirement = len(staff_preset)
                        update_fields['staff_requirement'] = staff_requirement
                    else:
                        # if user click remove preset and no other data passing in, will need update to none and set requirement to 0
                        if "staff_requirement" not in update_fields:
                            update_fields['staff_requirement_type'] = TtActivity.STAFF_REQUIREMENT_TYPE['none']
                            staff_requirement = 0
                            update_fields['staff_requirement'] = staff_requirement

                # when bulk update, need special handle this scenario
                # if only pass in staff requirement from FE, and without preset and suitability, need rmoeve the preset and suitability
                if staff_requirement is not None and staff_preset is None and staff_suitability is None:
                    staff_preset = []
                    staff_suitability = []
                # if only preset, need remove suitability
                elif staff_preset is not None and staff_suitability is None:
                    staff_suitability = []
                # if only suitability, need remove preset also
                elif staff_suitability is not None and staff_preset is None:
                    staff_preset = []

                # handle for requirement type
                # if got requirement, set as wildcard or not first
                if location_requirement is not None:
                    if location_requirement > 0:
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['wildcard']
                    else:
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['none']
                # if got suitability, can set as wildcard, even requirement is 0
                if location_suitability:
                    update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['wildcard']
                if location_preset is not None:
                    if location_preset:
                        update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['preset']
                        # when got preset pass in, requirement always follow preset count
                        location_requirement = len(location_preset)
                        update_fields['location_requirement'] = location_requirement
                    else:
                        # if user click remove preset and no other data passing in, will need update to none and set requirement to 0
                        if "location_requirement" not in update_fields:
                            update_fields['location_requirement_type'] = TtActivity.LOCATION_REQUIREMENT_TYPE['none']
                            location_requirement = 0
                            update_fields['location_requirement'] = location_requirement

                # when bulk update, need special handle this scenario
                # if only pass in location requirement from FE, and without preset and suitability, need rmoeve the preset and suitability
                if location_requirement is not None and location_preset is None and location_suitability is None:
                    location_preset = []
                    location_suitability = []
                # if only preset, need remove suitability
                elif location_preset is not None and location_suitability is None:
                    location_suitability = []
                # if only suitability, need remove preset also
                elif location_suitability is not None and location_preset is None:
                    location_preset = []

            # be4 save need get the old new data already, because bulk update is no matter got change or no changes also will update
            old_new_data = get_multiple_old_new_data(edit_activity,request.data.items(),ignore_fields,valid_fields)
            if update_fields: # 2026-04-27, ignore updated param, it is useless, this code is replace by "replace in file", so didnt remove param updated
                update_fields['updated_by'] = request.user.id
                edit_activity.update(**update_fields)

            if staff_preset is not None:
                old_new_data = self.update_m2m_field_bulk(edit_activity,'staff_preset',staff_preset,old_new_data)

            if staff_suitability is not None:
                old_new_data = self.update_m2m_field_bulk(edit_activity,'staff_suitability',staff_suitability,old_new_data)

            if location_preset is not None:
                old_new_data = self.update_m2m_field_bulk(edit_activity,'location_preset',location_preset,old_new_data)

            if location_suitability is not None:
                old_new_data = self.update_m2m_field_bulk(edit_activity,'location_suitability',location_suitability,old_new_data)

            # reschedule for scheduled activity
            staff_with_affected_week = {}
            location_with_affected_week = {}
            staff_ids = set()
            location_ids = set()
            if first_activity.scheduled:
                if first_activity.week_pattern:
                    activity_week_ids = [aw.id for aw in first_activity.week_pattern.week.all()]
                else:
                    activity_week_ids = [aw.id for aw in first_activity.week.all()]

                current_staff_ids = list(activity_staff.id for activity_staff in first_activity.staff.all())

            redis_data = {
                "update": {}
            }

            redis_activity_table = "activity"
            redis_staff_table = "staff"
            redis_location_table = "location"
            redis_activity_data = {}
            redis_activity_column = [
                "suggested_day","suggested_time_slot","suggested_time",
                "staff_requirement_type", "staff_preset", "staff_suitability_id",
                "location_requirement_type","location_preset", "location_suitability_id"
            ]

            # if only 1 activity, can edit code
            if edit_activity.count() == 1:
                redis_activity_column.append('code')

            # got new data only means got update, then will for loop request.data, cause the old_new_data['new_data'] for foreign key name, like department_id, will return in department, so cant use it
            if old_new_data['new_data']:
                for edit_id in activity_ids:
                    # every new activity reset the redis_activity_data and append to redis_data['update']
                    redis_activity_data = {"id": edit_id}

                    if "suggested_day" in request.data:
                        redis_activity_data["suggested_day"] = request.data.get("suggested_day")
                    # if got update suggested time, need manual handle the suggested_time_slot update in redis
                    if "suggested_time" in request.data:
                        redis_activity_data["suggested_time_slot"] = suggested_time_slot
                        redis_activity_data["suggested_time"] = request.data.get("suggested_time")
                    if staff_preset is not None:
                        redis_activity_data["staff_preset"] = staff_preset
                    if staff_suitability is not None:
                        redis_activity_data["staff_suitability_id"] = staff_suitability[0] if staff_suitability else None
                    if location_preset is not None:
                        redis_activity_data["location_preset"] = location_preset
                    if location_suitability is not None:
                        redis_activity_data["location_suitability_id"] = location_suitability[0] if location_suitability else None
                    # this not input from FE, so need manual handle
                    if "location_requirement_type" in update_fields and update_fields["location_requirement_type"] is not None:
                        redis_activity_data["location_requirement_type"] = update_fields["location_requirement_type"]
                    if "staff_requirement_type" in update_fields and update_fields["staff_requirement_type"] is not None:
                        redis_activity_data["staff_requirement_type"] = update_fields['staff_requirement_type']
                    if staff_requirement is not None:
                        redis_activity_data["staff_required_no"] = staff_requirement
                    if location_requirement is not None:
                        redis_activity_data["location_required_no"] = location_requirement

                    if len(redis_activity_data)>1:# > 1 means not only id
                        redis_data["update"].setdefault(redis_activity_table, []).append(redis_activity_data)

            staff_with_affected_week = {k: v for k, v in staff_with_affected_week.items() if v}
            location_with_affected_week = {k: v for k, v in location_with_affected_week.items() if v}
            redis_data = helper_recalculate_and_get_redis_resource_map(
                redis_data=redis_data,
                staff_with_affected_week=staff_with_affected_week,
                location_with_affected_week=location_with_affected_week,
                slot_per_week=slot_per_week,
            )
            bulk_sync_to_redis(redis_client, redis_data)

            if old_new_data['old_data'] or old_new_data['new_data']:
                kafka_resources = []
                method = "resources_update_requirement"
                kafka_topic = self.kafka_config['MICROSERVICES_TT_TOPIC']
                if kafka_topic:
                    new_data = old_new_data.get("new_data")
                    relation_fields = ["staff_preset", "staff_suitability", "location_preset", "location_suitability"]
                    relation_data = generate_kafka_data_update(new_data, relation_fields)
                    update_fields.pop("updated_by", None)
                    for id in activity_ids:
                        data = {"id": id, **update_fields, **relation_data}
                        kafka_resources.append(data)
                    kafka_request_data = {
                        "session_id": request.user.name,
                        "activity": kafka_resources,
                    }
                    send_request(kafka_topic, kafka_request_data, None, method)    

                # insert audit trail and details
                # can insert the parent first, then only insert details
                audit_trail = AuditTrail.objects.create(
                    user_id=request.user.id,
                    type=self.audit_type,
                    ip_address=get_ip(request)
                )

                name = ",".join(edit_activity.values_list("name", flat=True))
                remark_param = {
                    "name":name
                }

                # call to a function for insert details, so in future if want change to used worker, can modify in function only
                AuditTrailDetails.custom_insert(
                    audit_trail=audit_trail,
                    action=action,
                    remark_param=remark_param,
                    new_data=old_new_data['new_data'],
                    old_data=old_new_data['old_data'],
                )

            response = {

            }

            return self.api_response(data=response)
        except ValidationError as e:
            first_message = e.detail['error']
            errors = e.detail['errors']
            return self.api_response(error=first_message,errors=errors,code=status.HTTP_400_BAD_REQUEST)
        except Exception as e:
            e_details = get_exception_detail(e)
            log_critical_error(user_id=None,descr=e_details['descr'],url=e_details['url'],trace=e_details['trace'])
            return self.api_response(error=__("message.internal_server_error"),code=status.HTTP_500_INTERNAL_SERVER_ERROR)

    def validate_slot_available(self, first_activity, request, slot_per_day):
        # Process Staff
        current_staff = list(activity_staff.id for activity_staff in first_activity.staff.all())
        return_staff_ids = self.get_available_resource_ids(
            resource_type="staff",
            model=TtStaff,  # Ensure this model name is correct
            current_ids=current_staff,
            request_data=request.data,
            first_activity=first_activity,
            slot_per_day=slot_per_day
        )

        # Process Location
        current_location = list(activity_location.id for activity_location in first_activity.location.all())
        return_location_ids = self.get_available_resource_ids(
            resource_type="location",
            model=TtLocation,  # Ensure this model name is correct
            current_ids=current_location,
            request_data=request.data,
            first_activity=first_activity,
            slot_per_day=slot_per_day
        )

        # 4. Error Validation
        error_details = {
            "error": "",
            "errors": {}
        }
        if len(return_staff_ids) != int(request.data.get("staff_requirement", 0)):
            msg = __("validation.no_available_resources_to_schedule", resource=__("attr.staff"))
            error_details["error"] = msg
            error_details["errors"]["staff_requirement"] = [msg]

        if len(return_location_ids) != int(request.data.get("location_requirement", 0)):
            msg = __("validation.no_available_resources_to_schedule", resource=__("attr.location"))
            error_details["error"] = msg
            error_details["errors"]["location_requirement"] = [msg]

        if error_details["error"]:
            raise ValidationError(error_details)

        return {
            "staff_ids": return_staff_ids,
            "location_ids": return_location_ids,
        }

    def get_available_resource_ids(self, resource_type, model, current_ids, request_data, first_activity, slot_per_day):

        requirement = int(request_data.get(f"{resource_type}_requirement", 0))
        suitability_ids = request_data.get(f"{resource_type}_suitability", [])
        preset_ids = request_data.get(f"{resource_type}_preset", [])

        start_slot = first_activity.scheduled_start_slot
        slot_required = first_activity.slot_required
        if first_activity.week_pattern:
            activity_week_ids = [aw.id for aw in first_activity.week_pattern.week.all()]
        else:
            activity_week_ids = [aw.id for aw in first_activity.week.all()]

        candidate_ids = []
        need_schedule = False
        return_ids = []
        if preset_ids:
            candidate_ids = preset_ids
            requirement = len(candidate_ids)
            need_schedule = True
        # for suitability type, need to check requirement is > 0 also, if 0 will remove all resources
        elif suitability_ids and requirement > 0:
            # need get suitability resources to find need schedule to which resource
            suitability_qs = TtSuitability.objects.filter(id__in=suitability_ids).prefetch_related(resource_type)
            for s in suitability_qs:
                manager = getattr(s, resource_type)
                candidate_ids.extend([item.id for item in manager.all()])
            need_schedule = True
        else:
            # when staff requirement > 0 and no suitability and preset, only need get extra resource to schedule
            if requirement > 0:
                need_schedule = True
        if need_schedule:
            map_name = f"tt{resource_type}resourcemap_set"
            queryset = model.objects.select_related("availability").prefetch_related(map_name)

            if candidate_ids:
                queryset = queryset.filter(id__in=candidate_ids)
            # this is the resource need to do checking is available to schedule or not
            all_allowed_ids = [s.id for s in queryset]
            # when scheduled resources exact inside all_allowed_ids and the length of current_ids is >= then resource requirement, then no need find new resources
            if set(current_ids).issubset(set(all_allowed_ids)) and len(current_ids) >= requirement:
                # if latest resources requirement < than current scheduled resources, need remove some scheduled resources until it match
                for cid in current_ids:
                    return_ids.append(cid)
                    if len(return_ids) == requirement:
                        break
            else:
                # first step must check is already scheduled resource is in allow ids or not, if inside will get this id and pass in first, and this id no need to do checking again
                for current_id in current_ids:
                    # incase if now current_ids is [1,2,3,4] and requirement is 2, all allow is [1,4], so return ids will be [1,4]
                    if current_id in all_allowed_ids:
                        if len(return_ids) >= requirement:
                            break
                        return_ids.append(current_id)
                for item in queryset:
                    if len(return_ids) >= requirement:
                        break
                    if item.id not in current_ids:
                        map_objs = getattr(item, map_name).all()
                        res_map = {rm.week_id: rm.pattern for rm in map_objs if rm.week_id in activity_week_ids}

                        pattern = item.availability.pattern if item.availability else item.availability_pattern
                        is_avail = check_availability_available(pattern, start_slot, slot_per_day)
                        is_map_ok = check_resource_map_available(res_map, start_slot, slot_required)

                        if is_avail and is_map_ok:
                            return_ids.append(item.id)

        return return_ids
