import hashlib
import time
from http import HTTPStatus
from datetime import datetime, timedelta
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import UploadedFile
from django.db import connection, models, transaction
from django.db.models import Q
from rest_framework.views import APIView
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed,ValidationError,PermissionDenied
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from api.models.access_token import AccessToken
from rest_framework.response import Response

from api.models.audit_trail import AuditTrail
from api.models.incoming_api_admin import IncomingApiAdmin
from api.request_auth import validation_payload
from api.utils import get_ip, parse_boolean, throw_validation_error, get_exception_detail, log_critical_error
from django.conf import settings
from api.translation import __
from api.admin_permission_conf import PERMISSIONS,NO_PERMISSION

class APIBase(APIView):
    default_per_page = -1
    default_page = 1
    kafka_config = settings.KAFKA_CONFIG

    # if want put desc, "-name" will handle desc order, if "name" will be asc order
    list_default_order_column = "name"

    default_code_prefix = settings.DEFAULT_CODE_PREFIX
    # when the code or name is auto generate, will used this param to seperate, eg. prefix is m21 so will be m21/
    auto_generate_name_seperator = "/"
    @staticmethod
    def sign(data: dict, secret: str) -> str:
        # Copy data to avoid modifying the original
        sign_data = data

        # Remove 'sign' and 'signature' keys if present
        sign_data.pop('sign', None)
        sign_data.pop('signature', None)
        # Sort by key
        sorted_items = sorted(sign_data.items())

        # Concatenate non-list/dict values
        raw_sign = ''
        for key, val in sorted_items:
            # null,None,"",false,0 also skip
            if val:
                if isinstance(val, (list, dict, UploadedFile)):
                    continue  # skip arrays and upload file
                raw_sign += str(val)

        # Append secret
        raw_sign += secret

        # Return SHA256 hex digest
        return hashlib.sha256(raw_sign.encode('utf-8')).hexdigest()

    @staticmethod
    def update_m2m_field(model, field_name, old_ids, new_ids, old_new_data, two_side=False, generic=False):
        """
        :param edit_model: model want to edit
        :param field_name: the field name eg.suitability
        :param old_ids: list of old ids
        :param new_ids: list of new ids
        :param old_new_data: dict of old_new_data
        :param two_side: when insert/delete a and b, will insert/delete b and a also, mostly used in same table relation liek avoid_concurrency
        :param generic: when relation only have 1 foreign key, and using refer_table and refer_id, eg. tag_relation table
        :return:
        """
        # if new ids pass in by FE is None, make it as empty list, so it can be compare
        if new_ids is None:
            new_ids = []
        if old_ids is None:
            old_ids = []

        to_add = [i for i in new_ids if i not in old_ids]
        to_delete = [i for i in old_ids if i not in new_ids]

        if not (to_add or to_delete):
            return old_new_data

        m2m_field = getattr(model, field_name)
        model_id = model.id
        # only have old_ids, only need old_data, if not it was a insert action, dun have old data
        if old_ids:
            old_new_data["old_data"][field_name] = list(old_ids)

        old_new_data["new_data"][field_name] = list(new_ids)
        if generic:
            # Generic relation handling
            ct = ContentType.objects.get_for_model(model)
            # DELETE
            if to_delete:
                model_class = getattr(model, field_name).model
                model_class.objects.filter(
                    refer_table=ct,
                    refer_id=model.id,
                    **{f"{field_name}_id__in": to_delete}
                ).delete()

            # INSERT
            if to_add:
                model_class = getattr(model, field_name).model
                objs = [
                    model_class(refer_table=ct, refer_id=model.id, **{f"{field_name}_id": pk})
                    for pk in to_add
                ]
                model_class.objects.bulk_create(objs, ignore_conflicts=True)
        elif two_side:
            through = m2m_field.through
            src_field = m2m_field.source_field_name
            tgt_field = m2m_field.target_field_name
            # DELETE both directions (2 queries)
            if to_delete:
                through.objects.filter(**{src_field: model, f"{tgt_field}__in": to_delete}).delete()
                through.objects.filter(**{tgt_field: model, f"{src_field}__in": to_delete}).delete()
            # INSERT both directions (bulk_create)
            if to_add:
                # Fetch instances once
                target_model = m2m_field.model
                target_instances = {s.id: s for s in target_model.objects.filter(id__in=to_add)}
                two_side_objs = []
                for pk in to_add:
                    other_instance = target_instances[pk]
                    two_side_objs.append(through(**{src_field: model, tgt_field: other_instance}))
                    two_side_objs.append(through(**{src_field: other_instance, tgt_field: model}))
                through.objects.bulk_create(two_side_objs, ignore_conflicts=True)
        else:
            # normal relation update
            if to_delete:
                m2m_field.remove(*to_delete)
            if to_add:
                m2m_field.add(*to_add)

        return old_new_data

    @staticmethod
    def update_m2m_field_bulk(models, field_name, new_ids, old_new_data, two_side=False, generic=False):
        """
        Bulk update ManyToMany relation for one or multiple models, optimized for prefetched data.

        :param models: single model or queryset/list of models (with prefetch_related(field_name))
        :param field_name: the field name eg. 'suitability'
        :param new_ids: list of new ids (same for all models)
        :param old_new_data: dict of old_new_data
        :param two_side: when relation is symmetric (eg. avoid_concurrency)
        :param generic: when relation table uses refer_table + refer_id
        :return: old_new_data (with audit logs)
        """
        if not isinstance(models, (list, tuple)) and not hasattr(models, "__iter__"):
            models = [models]

        if new_ids is None:
            new_ids = []
        new_ids = list(set(new_ids))  # deduplicate

        if not models:
            return old_new_data

        model_class = models[0].__class__
        m2m_field = getattr(model_class, field_name)

        for model in models:
            relation = getattr(model, field_name)  # e.g. model.tag
            if generic:
                sub_field = field_name + "_id"
                old_ids = [getattr(obj, sub_field) for obj in relation.all()]
            else:
                # normal m2m
                old_ids = [obj.id for obj in relation.all()]
            if set(old_ids) != set(new_ids):
                old_new_data["old_data"].setdefault(model.id, {})[field_name] = old_ids
                old_new_data["new_data"].setdefault(model.id, {})[field_name] = new_ids
        # Exit early if no changes at all
        if not old_new_data.get("new_data"):
            return old_new_data

        model_ids = [model.id for model in models]

        if generic:
            # take 1 of the model in models to get content type and relation model, cause all will in same model
            model=models[0]
            ct = ContentType.objects.get_for_model(model)
            relation_model = getattr(model, field_name).model

            # DELETE outdated
            relation_model.objects.filter(refer_table=ct, refer_id__in=model_ids).exclude(
                **{f"{field_name}_id__in": new_ids}
            ).delete()

            # INSERT missing
            objs = [
                relation_model(refer_table=ct, refer_id=mid, **{f"{field_name}_id": pk})
                for mid in model_ids for pk in new_ids
            ]
            relation_model.objects.bulk_create(objs, ignore_conflicts=True)

        elif two_side:
            through = m2m_field.through
            # just want to get src_field and target field
            get_field_m2m = getattr(models[0], field_name)
            src_field = f"{get_field_m2m.source_field_name}_id"
            tgt_field = f"{get_field_m2m.target_field_name}_id"

            # DELETE both directions
            through.objects.filter(**{f"{src_field}__in": model_ids}).exclude(
                **{f"{tgt_field}__in": new_ids}
            ).delete()
            through.objects.filter(**{f"{tgt_field}__in": model_ids}).exclude(
                **{f"{src_field}__in": new_ids}
            ).delete()

            # INSERT both directions
            objs = []
            for mid in model_ids:
                for pk in new_ids:
                    objs.append(through(**{src_field: mid, tgt_field: pk}))
                    objs.append(through(**{src_field: pk, tgt_field: mid}))
            through.objects.bulk_create(objs, ignore_conflicts=True)

        else:
            # Normal M2M relation
            through = m2m_field.through
            # just want to get src_field and target field
            get_field_m2m = getattr(models[0], field_name)
            src_field = f"{get_field_m2m.source_field_name}_id"
            tgt_field = f"{get_field_m2m.target_field_name}_id"

            # DELETE outdated
            through.objects.filter(**{f"{src_field}__in": model_ids}).exclude(
                **{f"{tgt_field}__in": new_ids}
            ).delete()

            # INSERT missing
            objs = [through(**{src_field: mid, tgt_field: pk}) for mid in model_ids for pk in new_ids]
            through.objects.bulk_create(objs, ignore_conflicts=True)

        return old_new_data

    def listing_pagination(self,request,model):
        page = request.data.get("page") or self.default_page
        per_page = request.data.get("per_page") or self.default_per_page
        sort_by = request.data.get("sort_by")
        order_by = request.data.get("order_by")
        total = model.count()

        try:
            if sort_by:
                match sort_by:
                    case "activity_template_name":
                        sort_by = "activity_template__name"
                    case "module_name":
                        sort_by = "module__name"
                    case "module_code":
                        sort_by = "module__code"

                if str(order_by).lower() == "desc":
                    ordering_string = f"-{sort_by}"
                else:
                    ordering_string = sort_by
                model = model.order_by(ordering_string)
            else:
                model = model.order_by(self.list_default_order_column)
        except Exception as e:
            # just log the error first, but dun throw interval server error to FE
            e_details = get_exception_detail(e)
            log_critical_error(user_id=None,descr=e_details['descr'],url=e_details['url'],trace=e_details['trace'])

        # -1 means all
        if per_page != -1:
            start_index = (page - 1) * per_page
            end_index = start_index + per_page
            paginated_data = model[start_index:end_index]
        else:
            paginated_data = model

        return {
            "total": total,
            "paginated_data":paginated_data,
            "page": page,
        }

    @staticmethod
    def parse_time_filter_value(value):
        if value in [None, ""]:
            return None

        if hasattr(value, "hour") and hasattr(value, "minute"):
            return value

        for fmt in ("%H:%M:%S", "%H:%M"):
            try:
                return datetime.strptime(str(value), fmt).time()
            except ValueError:
                continue

        return None

    @staticmethod
    def parse_day_filter_value(value):
        if value in [None, ""]:
            return None

        try:
            day_value = int(value)
        except (TypeError, ValueError):
            return None

        if 0 <= day_value <= 6:
            return day_value

        return None

    def listing_filter(self,request,model,exclude_fields=None):
        try:
            integer_filter = [
                "duration",
                "planned_size",
                "module_size",
                "staff_requirement",
                "location_requirement",
                "max_credit",
                "credit_provided",
                "real_size",
                "maximum_period",
                "contract_period",
                "capacity",
                "area",
            ]

            filters = request.data.get("filter")
            if exclude_fields is None:
                exclude_fields = []
            if filters and isinstance(filters, dict) and exclude_fields:
                for exclude_field in exclude_fields:
                    filters.pop(exclude_field, None)
            if filters:
                for key,val in filters.items():
                    if isinstance(val,str):
                        # remove front and end " " if is string
                        val = val.strip()
                    match key:
                        case "code"|"name"|"email"|"desc": # using LIKE %%
                            filter_kwargs = {f"{key}__icontains": val}
                            model = model.filter(**filter_kwargs)
                        case "department_id" | "zone_id" | "activity_type_id" | "pos_id" | "module_id" : # must be array only can do
                            if isinstance(val, (list, tuple)):
                                model = model.filter(**{f"{key}__in": val})
                            else:
                                model = model.filter(**{f"{key}": val})
                        case "module_name" | "module_code" | "activity_template_name":
                            filter_map = {
                                "module_name": "module__name__icontains",
                                "module_code": "module__code__icontains",
                                "activity_template_name": "activity_template__name__icontains",
                            }
                            if val not in [None, ""]:
                                model = model.filter(**{filter_map[key]: val}).distinct()
                        case "staff_suitability" | "location_suitability" | "staff" | "location" | "suitability" | "student_set_module" :
                            if key == "student_set_module":
                                key = "module"
                            if isinstance(val, (list, tuple)):
                                model = model.filter(**{f"{key}__id__in": val}).distinct()
                            else:
                                model = model.filter(**{f"{key}__id": val}).distinct()
                        case "is_part_time" | "scheduled":
                            bool_val = parse_boolean(val)
                            if bool_val is not None:
                                model = model.filter(**{key: bool_val})
                        case "scheduled_start_time" | "suggested_time":
                            exact_time = self.parse_time_filter_value(val)
                            if exact_time is not None:
                                model = model.filter(**{key: exact_time})
                        case "scheduled_start_time_from" | "suggested_time_from":
                            # -5 to remove _from
                            if val is not None:
                                model = model.filter(**{f"{key[:-5]}__gte": val})
                        case "scheduled_start_time_to" | "suggested_time_to":
                            if val is not None:
                                # -3 to remove _to
                                model = model.filter(**{f"{key[:-3]}__lte": val})
                        case "scheduled_day" | "suggested_day":
                            if isinstance(val, (list, tuple, set)):
                                day_values = [
                                    parsed_day
                                    for item in val
                                    for parsed_day in [self.parse_day_filter_value(item)]
                                    if parsed_day is not None
                                ]

                                if day_values:
                                    model = model.filter(**{f"{key}__in": list(set(day_values))})
                            else:
                                exact_day = self.parse_day_filter_value(val)
                                if exact_day is not None:
                                    model = model.filter(**{key: exact_day})
                        case "availability_id" | "start_preference_id" | "usage_preference_id" | "week_pattern_id":
                            if isinstance(val, (list, tuple)):
                                values = list(val)

                                include_null = -1 in values or "-1" in values

                                normal_values = [
                                    item for item in values
                                    if item not in [-1, "-1", None, ""]
                                ]

                                query = Q()

                                if normal_values:
                                    query |= Q(**{f"{key}__in": normal_values})

                                if include_null:
                                    query |= Q(**{f"{key}__isnull": True})

                                if query:
                                    model = model.filter(query)

                            elif val in [-1, "-1"]:
                                model = model.filter(**{f"{key}__isnull": True})

                            elif val not in [None, ""]:
                                model = model.filter(**{key: val})
                        case "journeys":
                            if val not in [None, ""]:
                                model = model.filter(
                                    Q(journey__from_zone__name__icontains=val) |
                                    Q(journey__to_zone__name__icontains=val)
                                ).distinct()
                        case "active_constraints":
                            if val not in [None, ""]:
                                model = model.filter(
                                    Q(free_block__name__icontains=val) |
                                    Q(resource_break__name__icontains=val) |
                                    Q(maximum_hour__name__icontains=val) |
                                    Q(maximum_workspan__name__icontains=val) |
                                    Q(travel_table__name__icontains=val)
                                ).distinct()
                        case s if s.endswith("_min") and isinstance(val, int):
                            if val not in [None, ""]:
                                # -4 is because want remove _min
                                model = model.filter(**{f"{s[:-4]}__gte": val})
                        case s if s.endswith("_max") and isinstance(val, int):
                            if val not in [None, ""]:
                                # -4 is because want remove _max
                                model = model.filter(**{f"{s[:-4]}__lte": val})
                        case _ if key in integer_filter:
                            if val not in [None, ""]:
                                # Exact match if not using min/max
                                model = model.filter(**{key: val})
            return model
        except Exception as e:
            # just log the error first, but dun throw interval server error to FE
            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 model
