import csv
import datetime
from collections import OrderedDict
from functools import partial
from io import BytesIO

from django.contrib.admin.utils import label_for_field
from django.core.exceptions import FieldDoesNotExist
from django.http import FileResponse, StreamingHttpResponse
from django.utils import timezone
from django.utils.dateformat import Formatter
from django.utils.encoding import force_str
from django.utils.formats import get_format
from django.utils.functional import cached_property
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell

from wagtail.admin.widgets.button import Button
from wagtail.coreutils import multigetattr


class Echo:
    """An object that implements just the write method of the file-like interface."""

    def write(self, value):
        """Write the value by returning it, instead of storing in a buffer."""
        return value.encode("UTF-8")


def list_to_str(value):
    return force_str(", ".join(value))


class ExcelDateFormatter(Formatter):
    data = None

    # From: https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
    # To: https://support.microsoft.com/en-us/office/format-numbers-as-dates-or-times-418bd3fe-0577-47c8-8caa-b4d30c528309#bm2
    _formats = {
        # Day of the month, 2 digits with leading zeros.
        "d": "dd",
        # Day of the month without leading zeros.
        "j": "d",
        # Day of the week, textual, 3 letters.
        "D": "ddd",
        # Day of the week, textual, full.
        "l": "dddd",
        # English ordinal suffix for the day of the month, 2 characters.
        "S": "",  # Not supported in Excel
        # Day of the week, digits without leading zeros.
        "w": "",  # Not supported in Excel
        # Day of the year.
        "z": "",  # Not supported in Excel
        # ISO-8601 week number of year, with weeks starting on Monday.
        "W": "",  # Not supported in Excel
        # Month, 2 digits with leading zeros.
        "m": "mm",
        # Month without leading zeros.
        "n": "m",
        # Month, textual, 3 letters.
        "M": "mmm",
        # Month, textual, 3 letters, lowercase. (Not supported in Excel)
        "b": "mmm",
        # Month, locale specific alternative representation usually used for long date representation.
        "E": "mmmm",  # Not supported in Excel
        # Month, textual, full.
        "F": "mmmm",
        # Month abbreviation in Associated Press style. Proprietary extension.
        "N": "mmm.",  # Approximation, wrong for May
        # Number of days in the given month.
        "t": "",  # Not supported in Excel
        # Year, 2 digits with leading zeros.
        "y": "yy",
        # Year, 4 digits with leading zeros.
        "Y": "yyyy",
        # Whether it's a leap year.
        "L": "",  # Not supported in Excel
        # ISO-8601 week-numbering year.
        "o": "yyyy",  # Approximation, same as Y
        # Hour, 12-hour format without leading zeros.
        "g": "h",  # Only works when combined with AM/PM, 24-hour format is used otherwise
        # Hour, 24-hour format without leading zeros.
        "G": "hH",
        # Hour, 12-hour format with leading zeros.
        "h": "hh",  # Only works when combined with AM/PM, 24-hour format is used otherwise
        # Hour, 24-hour format with leading zeros.
        "H": "hh",
        # Minutes.
        "i": "mm",
        # Seconds.
        "s": "ss",
        # Microseconds.
        "u": ".00",  # Only works when combined with ss
        # 'a.m.' or 'p.m.'.
        "a": "AM/PM",  # Approximation, uses AM/PM and only works when combined with h/hh
        # AM/PM.
        "A": "AM/PM",  # Only works when combined with h/hh
        # Time, in 12-hour hours and minutes, with minutes left off if they’re zero.
        "f": "h:mm",  # Approximation, uses 24-hour format and minutes are never left off
        # Time, in 12-hour hours, minutes and ‘a.m.’/’p.m.’, with minutes left off if they’re zero and the special-case strings ‘midnight’ and ‘noon’ if appropriate.
        "P": "h:mm AM/PM",  # Approximation, minutes are never left off, no special case strings
        # Timezone name.
        "e": "",  # Not supported in Excel
        # Daylight saving time, whether it’s in effect or not.
        "I": "",  # Not supported in Excel
        # Difference to Greenwich time in hours.
        "O": "",  # Not supported in Excel
        # Time zone of this machine.
        "T": "",  # Not supported in Excel
        # Timezone offset in seconds.
        "Z": "",  # Not supported in Excel
        # ISO 8601 format.
        "c": "yyyy-mm-ddThh:mm:ss.00",
        # RFC 5322 formatted date.
        "r": "ddd, d mmm yyyy hh:mm:ss",
        # Seconds since the Unix epoch.
        "U": "",  # Not supported in Excel
    }

    def get(self):
        format = get_format("SHORT_DATETIME_FORMAT")
        return self.format(format)

    def __getattr__(self, name):
        if name in self._formats:
            return lambda: self._formats[name]
        raise AttributeError(
            f"'{type(self).__name__}' object has no attribute '{name}'"
        )


