# -*- coding: utf-8 -*-
"""Global Moran's I (Spatial Autocorrelation) Processing Algorithm."""
from __future__ import annotations

import logging
import os
import tempfile
import html
import numpy as np

from ._mixins import HelpUrlMixin
from qgis.core import (
    NULL,
    QgsProcessing,
    QgsProcessingAlgorithm,
    QgsProcessingException,
    QgsProcessingParameterFeatureSource,
    QgsProcessingParameterField,
    QgsProcessingParameterEnum,
    QgsProcessingParameterNumber,
    QgsProcessingParameterFileDestination,
    QgsProcessingOutputHtml
)

from ..core.weights import build_weights_matrix
from ..core.stats_engines import calculate_global_moran
from ..core.analysis_diagnostics import (
    caveats_html,
    crs_unit_warning,
    diagnostics_html,
    neighbor_summary,
    numeric_quality_summary,
    push_diagnostics,
)
from ..core.reporting import analyst_guidance_css, analyst_guidance_html
from ..core.charts import chart_css, kpi_card_row_html, scatter_plot_svg
from ..core.spatial_autocorrelation_audit import global_moran_interpretation

from ._icons import algorithm_icon


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


class GlobalMoranAlgorithm(HelpUrlMixin, QgsProcessingAlgorithm):
    INPUT = "INPUT"
    FIELD = "FIELD"
    WEIGHT_TYPE = "WEIGHT_TYPE"
    KNN = "KNN"
    DISTANCE_BAND = "DISTANCE_BAND"
    HTML_REPORT = "HTML_REPORT"

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

    def displayName(self) -> str:
        return "Spatial Autocorrelation (Global Moran's I)"

    def group(self) -> str:
        return "02 | Urban Pattern Scan"

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

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

    def createInstance(self):
        return GlobalMoranAlgorithm()

    def shortHelpString(self) -> str:
        return (
            "Tests whether a numeric field is spatially clustered (similar values near "
            "each other), dispersed (dissimilar values adjacent), or indistinguishable "
            "from random, using Global Moran's I under the randomization assumption "
            "(Cliff & Ord, 1981). This is the gateway screening statistic: a "
            "significant result justifies local analysis (Local Moran's I, Getis-Ord "
            "Gi*) to locate where the pattern occurs; a non-significant result "
            "counsels caution before spatial modeling.\n\n"
            "Report fields: moran_i (observed I, roughly -1 to +1 for "
            "row-standardized weights), expected_i (= -1/(N-1) under CSR), variance, "
            "z_score, p_value, plus a plain-language pattern classification and "
            "confidence tier (Strong: p < 0.01, Moderate: p < 0.05, Weak: p >= "
            "0.05). z >= 1.96 indicates clustering, z <= -1.96 indicates dispersion, "
            "|z| < 1.96 is not distinguishable from complete spatial randomness at "
            "alpha = 0.05.\n\n"
            "Moran's I is a global number: it cannot say where clustering occurs, "
            "cannot separate one big cluster from several small ones, and is "
            "sensitive to the spatial-weights choice - a different W can flip the "
            "conclusion. It is also scale-dependent; if distance-band weights are "
            "used, run Incremental Spatial Autocorrelation first to find the "
            "distance where the signal peaks rather than guessing a threshold.\n\n"
            "The report also flags isolated features (zero neighbors, which "
            "contribute nothing and effectively shrink the sample) and "
            "fully-connected graphs (every feature a neighbor of every other, where "
            "I collapses toward 0 and loses discriminatory power) - both indicate a "
            "weight specification mismatched to the data's geometry."
        )

    def initAlgorithm(self, config=None):
        self.addParameter(
            QgsProcessingParameterFeatureSource(
                self.INPUT,
                "Input vector layer",
                [QgsProcessing.TypeVectorAnyGeometry]
            )
        )
        self.addParameter(
            QgsProcessingParameterField(
                self.FIELD,
                "Target numeric field to analyze",
                parentLayerParameterName=self.INPUT,
                type=QgsProcessingParameterField.Numeric
            )
        )
        self.addParameter(
            QgsProcessingParameterEnum(
                self.WEIGHT_TYPE,
                "Spatial relationship / weights type",
                options=["Queen contiguity", "Rook contiguity", "K-Nearest Neighbors (KNN)", "Distance Band"],
                defaultValue=0
            )
        )
        self.addParameter(
            QgsProcessingParameterNumber(
                self.KNN,
                "Number of neighbors (K value, KNN only)",
                type=QgsProcessingParameterNumber.Integer,
                defaultValue=5,
                minValue=1
            )
        )
        self.addParameter(
            QgsProcessingParameterNumber(
                self.DISTANCE_BAND,
                "Distance band threshold (map units, Distance Band only)",
                type=QgsProcessingParameterNumber.Double,
                defaultValue=1000.0,
                minValue=0.0001
            )
        )
        self.addParameter(
            QgsProcessingParameterFileDestination(
                self.HTML_REPORT,
                "Output HTML report",
                fileFilter="HTML files (*.html)",
                optional=True
            )
        )
        self.addOutput(
            QgsProcessingOutputHtml(
                "HTML_REPORT_OUT",
                "Spatial autocorrelation diagnostic report"
            )
        )

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

        field_name = self.parameterAsString(parameters, self.FIELD, context)
        weight_type_idx = self.parameterAsEnum(parameters, self.WEIGHT_TYPE, context)
        weight_types = ["queen", "rook", "knn", "distance"]
        weight_type = weight_types[weight_type_idx]

        k_neighbors = self.parameterAsInt(parameters, self.KNN, context)
        distance_band = self.parameterAsDouble(parameters, self.DISTANCE_BAND, context)

        # Resolve output HTML file path
        html_path = self.parameterAsFileOutput(parameters, self.HTML_REPORT, context)
        if not html_path:
            temp_dir = tempfile.gettempdir()
            html_path = os.path.join(temp_dir, "global_moran_report.html")

        # Validate target field
        field_idx = source.fields().lookupField(field_name)
        if field_idx < 0:
            raise QgsProcessingException(f"Target field '{field_name}' not found.")

        field = source.fields().at(field_idx)
        if not field.isNumeric():
            raise QgsProcessingException(f"Target field '{field_name}' must be numeric.")

        feedback.pushInfo("Generating spatial weights matrix...")
        neighbors, weights, id_order, _ = build_weights_matrix(
            source,
            weight_type,
            k_neighbors=k_neighbors,
            distance_band=distance_band,
            feedback=feedback
        )

        if feedback.isCanceled():
            return {}

        feedback.pushInfo("Extracting target field values...")
        y_dict = {}
        for f in source.getFeatures():
            if feedback.isCanceled():
                break
            val = f.attribute(field_name)
            if val is None or val == NULL or str(val) == 'NULL':
                continue
            try:
                y_dict[f.id()] = float(val)
            except (ValueError, TypeError):
                continue

        # Filter id_order and construct y array
        valid_id_order = [fid for fid in id_order if fid in y_dict]
        y = np.array([y_dict[fid] for fid in valid_id_order])
        numeric_summary = numeric_quality_summary(source.featureCount(), y_dict, y)
        neighborhood_summary = neighbor_summary(neighbors, valid_id_order)
        crs_warning = crs_unit_warning(source)
        push_diagnostics(feedback, numeric_summary, neighborhood_summary, crs_warning)

        if len(y) <= 3:
            raise QgsProcessingException("At least 4 valid features with numeric values are required for Global Moran's I analysis.")
        if numeric_summary["is_constant"]:
            raise QgsProcessingException("Global Moran's I requires variation in the target field; all valid values are identical.")

        feedback.pushInfo("Calculating Global Moran's I statistics...")
        moran_i, expected_i, variance, z_score, p_value = calculate_global_moran(
            y,
            neighbors,
            weights,
            valid_id_order
        )

        if feedback.isCanceled():
            return {}

        scatter_z, scatter_lag = self._moran_scatter_arrays(y, neighbors, weights, valid_id_order)

        feedback.pushInfo("Generating HTML report...")
        self.write_html_report(
            html_path,
            field_name,
            len(y),
            moran_i,
            expected_i,
            variance,
            z_score,
            p_value,
            numeric_summary,
            neighborhood_summary,
            crs_warning,
            weight_type,
            scatter_z,
            scatter_lag,
        )

        return {
            self.HTML_REPORT: html_path,
            "HTML_REPORT_OUT": html_path
        }

    @staticmethod
    def _moran_scatter_arrays(y, neighbors, weights, id_order):
        """Standardized attribute value (z) and its row-standardized spatial
        lag (Wz) per feature -- the standard Moran scatterplot axes (Anselin,
        1996). Derived from the same y/neighbors/weights already computed for
        calculate_global_moran(), not a new statistic."""
        y_mean = float(np.mean(y))
        y_std = float(np.std(y))
        if y_std == 0:
            return [], []
        z_scores = [(float(v) - y_mean) / y_std for v in y]
        id_to_idx = {fid: idx for idx, fid in enumerate(id_order)}
        lag = []
        for fid in id_order:
            neighs = neighbors.get(fid, [])
            w_list = weights.get(fid, [])
            acc = 0.0
            w_sum = 0.0
            for j_fid, w in zip(neighs, w_list):
                j = id_to_idx.get(j_fid)
                if j is not None:
                    acc += w * z_scores[j]
                    w_sum += w
            lag.append(acc / w_sum if w_sum > 0 else 0.0)
        return z_scores, lag

    def write_html_report(
        self,
        path: str,
        field_name: str,
        n: int,
        mi: float,
        ei: float,
        var: float,
        z: float,
        p: float,
        numeric_summary: dict,
        neighborhood_summary: dict,
        crs_warning: str,
        weight_type: str,
        scatter_z: list,
        scatter_lag: list,
    ):
        interpretation = global_moran_interpretation(z, p, neighborhood_summary)
        pattern = interpretation["pattern"]
        desc = interpretation["description"]
        status_color = interpretation["color"]
        confidence = interpretation["confidence"]
        next_action = interpretation["next_action"]

        confidence_tone = {"Strong": "good", "Moderate": "warn", "Weak": "neutral"}.get(confidence, "neutral")
        kpi_row = kpi_card_row_html([
            {"label": "Moran's I", "value": f"{mi:.4f}", "sublabel": pattern, "tone": confidence_tone},
            {"label": "z-score", "value": f"{z:.3f}", "sublabel": "|z| >= 1.96 is significant at alpha=0.05"},
            {"label": "p-value", "value": f"{p:.4f}", "sublabel": f"Confidence: {confidence}", "tone": confidence_tone},
        ])

        guidance_html = analyst_guidance_html(
            "Global Moran's I",
            "Global Moran's I evaluates whether similar values are spatially clustered, dispersed, or not detectably patterned across the full study area.",
            [
                "The input layer uses a projected CRS when distance bands are involved.",
                "The analysis field has enough numeric variation and few skipped records.",
                "The neighborhood graph is neither dominated by isolated features nor fully connected.",
            ],
            [
                "A significant result without a defensible neighborhood definition.",
                "A global result being used to claim exactly where the pattern occurs.",
                "Very sparse or very dense neighborhood support.",
            ],
            [
                "Incremental Spatial Autocorrelation for scale selection",
                "Local Moran's I for cluster/outlier locations",
                "Getis-Ord Gi* for hot/cold spot mapping",
            ],
            "Use this as a screening statistic. It can justify deeper local analysis, but it should not be the only evidence for a site-specific planning intervention.",
        )

        html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>PlanX GeoStats Lab Spatial Autocorrelation Report</title>
