import hashlib
import os
import re
from datetime import datetime
from logging import getLogger
from typing import Dict, Iterable, Optional, Set, Tuple
from urllib.parse import urljoin

import requests
from NEMO.models import AdjustmentRequest, Project, Tool, UsageEvent, User
from NEMO.typing import QuerySetType
from NEMO.utilities import render_email_template
from NEMO.views.adjustment_requests import send_request_received_email
from NEMO.views.notifications import create_adjustment_request_notification
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.datastructures import OrderedSet

from NEMO_osticket.customizations import OsTicketCustomization
from NEMO_osticket.exceptions import OsTicketException
from NEMO_osticket.models import (
    OstFormEntry,
    OstFormEntryValues,
    OstFormField,
    OstHelpTopic,
    OstListItems,
    OstObjectType,
    OstStaff,
    OstThread,
    OstThreadEntry,
    OstThreadEntryType,
    OstTicket,
    OstUser,
    OstUserEmail,
)

api_logger = getLogger(__name__)


TICKETS_PATH = "/api/tickets.json"


class OsTicketSearchObject:
    def __init__(
        self,
        email: str = None,
        is_open=None,
        search: str = None,
        start: datetime = None,
        end: datetime = None,
        tool: Tool = None,
    ):
        self.email = email
        self.is_open = is_open
        self.search = search
        self.start = start
        self.end = end
        self.tool = tool
        self.allowed_ids: Optional[Set[int]] = None

    def update_allowed_ids(self, new_allowed_ids: Optional[Iterable[int]]):
        if new_allowed_ids is not None:
            if self.allowed_ids is None:
                self.allowed_ids = set(new_allowed_ids)
            else:
                self.allowed_ids = self.allowed_ids.intersection(set(new_allowed_ids))

    def search_tickets(self, preload=True) -> QuerySetType[OstTicket]:
        if not settings.DATABASES.get("osticket", False):
            return set()
        if self.email:
            self.update_allowed_ids(
                OstTicket.objects.filter(user__ostuseremail__address=self.email).values_list("ticket_id", flat=True)
            )
        # is_open can be True (open), False (closed) or None (both)
        if self.is_open is not None:
            if self.is_open:
                self.update_allowed_ids(
                    OstTicket.objects.filter(status__state="open").values_list("ticket_id", flat=True)
                )
            else:
                self.update_allowed_ids(
                    OstTicket.objects.exclude(status__state="open").values_list("ticket_id", flat=True)
                )
        if self.search:
            threads = OstThreadEntry.objects.filter(
                thread__object_type=OstObjectType.TICKET, body__icontains=self.search
            )
            form_entries = OstFormEntryValues.objects.filter(
                entry__object_type=OstObjectType.TICKET, value__icontains=self.search
            )
            if self.start:
                form_entries = form_entries.filter(entry__created__gte=self.start)
            if self.end:
                form_entries = form_entries.filter(entry__created__lte=self.end)
            if self.allowed_ids is not None:
                threads = threads.filter(thread__object_id__in=self.allowed_ids)
                form_entries = form_entries.filter(entry__object_id__in=self.allowed_ids)
            valid_thread_ticket_ids = list(threads.values_list("thread__object_id", flat=True))
            valid_form_ticket_ids = list(form_entries.values_list("entry__object_id", flat=True))
            self.update_allowed_ids(set(valid_thread_ticket_ids + valid_form_ticket_ids))
        if self.tool:
            self.update_allowed_ids(filter_tickets_for_tool(self.tool, self.allowed_ids))
        if self.allowed_ids is None:
            self.allowed_ids = OstTicket.objects.values_list("ticket_id", flat=True)
        if preload:
            return get_preloaded_os_tickets(self.allowed_ids, self.start, self.end)
        else:
            return OstTicket.objects.filter(ticket_id__in=self.allowed_ids)


def filter_tickets_for_tool(tool: Tool, allowed_ticket_ids=None) -> Set[int]:
    # We are making sure we are only getting tickets linked to the given tool
    from NEMO_osticket.customizations import OsTicketCustomization

    list_item = OsTicketCustomization.get_osticket_tool_matching_value(tool)
    form_field = OsTicketCustomization.get_osticket_tool_matching_field()
    form_entry_values = OstFormEntryValues.objects.filter(field=form_field, entry__object_type=OstObjectType.TICKET)
    if allowed_ticket_ids is not None:
        # Filter by specific ticket ids if available
        form_entry_values = form_entry_values.filter(entry__object_id__in=allowed_ticket_ids)
    ticket_ids = set()
    if list_item:
        # do some pre-filtering even if not perfect to see if the list id is even in there
        # we are checking for both single and double quotes for the id, should narrow it down quite a bit
        for form_entry_value in form_entry_values.filter(
            Q(value__icontains=f"'{list_item.id}'") | Q(value__icontains=f'"{list_item.id}"')
        ).prefetch_related("entry"):
            try:
                if str(list_item.id) == form_entry_value.get_list_item_id():
                    ticket_ids.add(form_entry_value.entry.object_id)
            except Exception as e:
                api_logger.debug(e)
    else:
        # just get corresponding tickets with value
        ticket_ids = (
            form_entry_values.filter(value=OsTicketCustomization.get_tool_matching_nemo_property(tool))
            .values_list("entry__object_id", flat=True)
            .distinct()
        )
    return ticket_ids


