# -*- coding: utf-8 -*-
"""📦 Analysis Report module.

This module contains functionality for analysis report.
"""

import json
from datetime import datetime
from typing import Dict, List, Optional

from qgis.core import (
    QgsFillSymbol,
    QgsLayout,
    QgsLayoutItemShape,
    QgsLayoutPoint,
    QgsLayoutSize,
    QgsProject,
    QgsRasterLayer,
    QgsSingleSymbolRenderer,
    QgsUnitTypes,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtGui import QColor

from geest.utilities import log_message, resources_path

from .base_report import CHARCOAL, CONTENT_W, CYAN, GREY, HEADER_H, MARGIN, MIST, NAVY, RULE, BaseReport, _flat_fill

# The five score classes used by every result map in this report
# (see resources/qml/analysis.qml): range, name, colour, significance.
SCORE_CLASSES = [
    (
        "0 – 1",
        "Very Low Enablement",
        "#d7191c",
        "Conditions offer little or no support for employment and " "entrepreneurship; major barriers dominate.",
    ),
    (
        "1 – 2",
        "Low Enablement",
        "#fdae61",
        "Support is limited and significant barriers remain across most " "measured factors.",
    ),
    (
        "2 – 3",
        "Moderately Enabling",
        "#ffffbf",
        "Some supportive conditions exist, but their coverage or quality " "is uneven.",
    ),
    (
        "3 – 4",
        "Enabling",
        "#bce1b8",
        "Conditions generally support access to employment and business " "opportunities.",
    ),
    (
        "4 – 5",
        "Highly Enabling",
        "#2c7bb6",
        "Strong, well-developed enabling conditions across the measured " "factors.",
    ),
]


class AnalysisReport(BaseReport):
    """
    A class to generate a PDF report from the analysis results.

    """

    def __init__(self, model_path: str, working_directory: str = None, report_name="GeoE3 Analysis Report"):
        """
        Initialize the report.

        Args:
            model_path: Path to the model JSON file.
            working_directory: Path to the working directory containing the study area GeoPackage.
            report_name: The title to use for the report.
        """
        template_path = resources_path("resources", "qpt", "analysis_summary_report_template.qpt")
        super().__init__(template_path, report_name)

        self.report_name = report_name
        self.model_path = model_path
        self.working_directory = working_directory
        self.temp_layers = []  # Track layers added to project for cleanup
        self.study_area_layer = None  # Will hold the study area outline layer

        # Load the study area outline layer if working_directory is provided
        if working_directory:
            self._load_study_area_layer()

        self.page_descriptions[
            "analysis_summary"
        ] = """
        This shows the relative elapsed time for each analysis step. The time is in minutes.
        """
        self.page_descriptions[
            "legend"
        ] = """
        Every map in this report uses the same five-class colour scheme, applied to
        scores from 0 to 5. This page explains what the colours mean and how the
        values are derived.
        """

    def cleanup(self):
        """
        Explicitly clean up temporary layers. Call this when done with the report,
        or use the context manager pattern.
        """
        if self._cleanup_done:
            return
        # Remove temporary layers added for rendering
        for layer in self.temp_layers:
            if layer:
                try:
                    QgsProject.instance().removeMapLayer(layer.id())
                    log_message(f"Removed temporary layer '{layer.name()}' from project.")
                except Exception as e:
                    log_message(f"Could not remove temporary layer: {e}")
        self.temp_layers = []
        super().cleanup()

    def _load_study_area_layer(self):
        """
        Load the study area outline layer from the GeoPackage.
        """
        import os

        gpkg_path = os.path.join(self.working_directory, "study_area", "study_area.gpkg")
        if not os.path.exists(gpkg_path):
            log_message(f"Study area GeoPackage not found at {gpkg_path}")
            return

        # Try to load study_area_clip_polygons first, fall back to study_area_polygons
        for layer_name in ["study_area_clip_polygons", "study_area_polygons"]:
            uri = f"{gpkg_path}|layername={layer_name}"
            layer = QgsVectorLayer(uri, f"Study Area ({layer_name})", "ogr")
            if layer.isValid():
                self.study_area_layer = layer
                # Outline only: the default study-area style has a solid fill
                # which, drawn over the result raster, blanks out the map
                # ("white interior with a black outline"). The report must
                # show the same styled raster the model tree shows, with just
                # a thin boundary on top.
                symbol = QgsFillSymbol.createSimple(
                    {
                        "color": "0,0,0,0",
                        "outline_color": "51,51,51,255",
                        "outline_width": "0.4",
                        "outline_style": "solid",
                    }
                )
                layer.setRenderer(QgsSingleSymbolRenderer(symbol))
                # Add to project temporarily for rendering
                QgsProject.instance().addMapLayer(layer, False)
                self.temp_layers.append(layer)
                log_message(f"Loaded study area outline from {layer_name}")
                break
            else:
                log_message(f"Could not load study area layer: {layer_name}")

    def create_layout(self):
        """
        Create a QGIS layout (report) that includes a title, summary statistics,
        and individual pages for each indicator.
        """
        project = QgsProject.instance()
        self.title = "Analysis Report"
        self.layout = QgsLayout(project)
        self.layout.initializeDefaults()
        self.load_template()
        self.style_cover_page()

        # Credits sit directly behind the cover so funders and developers
        # get a designed, prominent home.
        self.make_credits_page(current_page=1)

        # Explain the colour scheme before showing any maps.
        self.make_legend_page(current_page=2)

        # Then the analysis overview and the dimension/factor pages.
        current_page = self.create_detail_pages(current_page=3)

        # Processing times live at the back of the report — they are
        # technical bookkeeping, not analysis content.
        self.make_page(
            title="Processing Times",
            description_key="analysis_summary",
            current_page=current_page,
            show_header_and_footer=True,
        )
        self.create_execution_time_layout(
            entries=self.extract_execution_times_with_colors(),
            page=current_page,
        )
        current_page += 1

    def make_legend_page(self, current_page: int) -> None:
        """Add the legend page: the score colour scheme and what it means.

        A segmented 0-5 colour bar mirrors the ramp used on every result
        map (resources/qml/analysis.qml), followed by one row per class
        explaining its significance, and a note on how scores are derived.
        """
        self.make_page(
            title="Reading the Maps",
            description_key="legend",
            current_page=current_page,
            show_header_and_footer=True,
        )
        # Segmented colour bar with 0-5 tick labels.
        bar_y = HEADER_H + 26
        bar_h = 10
        segment_w = CONTENT_W / len(SCORE_CLASSES)
        # Hairline backing keeps the palest class visible against the page.
        self._rect(MARGIN - 0.3, bar_y - 0.3, CONTENT_W + 0.6, bar_h + 0.6, RULE, current_page)
        for i, (_, _, colour, _) in enumerate(SCORE_CLASSES):
            self._rect(MARGIN + i * segment_w, bar_y, segment_w, bar_h, QColor(colour), current_page)
        for i in range(len(SCORE_CLASSES) + 1):
            self._label(
                str(i),
                MARGIN + i * segment_w - 10,
                bar_y + bar_h + 1.5,
                20,
                5,
                current_page,
                size=8.5,
                color=GREY,
                halign=Qt.AlignmentFlag.AlignHCenter,
            )

        # One row per class: swatch, range and name, significance.
        row_top = bar_y + bar_h + 14
        row_pitch = 23
        for i, (score_range, name, colour, meaning) in enumerate(SCORE_CLASSES):
            y = row_top + i * row_pitch
            self._rect(MARGIN - 0.3, y - 0.3, 22.6, 14.6, RULE, current_page)
            self._rect(MARGIN, y, 22, 14, QColor(colour), current_page)
            self._label(
                f"{score_range}   {name}",
                MARGIN + 28,
                y,
                CONTENT_W - 28,
                7,
                current_page,
                size=11.5,
                color=NAVY,
                bold=True,
            )
            self._label(
                meaning,
                MARGIN + 28,
                y + 7,
                CONTENT_W - 28,
                7,
                current_page,
                size=9.5,
                color=GREY,
            )

        # How the values are derived.
        note_y = row_top + len(SCORE_CLASSES) * row_pitch + 6
        note_h = 42
        self._rect(MARGIN, note_y, CONTENT_W, note_h, MIST, current_page)
        self._rect(MARGIN, note_y, 2.2, note_h, CYAN, current_page)
        self._label(
            "How the scores are calculated",
            MARGIN + 8,
            note_y + 4.5,
            CONTENT_W - 16,
            7,
            current_page,
            size=12,
            color=NAVY,
            bold=True,
        )
        note_body = self._label(
            "<p>Each indicator scores every analysis grid cell from 0 to 5. Indicator "
            "scores combine into factor scores, factor scores into dimension scores, "
            "and dimension scores into the overall GeoE3 score, using the weights "
            "configured in the model. Blank cells fall outside the study area or have "
            "no data; a score of 0 can also mean the measured service is out of reach "
            "— for example, locations with no road-network access.</p>",
            MARGIN + 8,
            note_y + 13,
            CONTENT_W - 16,
            note_h - 17,
            current_page,
            size=9.5,
            color=CHARCOAL,
            html=True,
        )
        note_body.setHAlign(Qt.AlignmentFlag.AlignJustify)

    def _load_raster(self, layer_uri: str, title: str) -> Optional[QgsRasterLayer]:
        """Load (and cache) a raster result layer, or None when unavailable.

        Validity is checked BEFORE any page is created, so an unreadable
        result can never produce a blank page.
        """
        if not layer_uri:
            return None
        if not hasattr(self, "_raster_cache"):
            self._raster_cache = {}
        if layer_uri in self._raster_cache:
            return self._raster_cache[layer_uri]
        layer = QgsRasterLayer(layer_uri, title)
        if not layer.isValid():
            log_message(f"Layer {layer_uri} is invalid — omitted from report.", tag="GeoE3")
            self._raster_cache[layer_uri] = None
            return None
        QgsProject.instance().addMapLayer(layer, False)
        self.temp_layers.append(layer)
        self._raster_cache[layer_uri] = layer
        return layer

    def _map_layers(self, layer: QgsRasterLayer) -> list:
        """Raster plus the study area outline when available."""
        layers = [layer]
        if self.study_area_layer:
            layers.append(self.study_area_layer)
        return layers

    def _add_map_page(
        self,
        title: str,
        description_key: str,
        layer_uri: str,
        current_page: int,
        minimaps: Optional[list] = None,
    ) -> bool:
        """Add a page with a large map and an optional grid of minimaps.

        Args:
            title: The title for the page.
            description_key: Key for the page description in self.page_descriptions.
            layer_uri: Path to the raster layer file.
            current_page: The current page number.
            minimaps: Optional list of (caption, QgsRasterLayer) tuples laid
                out as a grid beneath the main map.

        Returns:
            bool: True if the page was added, False otherwise.
        """
        layer = self._load_raster(layer_uri, title)
        if layer is None:
            log_message(f"No usable layer for '{title}', skipping page", tag="GeoE3")
            return False

        self.make_page(
            title=title,
            description_key=description_key,
            current_page=current_page,
            show_header_and_footer=True,
        )

        minimaps = [m for m in (minimaps or []) if m[1] is not None]
        main_h = 105.0 if minimaps else 200.0
        self.make_map(
            layers=self._map_layers(layer),
            current_page=current_page,
            crs=layer.crs(),
            x=15,
            y=52,
            map_width_mm=180,
            map_height_mm=main_h,
        )
        if minimaps:
            # 12 mm clearance leaves room for the main map's outside-frame
            # coordinate annotations.
            self._add_minimap_grid(minimaps, current_page, top=52 + main_h + 12)
        return True

    def _add_minimap_grid(
        self,
        minimaps: list,
        current_page: int,
        top: float,
        columns: int = 4,
        bottom: float = 280.0,
    ) -> None:
        """Lay out (caption, layer) tuples as a captioned minimap grid."""
        gap = 5.0
        cell_w = (180.0 - (columns - 1) * gap) / columns
        cell_h = cell_w + 7  # square map face + caption strip
        max_rows = max(1, int((bottom - top + gap) // (cell_h + gap)))
        capacity = max_rows * columns
        if len(minimaps) > capacity:
            log_message(
                f"Minimap grid clipped to {capacity} of {len(minimaps)} entries on page {current_page}",
                tag="GeoE3",
            )
            minimaps = minimaps[:capacity]
        for index, (caption, layer) in enumerate(minimaps):
            row, col = divmod(index, columns)
            self.make_minimap(
                caption=caption,
                layers=self._map_layers(layer),
                crs=layer.crs(),
                current_page=current_page,
                x=15 + col * (cell_w + gap),
                y=top + row * (cell_h + gap),
                w=cell_w,
                h=cell_h,
            )

    def _has_used_indicators(self, factor: dict) -> bool:
        """Check if a factor has any indicators that are not 'Do Not Use'.

        Args:
            factor: Factor dictionary from the model.

        Returns:
            bool: True if at least one indicator is used.
        """
        for indicator in factor.get("indicators", []):
            if indicator.get("analysis_mode", "") != "Do Not Use":
                return True
        return False

    def _has_used_factors(self, dimension: dict) -> bool:
        """Check if a dimension has any factors with used indicators.

        Args:
            dimension: Dimension dictionary from the model.

        Returns:
            bool: True if at least one factor has used indicators.
        """
        for factor in dimension.get("factors", []):
            if self._has_used_indicators(factor):
                return True
        return False

    def create_detail_pages(self, current_page: int = 1):
        """Iterate over dimensions, factors, and indicators to create detail pages.

        Args:
            current_page: The current page number to start from. Incremented for each new page.
        """

        with open(self.model_path, "r", encoding="utf-8") as f:
            model = json.load(f)

        # --- Analysis overview: the main GeoE3 score map on top, then the
        # dimension aggregates as minimaps, then any additional analysis
        # products (population/mask variants) as further minimaps.
        self.page_descriptions["analysis_overview"] = (
            "The overall GeoE3 score, with the aggregated score for each "
            "dimension and any derived analysis products shown below."
        )
        main_uri = model.get("geoe3_score_ghsl_masked_result_file") or model.get("result_file")
        product_keys = [
            ("result_file", "GeoE3 score"),
            ("geoe3_score_ghsl_masked_result_file", "GHSL masked"),
            ("geoe3_by_population_result_file", "By population"),
            ("geoe3_score_by_population_ghsl_masked_result_file", "By population (GHSL)"),
            ("opportunities_mask_result_file", "Opportunities mask"),
            ("geoe3_by_opportunities_mask_result_file", "Score × opportunities"),
            ("geoe3_by_population_by_opportunities_mask_result_file", "Population × opportunities"),
        ]
        dimension_minimaps = [
            (dimension.get("name", ""), self._load_raster(dimension.get("result_file"), dimension.get("name", "")))
            for dimension in model.get("dimensions", [])
            if self._has_used_factors(dimension)
        ]
        product_minimaps = [
            (label, self._load_raster(model.get(key), label))
            for key, label in product_keys
            if model.get(key) and model.get(key) != main_uri
        ]
        if self._add_map_page(
            "Analysis Overview",
            "analysis_overview",
            main_uri,
            current_page,
            minimaps=dimension_minimaps + product_minimaps,
        ):
            current_page += 1

        # --- One page per dimension: its map + a grid of factor minimaps
        for dimension in model.get("dimensions", []):
            dim_name = dimension.get("name", "")

            if not self._has_used_factors(dimension):
                log_message(f"Skipping dimension '{dim_name}' - no used indicators", tag="GeoE3")
                continue

            used_factors = [factor for factor in dimension.get("factors", []) if self._has_used_indicators(factor)]

            self.page_descriptions[dim_name] = dimension.get(
                "description", f"Aggregated analysis for dimension: {dim_name}"
            )
            # A dimension with a single factor aggregates to the same surface
            # as that factor — a one-cell minimap grid would just repeat the
            # main map, so skip it.
            factor_minimaps = (
                [
                    (factor.get("name", ""), self._load_raster(factor.get("result_file"), factor.get("name", "")))
                    for factor in used_factors
                ]
                if len(used_factors) > 1
                else []
            )
            if self._add_map_page(
                f"{dim_name} Dimension",
                dim_name,
                dimension.get("result_file"),
                current_page,
                minimaps=factor_minimaps,
            ):
                current_page += 1

            # --- One page per factor: its map + a grid of indicator minimaps
            for factor in used_factors:
                factor_name = factor.get("name", "")
                self.page_descriptions[factor_name] = factor.get(
                    "description", f"Aggregated analysis for factor: {factor_name}"
                )
                used_indicators = [
                    indicator
                    for indicator in factor.get("indicators", [])
                    if indicator.get("analysis_mode", "") != "Do Not Use"
                ]
                # A factor with a single indicator produces the same surface
                # as that indicator — skip the redundant one-cell grid.
                indicator_minimaps = (
                    [
                        (
                            indicator.get("indicator", ""),
                            self._load_raster(indicator.get("result_file"), indicator.get("indicator", "")),
                        )
                        for indicator in used_indicators
                    ]
                    if len(used_indicators) > 1
                    else []
                )
                if self._add_map_page(
                    f"{factor_name}",
                    factor_name,
                    factor.get("result_file"),
                    current_page,
                    minimaps=indicator_minimaps,
                ):
                    current_page += 1

        return current_page

    def parse_iso_datetime(self, iso_str: str) -> Optional[datetime]:
        """Parse ISO 8601 datetime string safely.

        Args:
            iso_str: ISO 8601 formatted datetime string.

        Returns:
            datetime: Parsed datetime object, or None if parsing fails.
        """
        try:
            return datetime.fromisoformat(iso_str)
        except Exception:
            return None

    def interpolate_color(self, rel: float) -> str:
        """Linearly interpolate between forest green and off-red.

        rel = 0.0 => forest green (#228B22), rel = 1.0 => off-red (#CC4444)

        Args:
            rel: Relative value from 0.0 to 1.0.

        Returns:
            str: Hex color string (e.g., "#228B22").
        """
        fg = (34, 139, 34)  # Forest Green
        or_ = (204, 68, 68)  # Off Red
        r = int(fg[0] + (or_[0] - fg[0]) * rel)
        g = int(fg[1] + (or_[1] - fg[1]) * rel)
        b = int(fg[2] + (or_[2] - fg[2]) * rel)
        return f"#{r:02x}{g:02x}{b:02x}"  # noqa E231

    def extract_execution_times_with_colors(self) -> List[Dict[str, Optional[str]]]:
        """Extract execution times for indicators with relative time and color gradient.

        Returns:
            List[Dict]: A sorted list of dictionaries with indicator, factor, dimension,
                execution_time_minutes, relative_time (0.0-1.0), and color (hex string).
        """

        with open(self.model_path, "r", encoding="utf-8") as f:
            model = json.load(f)

        results = []

        for dimension in model.get("dimensions", []):
            dim_name = dimension.get("name", "")
            for factor in dimension.get("factors", []):
                factor_name = factor.get("name", "")
                for indicator in factor.get("indicators", []):
                    # Skip indicators that are not used
                    analysis_mode = indicator.get("analysis_mode", "")
                    if analysis_mode == "Do Not Use":
                        continue

                    ind_name = indicator.get("indicator", "")
                    start_str = indicator.get("execution_start_time", "")
                    end_str = indicator.get("execution_end_time", "")

                    start_dt = self.parse_iso_datetime(start_str)
                    end_dt = self.parse_iso_datetime(end_str)

                    if start_dt and end_dt:
                        duration = round((end_dt - start_dt).total_seconds() / 60, 2)
                    else:
                        duration = None

                    results.append(
                        {
                            "indicator": ind_name,
                            "factor": factor_name,
                            "dimension": dim_name,
                            "execution_time_minutes": duration,
                        }
                    )

        # Compute relative times
        valid_times = [r["execution_time_minutes"] for r in results if r["execution_time_minutes"] is not None]

        if valid_times:
            min_time = min(valid_times)
            max_time = max(valid_times)
            range_time = max_time - min_time if max_time > min_time else 1.0  # avoid division by zero

            for r in results:
                exec_time = r["execution_time_minutes"]
                if exec_time is not None:
                    rel = (exec_time - min_time) / range_time
                    r["relative_time"] = round(rel, 2)
                    r["color"] = self.interpolate_color(rel)
                else:
                    r["relative_time"] = None
                    r["color"] = None

        # Sort by execution time descending, placing None last
        results.sort(
            key=lambda r: (
                r["execution_time_minutes"] is None,
                -(r["execution_time_minutes"] or 0),
            )
        )

        return results

    def create_execution_time_layout(self, entries: list, max_bar_width_mm: float = 78.0, page: int = 1):
        """Add a clean horizontal bar chart of indicator execution times.

        Layout per row: indicator name right-aligned in a left column, a
        length-encoded bar, and the duration at the bar's end. Rows carry a
        subtle alternating tint. Indicators that were not run render as a
        greyed label with no bar.

        Args:
            entries: Output from `extract_execution_times_with_colors()`.
            max_bar_width_mm: Bar width for the slowest indicator, in mm.
            page: Page number to draw the chart on.
        """
        top = 44.0
        row_pitch = 7.0
        bar_h = 3.6
        label_x = 15.0
        label_w = 72.0
        bar_x = label_x + label_w + 3.0
        max_rows = 32

        clipped = len(entries) > max_rows
        for i, entry in enumerate(entries[:max_rows]):
            y = top + i * row_pitch
            indicator = entry["indicator"]
            duration = entry["execution_time_minutes"]

            if i % 2 == 0:
                self._rect(label_x - 1, y - 1.4, 181.0, row_pitch - 0.6, MIST, page)

            name = indicator if len(indicator) <= 52 else indicator[:49] + "…"
            self._label(
                name,
                label_x,
                y - 0.6,
                label_w,
                row_pitch,
                page,
                size=8,
                color=CHARCOAL if duration is not None else GREY,
                halign=Qt.AlignmentFlag.AlignRight,
            )

            if duration is None:
                self._label("not run", bar_x, y - 0.6, 30, row_pitch, page, size=8, color=GREY)
                continue

            bar_width = max(0.6, max_bar_width_mm * (entry.get("relative_time") or 0.0))
            bar = QgsLayoutItemShape(self.layout)
            bar.attemptMove(QgsLayoutPoint(bar_x, y, QgsUnitTypes.LayoutUnit.LayoutMillimeters), page=page)
            bar.setFixedSize(QgsLayoutSize(bar_width, bar_h, QgsUnitTypes.LayoutUnit.LayoutMillimeters))
            _flat_fill(bar, CYAN)
            self.layout.addLayoutItem(bar)

            self._label(
                f"{duration:g} min",
                bar_x + bar_width + 2,
                y - 0.6,
                28,
                row_pitch,
                page,
                size=8,
                color=GREY,
            )
        if clipped:
            self._label(
                f"Showing the {max_rows} slowest steps of {len(entries)}.",
                label_x,
                top + max_rows * row_pitch + 2,
                180,
                6,
                page,
                size=8,
                color=GREY,
            )