<style>
    body {{
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        color: #2d3748;
        background-color: #f7fafc;
        margin: 0;
        padding: 20px;
        line-height: 1.5;
    }}
    .container {{
        max-width: 760px;
        margin: 0 auto;
        background: #ffffff;
        padding: 30px;
        border-radius: 8px;
        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
    }}
    header {{
        border-bottom: 2px solid #edf2f7;
        padding-bottom: 20px;
        margin-bottom: 25px;
    }}
    h1 {{
        color: #1a202c;
        margin: 0 0 5px 0;
        font-size: 1.6rem;
    }}
    .subtitle {{
        color: #718096;
        margin: 0;
        font-size: 0.95rem;
    }}
    .interpretation-box {{
        background-color: #f8fafc;
        border-left: 5px solid {status_color};
        padding: 20px;
        border-radius: 4px;
        margin-bottom: 30px;
    }}
    .status-title {{
        font-size: 1.3rem;
        font-weight: 800;
        color: {status_color};
        margin: 0 0 10px 0;
        text-transform: uppercase;
        letter-spacing: 0.05em;
    }}
    .status-desc {{
        color: #4a5568;
        font-size: 0.95rem;
        margin: 0;
    }}
    table {{
        width: 100%;
        border-collapse: collapse;
        margin-bottom: 25px;
    }}
    th, td {{
        padding: 12px 15px;
        text-align: left;
        border-bottom: 1px solid #edf2f7;
        font-size: 0.9rem;
    }}
    th {{
        background-color: #ebf8ff;
        color: #2b6cb0;
        font-weight: 700;
        text-transform: uppercase;
        font-size: 0.75rem;
        letter-spacing: 0.05em;
    }}
    .metric-name {{
        font-weight: 600;
        color: #2d3748;
    }}
    .metric-val {{
        font-family: monospace;
        font-size: 1rem;
        font-weight: 600;
    }}
    footer {{
        margin-top: 40px;
        border-top: 1px solid #edf2f7;
        padding-top: 15px;
        font-size: 0.8rem;
        color: #a0aec0;
        text-align: center;
    }}
    section {{
        margin: 28px 0;
    }}
    h2 {{
        color: #1a202c;
        font-size: 1.15rem;
        margin: 0 0 12px 0;
    }}
    .next-action {{
        background: #f0fff4;
        border-left: 5px solid #2f855a;
        padding: 16px 18px;
        border-radius: 4px;
    }}
    {analyst_guidance_css()}
    {chart_css()}