def get_preloaded_os_tickets(
    ticket_ids: Iterable[int], start: datetime = None, end: datetime = None, order_by="-created"
) -> Set[OstTicket]:
    """Load ticket with everything we need"""
    tickets: OrderedSet[OstTicket] = OrderedSet()
    threads = list(OstThread.objects.filter(object_id__in=ticket_ids, object_type=OstObjectType.TICKET))
    thread_entries = list(OstThreadEntry.objects.filter(thread__in=[thread.id for thread in threads]))
    form_entries = list(
        OstFormEntry.objects.filter(object_id__in=ticket_ids, object_type=OstObjectType.TICKET).prefetch_related(
            "ostformentryvalues_set__field"
        )
    )
    filtered_tickets = (
        OstTicket.objects.filter(ticket_id__in=ticket_ids).order_by(order_by).prefetch_related("user", "staff", "topic")
    )
    if start:
        filtered_tickets = filtered_tickets.filter(created__gte=start)
    if end:
        filtered_tickets = filtered_tickets.filter(created__lte=end)
    # preload nemo users
    nemo_users = {
        user["username"]: user
        for user in User.objects.filter(
            username__in=[ticket.user.name for ticket in filtered_tickets if ticket.user]
            + [thread_entry.poster for thread_entry in thread_entries]
        ).values("username", "first_name", "last_name")
    }
    for thread_entry in thread_entries:
        if thread_entry.poster:
            thread_entry.nemo_user = nemo_users.get(thread_entry.poster)
    list_items = {str(item.id): item.value for item in OstListItems.objects.all()}
    tool_matching_field_id = OsTicketCustomization.get_int("osticket_tool_matching_field_id")
    for ticket in filtered_tickets:
        # Orderedset is very important here to keep the order
        ticket.ostthread_set = OrderedSet([thread for thread in threads if thread.object_id == ticket.ticket_id])
        ticket.thread = next(iter(ticket.ostthread_set))
        ticket.ostformentry_set = OrderedSet(
            [form_entry for form_entry in form_entries if form_entry.object_id == ticket.ticket_id]
        )
        # Create a map of all entries so we can access them directly later.
        # The map will have the field name, and a tuple of label and value: {"priority": ("Priority Level", "High")}
        for form_entry in form_entries:
            if form_entry.object_id == ticket.ticket_id:
                for form_entry_value in form_entry.ostformentryvalues_set.all():
                    if form_entry_value.field_id != tool_matching_field_id:
                        display_value = form_entry_value.field.label
                        if form_entry_value.field.is_list_type() and form_entry_value.get_list_item_id():
                            value = list_items.get(form_entry_value.get_list_item_id())
                        else:
                            value = form_entry_value.value
                        ticket.form_entry_values[form_entry_value.field.name] = (display_value, value)
        ticket.ostthreadentry_set = OrderedSet(
            [
                thread_entry
                for thread_entry in thread_entries
                if thread_entry.thread_id in [thread.id for thread in ticket.ostthread_set]
            ]
        )
        if ticket.user:
            ticket.nemo_user = nemo_users.get(ticket.user.name)
        ticket.subject()  # preload subject
        tickets.add(ticket)
    return tickets


def create_osticket_user(user: User) -> OstUser:
    """Adds a user to OsTicket"""
    # let's create the user with a fake default email id and assign it later (since it cannot be null and the user cannot be null either on the email object)
    osticket_user = OstUser.objects.create(name=user.username, status=0, default_email_id=0)
    user_email = OstUserEmail.objects.create(user=osticket_user, address=user.email, flags=0)
    osticket_user.default_email_id = user_email.id
    osticket_user.save()
    return osticket_user


