# -*- coding: utf-8 -*-
"""Explainable Boosting Machine (EBM) Regression Processing Algorithm."""
from __future__ import annotations

import html
import logging
import os
import tempfile

from ._mixins import HelpUrlMixin
from qgis.core import (
    QgsFeature,
    QgsFeatureSink,
    QgsField,
    QgsProcessing,
    QgsProcessingAlgorithm,
    QgsProcessingException,
    QgsProcessingOutputHtml,
    QgsProcessingParameterFeatureSink,
    QgsProcessingParameterFeatureSource,
    QgsProcessingParameterField,
    QgsProcessingParameterFileDestination,
)
from qgis.PyQt.QtCore import QVariant

from ..core.layer_metadata import apply_output_metadata
from ..core.symbology import apply_renderer, diverging_residual_renderer
from ..core.ml_engines import extract_regression_matrix, fit_ebm_regressor
from ..core.reporting import analyst_guidance_css, analyst_guidance_html
from ..core.charts import chart_css, scatter_plot_svg
from ..dependencies import optional_dependency_error

from ._icons import algorithm_icon

logger = logging.getLogger("PlanX GeoStats Lab")


class EBMRegressionAlgorithm(HelpUrlMixin, QgsProcessingAlgorithm):
    INPUT = "INPUT"
    TARGET = "TARGET"
    FEATURES = "FEATURES"
    OUTPUT = "OUTPUT"
    HTML_REPORT = "HTML_REPORT"

    def __init__(self):
        super().__init__()
        self.out_layer_id = None

    def name(self) -> str:
        return "ebm_regression"

    def displayName(self) -> str:
        return "Explainable Boosting Machine Regression (EBM)"

    def group(self) -> str:
        return "06 | Machine Learning and Explainable AI"

    def groupId(self) -> str:
        return "planx_ml_xai"

    def icon(self):
        return algorithm_icon("ebm_regression")

    def createInstance(self):
        return EBMRegressionAlgorithm()

    def shortHelpString(self) -> str:
        return (
            "Fits an Explainable Boosting Machine (EBM) regressor - a glass-"
            "box generalized additive model (Lou, Caruana and Gehrke, KDD "
            "2012; Microsoft's actively developed InterpretML implementation) "
            "trained by cyclic gradient boosting: one shallow tree per field "
            "per boosting round, round-robin across all fields, so the final "
            "model is an exact sum of per-field shape functions rather than "
            "an opaque ensemble like Random Forest or a Gradient Boosting "
            "engine.\n\n"
            "The defining difference from every other model in this group: "
            "EBM's per-field contribution for every prediction can be read "
            "directly off the fitted model, exactly, with no post-hoc "
            "sampling approximation. SHAP explains what a black-box model "
            "probably did; an EBM's contributions are what the model "
            "actually did. This tool exports that exact breakdown as one "
            "ebm_<field> column per explanatory field, for every record, in "
            "the same output layer as the prediction - no separate SHAP run "
            "required.\n\n"
            "Pairwise interaction terms are disabled here so every model "
            "term maps 1:1 onto an explanatory field, keeping that per-"
            "field export unambiguous; this trades a small amount of the "
            "accuracy EBM's default auto-selected interactions would add "
            "for a completely legible output. In independent benchmarking, "
            "EBM has been competitive with Gradient Boosting/Random Forest "
            "accuracy on many tabular problems despite being fully "
            "additive and inspectable.\n\n"
            "Output: ebm_pred, ebm_resid, ebm_intercept (the model's base "
            "value; roughly constant across records), and one ebm_<field> "
            "exact contribution column per explanatory field - "
            "ebm_intercept plus the sum of the ebm_<field> columns "
            "reconstructs ebm_pred. Map any ebm_<field> column directly to "
            "see exactly where that field pushed predictions up or down "
            "across the study area, the same way SHAP Spatial Attribution "
            "Map is used for black-box models, but exact rather than "
            "approximated.\n\n"
            "Requires the optional interpret package (Setup and Diagnostics "
            "> Install / Update GeoStats Libraries)."
        )

    def initAlgorithm(self, config=None):
        self.addParameter(
            QgsProcessingParameterFeatureSource(self.INPUT, "Input vector layer", [QgsProcessing.TypeVectorAnyGeometry])
        )
        self.addParameter(
            QgsProcessingParameterField(
                self.TARGET, "Target field (numeric)", parentLayerParameterName=self.INPUT,
                type=QgsProcessingParameterField.Numeric,
            )
        )
        self.addParameter(
            QgsProcessingParameterField(
                self.FEATURES, "Explanatory fields (numeric)", parentLayerParameterName=self.INPUT,
                type=QgsProcessingParameterField.Numeric, allowMultiple=True,
            )
        )
        self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Output EBM predictions layer"))
        self.addParameter(
            QgsProcessingParameterFileDestination(
                self.HTML_REPORT, "Output EBM HTML report", fileFilter="HTML files (*.html)", optional=True,
            )
        )
        self.addOutput(QgsProcessingOutputHtml("HTML_REPORT_OUT", "EBM regression report"))

    def processAlgorithm(self, parameters, context, feedback):
        source = self.parameterAsSource(parameters, self.INPUT, context)
        if source is None:
            raise QgsProcessingException("Invalid input layer source.")

        target_field = self.parameterAsString(parameters, self.TARGET, context)
        feature_fields = self.parameterAsFields(parameters, self.FEATURES, context)
        if not feature_fields:
            raise QgsProcessingException("At least one explanatory field must be selected.")

        html_path = self.parameterAsFileOutput(parameters, self.HTML_REPORT, context)
        if not html_path:
            html_path = os.path.join(tempfile.gettempdir(), "planx_ebm_regression_report.html")

        feedback.pushInfo("Extracting complete numeric records...")
        try:
            extraction = extract_regression_matrix(source, feature_fields, target_field, feedback, (0, 30))
        except ValueError as exc:
            raise QgsProcessingException(str(exc))

        x, y, valid_fids, skipped = extraction["x"], extraction["y"], extraction["valid_fids"], extraction["skipped"]
        if len(y) <= len(feature_fields) + 1:
            raise QgsProcessingException(
                f"Insufficient complete records ({len(y)}) for {len(feature_fields)} explanatory field(s)."
            )
        if skipped:
            feedback.pushInfo(f"Skipped {skipped} feature(s) with missing or non-numeric values.")

        feedback.pushInfo("Fitting Explainable Boosting Machine (interactions disabled)...")
        try:
            results = fit_ebm_regressor(x, y, feature_fields)
        except ImportError as exc:
            raise QgsProcessingException(optional_dependency_error("Explainable Boosting Machine Regression", ["interpret"], exc))

        feedback.pushInfo(f"R2={results['r2']:.4f}, RMSE={results['rmse']:.4f}, MAE={results['mae']:.4f}")

        contrib_fields = [f"ebm_{name}" for name in feature_fields]
        out_fields = source.fields()
        out_fields.append(QgsField("ebm_pred", QVariant.Double, len=12, prec=6))
        out_fields.append(QgsField("ebm_resid", QVariant.Double, len=12, prec=6))
        out_fields.append(QgsField("ebm_intercept", QVariant.Double, len=12, prec=6))
        out_fields.append(QgsField("ebm_used", QVariant.Int))
        for cname in contrib_fields:
            out_fields.append(QgsField(cname, QVariant.Double, len=12, prec=6))

        sink, dest_id = self.parameterAsSink(parameters, self.OUTPUT, context, out_fields, source.wkbType(), source.sourceCrs())
        self.out_layer_id = dest_id
        result_map = {fid: idx for idx, fid in enumerate(valid_fids)}
        total = source.featureCount() or 1
        for current, feature in enumerate(source.getFeatures()):
            if feedback.isCanceled():
                break
            out_feature = QgsFeature(feature)
            out_feature.setFields(out_fields)
            fid = feature.id()
            if fid in result_map:
                row_idx = result_map[fid]
                out_feature.setAttribute("ebm_pred", float(results["fitted"][row_idx]))
                out_feature.setAttribute("ebm_resid", float(results["residuals"][row_idx]))
                out_feature.setAttribute("ebm_intercept", results["intercept"])
                out_feature.setAttribute("ebm_used", 1)
                for col_idx, cname in enumerate(contrib_fields):
                    out_feature.setAttribute(cname, float(results["contributions"][row_idx, col_idx]))
            else:
                out_feature.setAttribute("ebm_pred", None)
                out_feature.setAttribute("ebm_resid", None)
                out_feature.setAttribute("ebm_intercept", None)
                out_feature.setAttribute("ebm_used", 0)
                for cname in contrib_fields:
                    out_feature.setAttribute(cname, None)
            sink.addFeature(out_feature, QgsFeatureSink.FastInsert)
            feedback.setProgress(int(30 + 70 * (current / total)))

        self._write_html(html_path, target_field, feature_fields, results, skipped)
        return {self.OUTPUT: dest_id, self.HTML_REPORT: html_path, "HTML_REPORT_OUT": html_path}

    def postProcessAlgorithm(self, context, feedback):
        layer = context.getMapLayer(self.out_layer_id) if self.out_layer_id else None
        field_descriptions = {
            "ebm_pred": "EBM predicted value",
            "ebm_resid": "Observed minus predicted (positive = model underpredicted)",
            "ebm_intercept": "Model base value (roughly constant across records)",
            "ebm_used": "1 if the record had complete target/feature values and was used to fit and predict, 0 otherwise",
        }
        apply_output_metadata(
            layer,
            "PlanX GeoStats Explainable Boosting Machine (EBM) regression output",
            field_descriptions,
            "ebm_regression",
        )
        apply_renderer(layer, diverging_residual_renderer(layer, layer.geometryType(), "ebm_resid"))
        return {}

    def _write_html(self, path, target_field, feature_fields, results, skipped):
        mean_abs_contrib = [
            (name, float(abs(results["contributions"][:, i]).mean()))
            for i, name in enumerate(feature_fields)
        ]
        mean_abs_contrib.sort(key=lambda item: item[1], reverse=True)
        rows = "".join(
            f"<tr><td>{html.escape(name)}</td><td>{value:.6f}</td></tr>"
            for name, value in mean_abs_contrib
        )
        residual_chart = scatter_plot_svg(
            list(results["fitted"]),
            list(results["residuals"]),
            x_label="Fitted value",
            y_label="Residual",
            trend_line=True,
            split_y=0.0,
        )
        guidance = analyst_guidance_html(
            "Explainable Boosting Machine (EBM) Regression",
            "A fully additive, glass-box boosted model whose per-field "
            "contribution to every prediction is exact, not a post-hoc "
            "approximation - interactions disabled so each term maps 1:1 "
            "onto an explanatory field.",
            [
                "R2 is comparable to Random Forest Regression / Gradient Boosting Regression on the same fields.",
                "ebm_intercept plus the sum of the ebm_<field> columns for a record reconstructs ebm_pred (a useful sanity check).",
                "The mean |contribution| ranking below makes planning sense for the fields involved.",
            ],
            [
                "R2 is notably lower than Gradient Boosting Regression on the same fields (the disabled pairwise interactions may matter for this dataset).",
                "One field's contribution dominates every record with no other field mattering (consider whether that field alone should drive the analysis instead).",
            ],
            [
                "Gradient Boosting Regression (any engine) - compare accuracy against a model that does use interactions.",
                "SHAP Global Feature Importance - a black-box-model comparison point using the approximated approach EBM avoids.",
                "Spatial k-Fold Cross-Validation Evaluator - honest out-of-sample R2.",
            ],
            "Prefer EBM when the audience needs the exact reason behind each "
            "prediction, not just an approximation - map an ebm_<field> "
            "column directly instead of running a separate SHAP tool.",
        )
        content = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>EBM Regression Report</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #243040; background: #f6f8fb; margin: 0; padding: 24px; }}
