#!/usr/bin/env python3
import traceback
from datetime import datetime, timezone
import os

import xlsxwriter
from xlsxwriter.utility import xl_col_to_name

from .base import ReportGenerator


class XlsxReportGenerator(ReportGenerator):
    """
    Report generator that produces an Excel report for a single agent.
    The report consists of two sheets:
      - A "Data" sheet that contains a header block and the raw data.
      - A "Summary" sheet that has the same header block and a chart that references the Data sheet.

    The class is structured so that each section is implemented in a separate method,
    making it easier for subclasses to override or extend parts of the report.
    """

    include_summary_sheet = True

    cursor_cache_values = True

    logo_filename = "logo.png"
    logo_cell = "B2"
    header_logo_x_offset = 1
    header_logo_y_offset = 5

    chart_scale = {"x_scale": 2, "y_scale": 2}
    include_x_axis = False
    series_kwargs = {}  ## Styling applied to all series
    filtered_series_kwargs = {}  ## Styling applied to specific series
    summary_chart_height = 30
    exclude_chart_series = []

    legend = {"position": "bottom"}
    y1_axis = {"major_gridlines": {"visible": False}}
    y2_axis = {}
    y2_axis_series = []

    def generate_one(self, agent_id: str) -> None:
        """Orchestrates the creation of the Data and Summary sheets for one agent."""
        agent_display_name = self.get_agent_display_name(agent_id=agent_id)
        self.add_to_log(f"Generating report for {agent_display_name}...")

        series, msgs = self.retrieve_report_data(agent_id)
        self.parse_data_series(series, msgs)

        self.workbook = self.open_workbook(workbook_name=agent_display_name)

        ## Create the summary sheet first to put it first, then put aside for later
        if self.include_summary_sheet:
            summary_sheet = self.create_sheet("Summary")

        # Create and fill the Data sheet.
        data_sheet = self.create_sheet("Data")
        data_range = self.write_data_sheet(data_sheet)

        # Fill the Summary sheet.
        if self.include_summary_sheet:
            self.write_summary_sheet(summary_sheet, data_range)

        self.close_workbook()

    # -- Setup and Data Retrieval --

    def retrieve_report_data(self, agent_id: str):
        """Retrieve the available series and data messages."""
        self.add_to_log("Finding available dataseries...")
        agg = self.get_current_data_aggregate(agent_id)
        series = self.get_available_dataseries(agg)
        self.add_to_log(f"Available series: {series}")
        self.update_progress(0.05)

        self.add_to_log("Retrieving data...")
        msgs = self.retrieve_data(
            period_from=self.localized_period_from,
            period_to=self.localized_period_to,
            agent_id=agent_id,
        )
        self.add_to_log(f"Finished retrieving {len(msgs)} messages")
        self.update_progress(0.20)
        return series, msgs

    # -- Workbook and Common Header --

    def open_workbook(self, workbook_name=None):
        """Create and return a new workbook."""
        workbook_name = self.get_ws_path(f"{workbook_name}.xlsx")
        wb = xlsxwriter.Workbook(workbook_name)
        self.wb_format_title = wb.add_format({"bold": True, "font_size": 14})
        self.wb_format_subtitle = wb.add_format({"italic": True, "font_size": 8})
        self.wb_format_bold = wb.add_format({"bold": True})
        self.wb_format_datetime_format = wb.add_format(
            {"num_format": "dd/mm/yyyy hh:mm:ss"}
        )
        self.wb_format_date_format = wb.add_format({"num_format": "dd/mm/yy"})
        self.wb_format_short_date_format = wb.add_format({"num_format": "dd/mm"})
        return wb

    def close_workbook(self):
        """Close the workbook."""
        self.workbook.close()

    def write_header_block(self, sheet) -> tuple[int, int]:
        """
        Write the common header block into the given sheet.
        Returns a tuple: (next_row, index_col) where next_row is the first row after the header.
        """
        index_col = 1
        next_row = 1

        if self.is_logo_available():
            index_col = self.header_logo_x_offset
            sheet.insert_image(
                self.logo_cell, self.logo_filename, {"x_scale": 0.5, "y_scale": 0.5}
            )
            next_row = self.header_logo_y_offset

        sheet.write(next_row, index_col, self.report_name, self.get_title_format())
        next_row += 1
        sheet.write(
            next_row, index_col, "Auto-generated by Doover", self.get_subtitle_format()
        )
        next_row += 2

        # Write time period details.
        sheet.write(next_row, index_col, "Start : ", self.get_bold_format())
        sheet.write(
            next_row,
            index_col + 2,
            self.localized_period_from.strftime("%d/%m/%y %H:%M:%S"),
        )
        next_row += 1
        sheet.write(next_row, index_col, "End : ", self.get_bold_format())
        sheet.write(
            next_row,
            index_col + 2,
            self.localized_period_to.strftime("%d/%m/%y %H:%M:%S"),
        )

        next_row += 2
        # sheet.set_column(index_col, index_col, 20)
        sheet.write(next_row, index_col, "Timezone : ", self.get_bold_format())
        sheet.write(next_row, index_col + 2, str(self.for_timezone))

        next_row += 3

        return next_row, index_col

    def is_logo_available(self):
        """Check if the logo file is available."""
        if self.logo_filename is None:
            return False
        if os.path.exists(self.logo_filename):
            return True
        if self.logging_function:
            self.logging_function("Logo file not accessible")
        return False

    def create_sheet(self, sheet_name="Data"):
        """Create the 'Data' sheet"""
        sheet = self.workbook.add_worksheet(sheet_name)
        return sheet

    # -- Data Sheet Methods --

    def process_message(self, m, series, value_cache):
        """
        Process a single message `m` and return a tuple:
        (temp_values, local_dt_str)

        - temp_values: List of computed values for each series. if temp_values[i] is None, the value is skipped.
        - local_dt_str: The formatted timestamp string.

        Subclasses can override this method to change how individual messages are processed.
        """
        temp_values = {}

        for i, s in enumerate(series):
            try:
                temp_obj = self.get_data_object(m["payload"], s["name"])
                value, raw_value = None, None
                if (
                    temp_obj
                    and "currentValue" in temp_obj
                    and temp_obj["currentValue"] is not None
                ):
                    current_val = temp_obj["currentValue"]
                    if isinstance(current_val, float):
                        current_val = round(current_val, 3)
                    value = current_val
                    raw_value = current_val
                elif self.cursor_cache_values and s["name"] in value_cache:
                    value = value_cache[s["name"]]

                if value is not None:
                    # Example special condition.
                    if s["name"] == "lastPacket" and raw_value in [
                        "Flow",
                        "Calibration",
                    ]:
                        return {}
                    temp_values[s["name"]] = value
                    value_cache[s["name"]] = value
            except Exception:
                if self.test_mode:
                    self.add_to_log(
                        f"Error getting datapoint {s['name']} : {traceback.format_exc()}"
                    )

        return temp_values

    def parse_data_series(self, series, msgs):
        """
        Process the incoming messages (msgs) and build the self.data_series list.
        Each element in self.data_series is a dictionary with keys:
        - "name": the series' internal name.
        - "display_name": the series' display string.
        - "timestamp_strs": list of X values (as formatted strings).
        - "timestamps": list of X values (as datetime objects).
        - "values": list of Y values.
        """
        value_cache = {}
        # Initialize self.data_series for each series.
        self.data_series = []
        for s in series:
            self.data_series.append(
                {
                    "name": s["name"],
                    "display_name": s["displayString"],
                    "timestamp_strs": [],
                    "timestamps": [],
                    "values": [],
                }
            )

        # Process each message using the helper function.
        for m in msgs:
            temp_values = self.process_message(m, series, value_cache)
            local_dt = datetime.fromtimestamp(m["timestamp"], timezone.utc).astimezone()
            local_dt_str = local_dt.strftime("%d/%m/%y %H:%M:%S")

            if len(temp_values.keys()) < 1:
                continue

            # Append the timestamp and values to each series.
            for i, ds in enumerate(self.data_series):
                if ds["name"] in temp_values:
                    ds["timestamp_strs"].append(local_dt_str)
                    ds["timestamps"].append(local_dt)
                    ds["values"].append(temp_values[ds["name"]])

    def get_data_series(self, series_name):
        """Return the data series with the given name."""
        for s in self.data_series:
            if s["name"] == series_name:
                return s
        return None

    def get_data_start_ts(self):
        """Return the start timestamp of the data series."""
        if not self.data_series:
            return None
        return self.data_series[0]["timestamp_strs"][0]

    def get_data_end_ts(self):
        """Return the end timestamp of the data series."""
        if not self.data_series:
            return None
        return self.data_series[0]["timestamp_strs"][-1]

    def write_data_sheet(self, sheet):
        """
        Write the data header and data rows into the 'Data' sheet.
        Assumes that self.data_series has already been populated by parse_data_series.
        Returns a dictionary containing data range information used for charting.
        """
        header_row, _ = self.write_header_block(sheet)

        index_col = 0
        # Write column headers: first the "Time" column, then each series display name.
        sheet.write(header_row, index_col, "Time", self.get_bold_format())
        col_counter = 1
        for s in self.data_series:
            sheet.write(
                header_row,
                index_col + col_counter,
                s["display_name"],
                self.get_bold_format(),
            )
            col_counter += 1

        data_start_row = header_row + 2
        # Determine the number of rows (assuming all series share the same timestamps).
        num_rows = (
            len(self.data_series[0]["timestamp_strs"])
            if self.data_series and self.data_series[0]["timestamp_strs"]
            else 0
        )

        # Write each row of data.
        for i in range(num_rows):
            # Write the timestamp from the first series.
            sheet.write(
                data_start_row + i,
                index_col,
                self.data_series[0]["timestamp_strs"][i],
                self.get_datetime_format(),
            )
            # Write each series' value.
            for j in range(len(self.data_series)):
                value = self.data_series[j]["values"][i]
                if value is not None:
                    sheet.write(data_start_row + i, index_col + j + 1, value)

        self.add_to_log("Finished composing Data sheet")
        self.update_progress(0.80)

        # Return data range information for chart creation.
        data_range = {
            "header_row": header_row,
            "data_start_row": data_start_row,
            "data_end_row": data_start_row + num_rows - 1,
            "data_columns": 1
            + len(self.data_series),  # Column 0 for Time plus one per series.
            "series_names": [s["name"] for s in self.data_series],
        }
        return data_range

    # -- Summary Sheet Methods --

    def write_summary_sheet(self, sheet, data_range):
        """
        Create a chart in the Summary sheet that references the data in the Data sheet.
        The chart will use the data range information from the Data sheet.
        """
        header_row, _ = self.write_header_block(sheet)

        chart = self.get_summary_chart(data_range)

        # Insert the chart into the Summary sheet at a designated position.
        sheet.insert_chart(f"B{header_row + 1}", chart, self.chart_scale)
        self.add_to_log("Inserted chart into Summary sheet")

        self.write_extra_summary(
            sheet, data_range, next_row=header_row + self.summary_chart_height
        )

    def get_summary_chart(self, data_range):
        chart = self.workbook.add_chart({"type": "line"})
        series = self.get_summary_chart_series(data_range)
        for s in series:
            y2_axis_enable = s["variableName"] in self.y2_axis_series
            chart.add_series({**s, "y2_axis": y2_axis_enable})
        # chart.set_title({'name': 'Summary Chart'})
        if self.include_x_axis:
            chart.set_x_axis(
                {
                    "name": "Time",
                    "date_axis": True,
                    # 'num_format': 'dd/mm/yyyy hh:mm:ss'
                    "num_format": "dd/mm",
                }
            )
        else:
            chart.set_x_axis(
                {
                    "line": {"none": True},
                    "label_position": "none",
                    "major_tick_mark": "none",
                    "minor_tick_mark": "none",
                }
            )

        chart.set_y_axis(self.y1_axis)
        if self.y2_axis:
            chart.set_y2_axis(self.y2_axis)

        chart.set_legend(self.legend)

        return chart

    def get_exclude_chart_series(self):
        if hasattr(self, "exclude_chart_series") and self.exclude_chart_series:
            return self.exclude_chart_series
        return []

    def get_summary_chart_series(self, data_range):
        # Chart series for each data column (skip column 0 which is Time).
        series = []
        for col in range(1, data_range["data_columns"]):
            series_name = data_range["series_names"][col - 1]
            if series_name in self.get_exclude_chart_series():
                continue

            # Create the series name from the header row.
            series_name_ref = self.get_series_name_reference(
                "Data", col, data_range["header_row"]
            )

            # Construct the cell range for the categories (x-axis) and values (y-axis).
            categories = self.get_data_sheet_ts_reference(data_range)
            values = self.get_data_sheet_range_reference(data_range, col)

            filtered_kwargs = self.filtered_series_kwargs.get(series_name, {})

            series.append(
                {
                    "variableName": series_name,
                    "name": series_name_ref,
                    "categories": categories,
                    "values": values,
                    **self.series_kwargs,
                    **filtered_kwargs,
                }
            )
        return series

    def write_extra_summary(self, sheet, data_range, next_row):
        """
        Write additional information into the Summary sheet.
        This method can be overridden by subclasses to add more content.
        """
        pass

    # -- Format Helper Methods --

    def get_title_format(self):
        return self.wb_format_title

    def get_subtitle_format(self):
        return self.wb_format_subtitle

    def get_bold_format(self):
        return self.wb_format_bold

    def get_datetime_format(self):
        return self.wb_format_datetime_format

    def get_date_format(self):
        return self.wb_format_date_format

    def get_short_date_format(self):
        return self.wb_format_short_date_format

    def xcol_to_name(self, col):
        return xl_col_to_name(col)

    def to_excel_date(dt):
        # Excel's date system for Windows starts on 1899-12-30.
        excel_start = datetime.datetime(1899, 12, 30)
        delta = dt - excel_start
        return delta.days + (delta.seconds / 86400.0)

    def get_data_sheet_range_reference(self, data_range, col):
        # Get the chart row indices (Excel rows are 1-indexed).
        cat_start, cat_end = self.get_chart_row_indices(data_range)
        # Construct the cell range for the categories (x-axis) and values (y-axis).
        ref = self.get_range_reference("Data", col, cat_start, cat_end)
        return ref

    def get_data_sheet_ts_reference(self, data_range):
        return self.get_data_sheet_range_reference(data_range, 0)

    def get_chart_row_indices(self, data_range):
        """
        Returns the starting and ending row indices for the chart.
        Excel rows are 1-indexed.
        """
        cat_start = data_range["data_start_row"] + 1
        cat_end = data_range["data_end_row"] + 1
        return cat_start, cat_end

    def get_series_name_reference(self, sheet, col, header_row):
        """
        Returns the series name as a cell reference from the header row of the given sheet.
        """
        return f"='{sheet}'!${self.xcol_to_name(col)}{header_row + 1}"

    def get_range_reference(self, sheet, col, row_start, row_end):
        """
        Returns a cell range reference for the given column between row_start and row_end on the specified sheet.
        """
        return f"='{sheet}'!${self.xcol_to_name(col)}{row_start}:${self.xcol_to_name(col)}{row_end}"


generator = XlsxReportGenerator