</style>
</head>
<body>
<div class="container">
    <header>
        <h1>Spatial Autocorrelation (Global Moran's I)</h1>
        <p class="subtitle">Field Analyzed: <strong>{html.escape(field_name)}</strong> | Feature Count: <strong>{n}</strong> | Weights: <strong>{html.escape(weight_type)}</strong></p>
    </header>

    {kpi_row}

    <div class="interpretation-box">
        <h2 class="status-title">Spatial Pattern: {pattern}</h2>
        <p class="status-desc">{desc} Evidence strength is <strong>{confidence}</strong> at p = {p:.4f}.</p>
    </div>

    <section>
        <h2>Executive Summary</h2>
        <p>Global Moran's I tests whether similar values are spatially clustered across the full study area. This run indicates <strong>{pattern.lower()}</strong> behavior for <strong>{html.escape(field_name)}</strong>. A global result does not identify where the pattern occurs; it should be paired with a local statistic when planning decisions require location-specific action.</p>
    </section>

    <table>
        <thead>
            <tr>
                <th>Global Moran's I Diagnostic</th>
                <th>Statistical Value</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td class="metric-name">Moran's Index</td>
                <td class="metric-val">{mi:.6f}</td>
            </tr>
            <tr>
                <td class="metric-name">Expected Index</td>
                <td class="metric-val">{ei:.6f}</td>
            </tr>
            <tr>
                <td class="metric-name">Variance</td>
                <td class="metric-val">{var:.6f}</td>
            </tr>
            <tr>
                <td class="metric-name">z-score</td>
                <td class="metric-val">{z:.6f}</td>
            </tr>
            <tr>
                <td class="metric-name">p-value</td>
                <td class="metric-val">{p:.6f}</td>
            </tr>
        </tbody>
    </table>

    <section>
        <h2>Moran Scatterplot</h2>
        {scatter_plot_svg(
            scatter_z,
            scatter_lag,
            x_label="Standardized value (z)",
            y_label="Spatial lag (Wz)",
            title="",
            trend_line=True,
            quadrant_shading=True,
        ) if scatter_z else "<p>Moran scatterplot unavailable: the analysis field has zero variance.</p>"}
        <p class="chart-caption">Each point is one feature: x is its standardized value, y is the row-standardized average of its neighbors' standardized values. The dashed trend line's slope is a visual proxy for Moran's I itself. Upper-right/lower-left (blue) quadrants are positive spatial association (HH/LL); upper-left/lower-right (amber) are negative association (HL/LH) -- spatial outliers relative to their neighbors.</p>
    </section>

    {diagnostics_html(numeric_summary, neighborhood_summary, crs_warning)}

    <section>
        <h2>Recommended Next Action</h2>
        <div class="next-action">{html.escape(next_action)}</div>
    </section>

    {caveats_html("Global Moran's I", neighborhood_summary, numeric_summary)}

    {guidance_html}

    <footer>
        Generated by PlanX GeoStats Lab spatial statistics engine.
    </footer>
</div>
</body>
</html>
"""
        with open(path, "w", encoding="utf-8") as f:
            f.write(html_content)