.container {{ max-width: 980px; margin: 0 auto; background: #fff; border: 1px solid #d9e2ec; border-radius: 8px; padding: 28px; }}
h1 {{ margin: 0 0 8px; font-size: 1.6rem; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 14px; }}
th, td {{ border-bottom: 1px solid #edf2f7; padding: 8px 10px; text-align: left; font-size: .9rem; }}
th {{ background: #ebf4ff; color: #24527a; text-transform: uppercase; font-size: .72rem; letter-spacing: .05em; }}
.summary {{ background: #eef7f3; border-left: 5px solid #2f855a; padding: 14px 18px; margin: 18px 0; }}
{analyst_guidance_css()}
{chart_css()}
</style></head>
<body><div class="container">
<h1>Explainable Boosting Machine Regression (EBM)</h1>
<p>Target: <strong>{html.escape(target_field)}</strong> | Explanatory fields: <strong>{html.escape(', '.join(feature_fields))}</strong></p>
<div class="summary">
R2 = {results['r2']:.6f} | RMSE = {results['rmse']:.6f} | MAE = {results['mae']:.6f} | Intercept = {results['intercept']:.6f}<br>
Skipped {skipped} record(s) with missing or non-numeric values.
</div>
<h2>Mean |Contribution| by Field (exact, not sampled)</h2>
<table><thead><tr><th>Field</th><th>Mean |ebm contribution|</th></tr></thead><tbody>{rows}</tbody></table>
<h2>Residual vs. Fitted</h2>
{residual_chart}
{guidance}
</div></body></html>"""
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(content)