def add_reply_to_ticket(request, ticket: OstTicket, user: User, message: str, internal: bool = False):
    """Adds a reply to a ticket"""
    thread = (
        OstThread.objects.filter(object_id=ticket.ticket_id, object_type=OstObjectType.TICKET)
        .prefetch_related("ostthreadentry_set")
        .last()
    )
    username = user.username
    osticket_user, osticket_staff = get_staff_or_user_or_create(user)
    entry_type = OstThreadEntryType.MESSAGE
    # It's only a response if the user is a staff in osticket
    if osticket_staff:
        entry_type = OstThreadEntryType.RESPONSE
    if internal:
        entry_type = OstThreadEntryType.INTERNAL_NOTE
    flags = 64 if internal else 1
    OstThreadEntry.objects.create(
        type=entry_type,
        flags=flags,
        poster=username,
        thread=thread,
        staff=osticket_staff,
        user=osticket_user,
        body=message,
        pid=thread.id,
        format="text",
        ip_address=get_client_ip(request),
    )
    if entry_type == OstThreadEntryType.RESPONSE:
        thread.lastresponse = timezone.now()
        thread.save(update_fields=["lastresponse"])
    elif entry_type == OstThreadEntryType.MESSAGE:
        thread.lastmessage = timezone.now()
        thread.save(update_fields=["lastmessage"])
    ticket.updated = timezone.now()
    ticket.save(update_fields=["updated"])


def get_staff_or_user_or_create(user: User) -> Tuple[OstUser, OstStaff]:
    # This function will try to find a staff in OsTicket ("Agent") or a user, and if none exist will create a user
    osticket_staff = OstStaff.objects.filter(email=user.email).last()
    osticket_user = None
    # if we found the staff, we are done
    if not osticket_staff:
        # otherwise let's find the user
        osticket_user = OstUser.objects.filter(ostuseremail__address=user.email).last()
        # we have a user, we are done
        if not osticket_user:
            # no staff and no user, let's add a user (even if the user is staff in NEMO)
            osticket_user = create_osticket_user(user)
    return osticket_user, osticket_staff


@transaction.atomic
def create_os_ticket(request, data: Dict, tool: Optional[Tool] = None):
    user = request.user
    os_ticket_service = get_os_ticket_service()
    if os_ticket_service.get("available", False):
        try:
            full_tickets_url = os_ticket_service["url"] + TICKETS_PATH
            keyword_arguments = os_ticket_service.get("keyword_arguments", {})
            json_data = data
            # usage_event and project cannot be serialized
            usage_event = json_data.get("usage_event")
            if "usage_event" in json_data:
                del json_data["usage_event"]
            project = json_data.get("project")
            if "project" in json_data:
                del json_data["project"]
            # update with matching form field if applicable
            form_field = OsTicketCustomization.get_osticket_tool_matching_field()
            if form_field:
                if form_field.is_list_type():
                    json_data.update({form_field.name: OsTicketCustomization.get_osticket_tool_matching_value(tool).id})
                else:
                    json_data.update({form_field.name: OsTicketCustomization.get_tool_matching_nemo_property(tool)})
            json_data.update({"email": user.email, "name": user.username})
            topic = OstHelpTopic.objects.filter(topic_id=json_data.get("topicId")).first()
            topic = topic.topic if topic else None

            # Create the corresponding adjustment request in NEMO if the box is checked
            adjustment_request = None
            if (
                OsTicketCustomization.get_bool("osticket_create_ticket_create_adjustment_request_option")
                and "create_adjustment_request" in data
                and data["create_adjustment_request"]
            ):
                data.pop("create_adjustment_request")
                adjustment_request = create_adjustment_request(
                    request, json_data["message"], topic, tool, usage_event, project
                )

            json_data["subject"] = format_ticket_subject(
                request, json_data["subject"], topic, tool, usage_event, project, adjustment_request
            )
            json_data["message"] = "data:text/html," + format_ticket_message(
                request, json_data["message"], topic, tool, usage_event, project, adjustment_request
            )
            response = requests.post(full_tickets_url, json=json_data, **keyword_arguments)
            response.raise_for_status()
            internal_note = OsTicketCustomization.get("osticket_create_ticket_internal_note_template")
            if internal_note.strip():
                message = format_ticket_internal_note(
                    request, json_data["subject"], topic, tool, usage_event, project, adjustment_request
                )
                if message.strip():
                    # We need to add an internal note
                    ticket_number = response.json()
                    add_reply_to_ticket(
                        request, OstTicket.objects.get(number=ticket_number), user, message, internal=True
                    )

        except Exception as e:
            raise OsTicketException(e)
    else:
        raise OsTicketException("OsTicket is not available")


def format_ticket_subject(
    request,
    subject: str,
    topic,
    tool: Tool,
    usage_event: UsageEvent = None,
    project: Project = None,
    adjustment_request: AdjustmentRequest = None,
) -> str:
    user: User = request.user
    template = OsTicketCustomization.get("osticket_create_ticket_subject_template")
    dictionary = {
        "subject": subject,
        "topic": topic,
        "tool": tool,
        "user": user,
        "usage_event": usage_event,
        "project": project,
        "adjustment_request": adjustment_request,
    }
    return render_template(template, dictionary, request)