class SpreadsheetExportMixin:
    """A mixin for views, providing spreadsheet export functionality in csv and xlsx formats"""

    FORMAT_XLSX = "xlsx"
    FORMAT_CSV = "csv"
    FORMATS = (FORMAT_XLSX, FORMAT_CSV)

    # A list of fields or callables (without arguments) to export from each item in the queryset (dotted paths allowed)
    list_export = []
    # A dictionary of custom preprocessing functions by field and format (expected value would be of the form {field_name: {format: function}})
    # If a valid field preprocessing function is found, any applicable value preprocessing functions will not be used
    custom_field_preprocess = {}
    # A dictionary of preprocessing functions by value class and format
    custom_value_preprocess = {
        datetime.datetime: {
            FORMAT_XLSX: lambda value: (
                value
                if timezone.is_naive(value)
                else timezone.make_naive(value, datetime.timezone.utc)
            )
        },
        (datetime.date, datetime.time): {FORMAT_XLSX: None},
        list: {FORMAT_CSV: list_to_str, FORMAT_XLSX: list_to_str},
    }
    # A dictionary of column heading overrides in the format {field: heading}
    export_headings = {}

    export_buttons_template_name = "wagtailadmin/shared/export_buttons.html"

    export_filename = "spreadsheet-export"

    def setup(self, request, *args, **kwargs):
        super().setup(request, *args, **kwargs)
        self.is_export = request.GET.get("export") in self.FORMATS

    def get_paginate_by(self, queryset):
        if self.is_export:
            return None
        return super().get_paginate_by(queryset)

    def get_filename(self):
        """Gets the base filename for the exported spreadsheet, without extensions"""
        return self.export_filename

    def to_row_dict(self, item):
        """Returns an OrderedDict (in the order given by list_export) of the exportable information for a model instance"""
        row_dict = OrderedDict(
            (field, multigetattr(item, field)) for field in self.list_export
        )
        return row_dict

    def get_preprocess_function(self, field, value, export_format):
        """Returns the preprocessing function for a given field name, field value, and export format"""

        # Try to find a field specific function and return it
        format_dict = self.custom_field_preprocess.get(field, {})
        if export_format in format_dict:
            return format_dict[export_format]

        # Otherwise check for a value class specific function
        for value_classes, format_dict in self.custom_value_preprocess.items():
            if isinstance(value, value_classes) and export_format in format_dict:
                return format_dict[export_format]

        # Finally resort to force_str to prevent encoding errors
        return partial(force_str, strings_only=True)

    def preprocess_field_value(self, field, value, export_format):
        """Preprocesses a field value before writing it to the spreadsheet"""
        preprocess_function = self.get_preprocess_function(field, value, export_format)
        if preprocess_function is not None:
            return preprocess_function(value)
        else:
            return value

    def generate_xlsx_row(self, worksheet, row_dict, date_format=None):
        """Generate cells to append to the worksheet"""
        for field, value in row_dict.items():
            cell = WriteOnlyCell(
                worksheet, self.preprocess_field_value(field, value, self.FORMAT_XLSX)
            )
            if date_format and isinstance(value, datetime.datetime):
                cell.number_format = date_format
            yield cell

    def write_csv_row(self, writer, row_dict):
        return writer.writerow(
            {
                field: self.preprocess_field_value(field, value, self.FORMAT_CSV)
                for field, value in row_dict.items()
            }
        )

    def get_heading(self, queryset, field):
        """Get the heading label for a given field for a spreadsheet generated from queryset"""
        heading_override = self.export_headings.get(field)
        if heading_override:
            return force_str(heading_override)
        try:
            return capfirst(force_str(label_for_field(field, queryset.model)))
        except (AttributeError, FieldDoesNotExist):
            return force_str(field)

    def stream_csv(self, queryset):
        """Generate a csv file line by line from queryset, to be used in a StreamingHTTPResponse"""
        writer = csv.DictWriter(Echo(), fieldnames=self.list_export)
        yield writer.writerow(
            {field: self.get_heading(queryset, field) for field in self.list_export}
        )

        for item in queryset:
            yield self.write_csv_row(writer, self.to_row_dict(item))

    def write_xlsx(self, queryset, output):
        """Write an xlsx workbook from a queryset"""
        workbook = Workbook(write_only=True, iso_dates=True)

        worksheet = workbook.create_sheet(title="Sheet1")
        worksheet.append(
            self.get_heading(queryset, field) for field in self.list_export
        )

        date_format = ExcelDateFormatter().get()
        for item in queryset:
            worksheet.append(
                self.generate_xlsx_row(
                    worksheet, self.to_row_dict(item), date_format=date_format
                )
            )

        workbook.save(output)

    def write_xlsx_response(self, queryset):
        """Write an xlsx file from a queryset and return a FileResponse"""
        output = BytesIO()
        self.write_xlsx(queryset, output)
        output.seek(0)

        return FileResponse(
            output,
            as_attachment=True,
            content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            filename=f"{self.get_filename()}.xlsx",
        )

    def write_csv_response(self, queryset):
        stream = self.stream_csv(queryset)

        response = StreamingHttpResponse(stream, content_type="text/csv")
        response["Content-Disposition"] = 'attachment; filename="{}.csv"'.format(
            self.get_filename()
        )
        return response

    def as_spreadsheet(self, queryset, spreadsheet_format):
        """Return a response with a spreadsheet representing the exported data from queryset, in the format specified"""
        if spreadsheet_format == self.FORMAT_CSV:
            return self.write_csv_response(queryset)
        elif spreadsheet_format == self.FORMAT_XLSX:
            return self.write_xlsx_response(queryset)

    def get_export_url(self, format):
        params = self.request.GET.copy()
        params["export"] = format
        return self.request.path + "?" + params.urlencode()

    @property
    def xlsx_export_url(self):
        return self.get_export_url("xlsx")

    @property
    def csv_export_url(self):
        return self.get_export_url("csv")

    @cached_property
    def show_export_buttons(self):
        return bool(self.list_export)

    @cached_property
    def header_more_buttons(self):
        buttons = super().header_more_buttons.copy()
        if self.show_export_buttons:
            buttons.append(
                Button(
                    _("Download XLSX"),
                    url=self.xlsx_export_url,
                    icon_name="download",
                    priority=90,
                )
            )
            buttons.append(
                Button(
                    _("Download CSV"),
                    url=self.csv_export_url,
                    icon_name="download",
                    priority=100,
                )
            )

        return buttons