def format_ticket_message(
    request,
    message: str,
    topic,
    tool: Tool,
    usage_event: UsageEvent = None,
    project: Project = None,
    adjustment_request: AdjustmentRequest = None,
) -> str:
    user: User = request.user
    template = OsTicketCustomization.get("osticket_create_ticket_message_template")
    dictionary = {
        "message": message,
        "topic": topic,
        "tool": tool,
        "user": user,
        "usage_event": usage_event,
        "project": project,
        "adjustment_request": adjustment_request,
    }
    return render_template(template, dictionary, request)


def format_ticket_internal_note(
    request,
    message: str,
    topic,
    tool: Tool,
    usage_event: UsageEvent = None,
    project: Project = None,
    adjustment_request: AdjustmentRequest = None,
) -> str:
    user: User = request.user
    template = OsTicketCustomization.get("osticket_create_ticket_internal_note_template")
    dictionary = {
        "message": message,
        "topic": topic,
        "tool": tool,
        "user": user,
        "usage_event": usage_event,
        "project": project,
        "adjustment_request": adjustment_request,
    }
    return render_template(template, dictionary, request)


def create_adjustment_request(
    request, message: str, topic, tool: Tool = None, usage_event: UsageEvent = None, project: Project = None
) -> AdjustmentRequest:
    user: User = request.user
    template = OsTicketCustomization.get("osticket_create_ticket_create_adjustment_request_template")
    dictionary = {
        "message": message,
        "topic": topic,
        "tool": tool,
        "user": user,
        "usage_event": usage_event,
        "project": project,
    }
    adjustment_request_description = render_template(template, dictionary, request)
    adjustment_request = AdjustmentRequest.objects.create(
        creator=user, description=adjustment_request_description, item=usage_event
    )
    create_adjustment_request_notification(adjustment_request)
    reviewers: Set[User] = set(list(adjustment_request.reviewers()))
    send_request_received_email(request, adjustment_request, False, reviewers)
    return adjustment_request


def render_template(template, dictionary, request) -> str:
    return render_email_template(template, dictionary, request)


def get_client_ip(request):
    x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
    if x_forwarded_for:
        ip = x_forwarded_for.split(",")[0]
    else:
        ip = request.META.get("REMOTE_ADDR")
    return ip


def get_osticket_form_fields(exclude_matching=True) -> Set[OstFormField]:
    # we need to exclude the tool matching field since we will be setting it automatically
    form_fields = set()
    if settings.DATABASES.get("osticket", False):
        form_fields = OstFormField.objects.filter(form__type=OstObjectType.TICKET)
        if exclude_matching:
            form_fields = form_fields.exclude(
                id=OsTicketCustomization.get_int("osticket_tool_matching_field_id") or None
            )
    return form_fields


def get_ticket_form_field_labels() -> Dict[str, str]:
    return {field.name: field.label for field in get_osticket_form_fields()}


def check_and_save_osticket_icon():
    url = get_os_ticket_service().get("url")
    name = "osticket_icon.png"

    base_dir = settings.STATIC_ROOT

    # 1 Try to find the favicon URL from the HTML <link> tag
    try:
        html = requests.get(url, timeout=5).text
        match = re.search(r'<link[^>]+rel=["\'](?:shortcut icon|icon)["\'][^>]*>', html, re.I)
        if match:
            href_match = re.search(r'href=["\'](.*?)["\']', match.group(0), re.I)
            favicon_url = urljoin(url, href_match.group(1)) if href_match else None
        else:
            favicon_url = None
    except:
        favicon_url = None

    # 2 Fallback to /favicon.ico if not found
    if not favicon_url:
        favicon_url = urljoin(url, "/favicon.ico")

    # 3 Download the favicon
    try:
        resp = requests.get(favicon_url, timeout=5)
        if resp.status_code != 200 or not resp.content:
            return
        content = resp.content
    except:
        return

    # 4 Compare with the existing file
    save_path = os.path.join(base_dir, f"{name}")
    new_hash = hashlib.md5(content).hexdigest()

    if os.path.exists(save_path):
        with open(save_path, "rb") as f:
            existing_hash = hashlib.md5(f.read()).hexdigest()
        if existing_hash == new_hash:
            return

    # 5 Save the new file
    with open(save_path, "wb") as f:
        f.write(content)


def get_os_ticket_service():
    return getattr(settings, "OSTICKET_SERVICE", {})
