import socket
import webbrowser
from datetime import datetime, timedelta

from psycopg2 import sql as pgsql
import matplotlib as mpl
from matplotlib import pyplot as _plt
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import (
    FigureCanvasQTAgg as FigureCanvas)
from matplotlib.patches import Polygon as MplPolygon
from matplotlib.collections import PatchCollection
from shapely import wkt as shapely_wkt
from qgis.PyQt.QtCore import QSettings, Qt
from qgis.core import QgsApplication, QgsTask

from dataclasses import dataclass, field
from dataclasses import replace as dataclasses_replace

from ..widgets.crop_simulation_page import CropSimulationPage
from ..widgets.crop_settings_dialog import CropSettingsDialog
from ..support_scripts.__init__ import check_text, db_rows, TR, isfloat
from ..support_scripts.notifier import report_warning, report_error, report_success
from ..support_scripts.license_client import LicenseClient, LicenseError
from ..support_scripts.open_meteo_client import OpenMeteoClient, OpenMeteoError
from ..support_scripts.fertilizer_timing_model import (
    ADVANCED_HORIZON_DAYS, DailyWeather, FertilizerEvent, analyse_events,
    summarize)
from ..support_scripts.season_water_model import daily_trace, estimate_season
from ..support_scripts import crop_model_settings
from ..support_scripts import crop_models
from ..support_scripts import field_grid

__author__ = 'Axel Horteborn'

# This module never calls pyplot itself (_render_heatmap/_render_curve_
# chart below both use a bare Figure(), never plt.figure()/subplots()),
# but pyplot's *interactive mode* is process-wide global state - if
# anything else already loaded into this QGIS session imports pyplot and
# leaves interactive mode on (database_scripts/mean_analyse.py does, via
# plt.subplots()), a freshly created Figure/FigureCanvas can still end up
# auto-shown as its own top-level window instead of staying embedded,
# purely because interactive mode happened to be on when it was drawn -
# not something this module's own (pyplot-free) code controls otherwise.
# ioff() just flips that one global flag back off; it doesn't create or
# track a figure, so it doesn't reintroduce the leak pyplot.figure()/
# subplots() would.
_plt.ioff()

# Where the Pro license key/activation instance id are stored in QSettings.
LICENSE_KEY_SETTING = "geodatafarm/pro_license_key"
LICENSE_INSTANCE_SETTING = "geodatafarm/pro_license_instance_id"
# Manual developer escape hatch - see is_licensed()'s docstring.
DEV_BYPASS_LICENSE_SETTING = "geodatafarm/dev_bypass_license"
# TODO: replace with the real Lemon Squeezy checkout/product URL before
# release - see PBGetLicense in widgets/crop_simulation_page.py.
PRO_LICENSE_CHECKOUT_URL = "https://geodatafarm.lemonsqueezy.com/buy/PLACEHOLDER"

_RISK_COLORS = {'low': 'green', 'moderate': 'orange', 'high': 'red',
                'unknown': 'gray'}
_SELECT_CROP = '--- Select crop ---'
# Trailing item in CBCrop - picking it reveals page.CBCropOther (a plain
# QLineEdit) for typing a crop name that isn't otherwise listed, instead
# of making the combo box itself editable (which loses the dropdown
# arrow/list-only click behaviour and makes every entry, known or
# farm-logged, look the same - see widgets/crop_simulation_page.py).
_OTHER_CROP = 'Other (type below)…'

# (settings_dlg spin box attribute name, crop_models.CropModel field name)
# for every field the settings popup's "Crop model" section can save - see
# crop_model_settings.BIG_LEVER_FIELDS/CURVE_SHAPE_FIELDS, which this must
# stay in sync with (a mismatch would silently drop a field from saving).
# Centralised here rather than repeated per field at each of the four call
# sites that touch all of them (_load_settings_for_current_variety,
# _dialog_crop_model, _save_crop_settings, set_widget_connections).
_BIG_LEVER_SPIN_FIELDS = (
    ('SBPotentialYield', 'potential_yield_t_ha'),
    ('SBKyInitial', 'ky_initial'),
    ('SBKyDevelopment', 'ky_development'),
    ('SBKyMidSeason', 'ky_mid_season'),
    ('SBKyLateSeason', 'ky_late_season'),
    ('SBKyNitrogen', 'ky_nitrogen'),
    ('SBNDemand', 'season_n_demand_kg_ha'),
    ('SBKyPotassium', 'ky_potassium'),
    ('SBKDemand', 'season_k_demand_kg_ha'),
    ('SBPDemand', 'season_p_demand_kg_ha'),
    ('SBMgDemand', 'season_mg_demand_kg_ha'),
    ('SBReferenceSpacing', 'reference_spacing_mm'),
    ('SBSpacingSensitivity', 'spacing_sensitivity'),
    ('SBHeatThreshold', 'heat_stress_threshold_c'),
    ('SBKyHeat', 'ky_heat'),
)
_CURVE_SHAPE_SPIN_FIELDS = (
    ('SBGddBase', 'gdd_base_c'),
    ('SBRootDepthMin', 'root_depth_min_cm'),
    ('SBRootDepthMax', 'root_depth_max_cm'),
    ('SBRootDepthFullGdd', 'root_depth_full_gdd'),
    ('SBKcIni', 'kc_ini'),
    ('SBKcMid', 'kc_mid'),
    ('SBKcEnd', 'kc_end'),
    ('SBKcIniEndGdd', 'kc_ini_end_gdd'),
    ('SBKcMidEndGdd', 'kc_mid_end_gdd'),
    ('SBKcLateStartGdd', 'kc_late_start_gdd'),
    ('SBSeasonEndGdd', 'season_end_gdd'),
    ('SBNUptakeMidpoint', 'n_uptake_midpoint_gdd'),
    ('SBNUptakeSteepness', 'n_uptake_steepness'),
    ('SBKUptakeMidpoint', 'k_uptake_midpoint_gdd'),
    ('SBKUptakeSteepness', 'k_uptake_steepness'),
)
_MODEL_SPIN_FIELDS = _BIG_LEVER_SPIN_FIELDS + _CURVE_SHAPE_SPIN_FIELDS


@dataclass
class _SimulationInputs:
    """Everything :meth:`CropSimulation._compute_simulation` needs, read
    from the page's widgets on the main thread by run_simulation() before
    handing off to a background _RunSimulationTask - the widgets
    themselves can't be touched from the task's worker thread, so nothing
    past this point may read self.page directly."""
    field_name: str
    date_from: str
    date_to: str
    override_crop: str
    # A snapshot, not a live reference to CropSimulation._planned_events -
    # the list could otherwise be mutated (e.g. "Add" clicked again) while
    # the task is still reading it on another thread.
    planned_events: list = field(default_factory=list)
    # Optional per-run "growth stopped early" override (YYYY-MM-DD) - see
    # CBGrowthStopEnabled/DEGrowthStop on the page and
    # season_water_model.estimate_season's harvest_date parameter. None
    # (the default, checkbox unticked) means "no override": the crop's
    # natural end-of-season decline runs its full course, as before this
    # existed.
    growth_stop_date: str = None


@dataclass
class _SimulationResult:
    """Everything :meth:`CropSimulation._apply_simulation_result` needs to
    render onto the page/settings dialog, back on the main thread - the
    return value of :meth:`CropSimulation._compute_simulation`, which must
    not touch self.page/self.settings_dlg itself (see that method's
    docstring). ``warnings`` collects messages that would normally have
    been shown immediately via report_warning, deferred here for the same
    reason - the message bar is a Qt widget too."""
    field_name: str
    date_from: str
    date_to: str
    auto_crop: object
    override_crop: str
    crop_for_model: str
    results: list
    season: object
    weather: list
    irrigation_by_date: dict
    fertilizer_kg_n_by_date: dict
    fertilizer_kg_k_by_date: dict
    phosphorus_applied_kg_ha: object
    magnesium_applied_kg_ha: object
    spacing_mm: object
    clay_pct: object
    organic_matter_pct: object
    legacy_warning_text: str
    cell_polygons: dict
    cell_traces: dict
    cell_varieties: dict
    trace_dates: list
    # The logged planting date actually used to anchor the GDD clock (None
    # if none was on file, or it fell outside the analysed period - see
    # _compute_simulation) and the run's "growth stopped early" override,
    # if any - both echoed back so _apply_simulation_result can show them
    # and cache them into _last_run for the settings popup's live preview.
    planting_date: object = None
    growth_stop_date: object = None
    warnings: list = field(default_factory=list)


class _RunSimulationTask(QgsTask):
    """Runs :meth:`CropSimulation._compute_simulation` - the slow part of
    "Run simulation" (Open-Meteo fetch, per-application analysis, season
    balance, per-cell stress map) - on a background thread via QGIS's task
    manager, so the UI doesn't freeze while it works. See
    CropSimulation.run_simulation for what builds and launches this, and
    CropSimulation._on_simulation_task_finished for how the result gets
    applied afterward, back on the main thread (QgsTask.finished() is
    always called on the main thread, even though run() runs on a worker
    thread - that split is exactly why the compute/apply methods either
    side of this are kept so strictly separate).

    Known limitation: self.db is a single psycopg2 connection shared by
    the whole plugin, not a per-thread connection pool. This task's own DB
    calls are safe (they only ever happen serially, one at a time, on this
    task's worker thread), but another plugin feature touching self.db
    *concurrently* from the main thread while this runs could race on that
    same connection - see CropSimulation._set_running_state, which
    disables this tab's own controls while a task is in flight, but can't
    prevent that for other tabs. Solving that properly would need real
    per-thread connections, out of scope for this feature.
    """

    def __init__(self, description, controller, inputs):
        super().__init__(description, QgsTask.Flag.CanCancel)
        self._controller = controller
        self._inputs = inputs
        self.result = None
        self.error = None

    def run(self):
        try:
            self.result = self._controller._compute_simulation(self._inputs, self)
        except Exception as e:  # noqa: BLE001 - reported to the user, see finished()
            self.error = e
            return False
        # _compute_cell_traces checks isCanceled() periodically and can
        # return an incomplete result rather than raising - treat that as
        # failure too, so finished() never renders a partial run as if it
        # were the real, complete result.
        return not self.isCanceled()

    def finished(self, success):
        self._controller._on_simulation_task_finished(self, success)


class CropSimulation:
    """Controller for the "Crop simulation" main tab (Pro feature) - see
    widgets/crop_simulation_page.py for the page this fills in and reacts
    to.

    Field-wide inputs (weather, the per-application fertilizer-timing
    detail, the season yield/irrigation estimate) work exactly like the
    fertilizer-timing analysis this replaces. What's new is the date-slider
    stress map: the field is split into a grid (support_scripts/
    field_grid.py - 2m cells, coarsened automatically only if that would be
    an unreasonable number of cells) and a day-by-day soil water balance
    (support_scripts/season_water_model.py's ``daily_trace``) runs once per
    cell, using that cell's own crop/soil/irrigation where imported/logged
    data covers it (falling back to the field-wide reading for
    crop/soil - not for irrigation, where "nothing logged" genuinely means
    zero - see :meth:`_resolve_crop_by_cell`/:meth:`_resolve_soil_by_cell`/
    :meth:`_resolve_irrigation_by_cell`). Only weather is shared field-wide
    outright: it doesn't vary meaningfully within one field, so it's
    fetched once for the whole run (see :meth:`_load_weather`).
    """

    def __init__(self, parent):
        self.parent = parent
        self.db = parent.db
        translate = TR('CropSimulation')
        self.tr = translate.tr
        self.qsettings = QSettings()
        self.license_client = LicenseClient()
        self.weather_client = OpenMeteoClient()
        self.page = CropSimulationPage()
        self.settings_dlg = CropSettingsDialog()
        # The license section lives directly on this page now (moved off
        # "Farm & Fields", since this is the feature it gates) - kept as a
        # separate name so it reads the same as the section it replaced.
        self.license_dlg = self.page
        self.canvas = None
        self.connect_buttons = False
        # Fertilizer applications added via "Add" in the page - used for
        # this run only, never written to the database.
        self._planned_events = []
        # Populated by run_simulation() for the date slider/heatmap.
        self._cell_polygons = {}   # cell_id -> polygon WKT
        self._cell_traces = {}     # cell_id -> {date_str: DailyStress}
        self._cell_varieties = {}  # cell_id -> variety name (only cells with one)
        self._trace_dates = []     # sorted date strings covered by the run
        # Cached inputs from the last successful run_simulation(), so the
        # Crop model settings popup can recompute a live preview without a
        # fresh Open-Meteo/DB round trip - see open_crop_settings and
        # _recompute_settings_preview.
        self._last_run = None
        # The crop/variety the settings popup is currently open for - see
        # _populate_settings_dialog/_load_settings_for_current_variety.
        # '' (not None) for _settings_variety means "crop-level, no variety".
        self._settings_crop_name = None
        self._settings_variety = ''
        # Spin-box values as of the last _load_settings_for_current_variety
        # call - see _save_crop_settings, which only saves a field whose
        # value has since diverged from this baseline.
        self._settings_starting_values = {}
        # The in-flight _RunSimulationTask, if "Run simulation" is
        # currently working in the background - None otherwise. Guards
        # against starting a second run (and its own DB queries on the
        # single shared self.db connection - see _RunSimulationTask's
        # docstring) while one is already using it.
        self._running_task = None

    def set_widget_connections(self):
        """Wires the page's buttons - called once at startup (see
        GeoDataFarm.set_buttons), since this is a persistent tab rather
        than a dialog that gets freshly opened each time."""
        if self.connect_buttons:
            return
        self.page.PBActivateLicense.clicked.connect(self.activate_license)
        self.page.PBGetLicense.clicked.connect(
            lambda: webbrowser.open(PRO_LICENSE_CHECKOUT_URL))
        self.page.PBRun.clicked.connect(self.run_simulation)
        self.page.PBAddPlanned.clicked.connect(self.add_planned_event)
        self.page.PBRemovePlanned.clicked.connect(self.remove_planned_event)
        self.page.SLDate.valueChanged.connect(self._on_slider_changed)
        self.page.CBCrop.currentIndexChanged.connect(self._toggle_crop_other_field)
        self.page.PBCropSettings.clicked.connect(self.open_crop_settings)
        self.settings_dlg.PBSaveSettings.clicked.connect(self._save_crop_settings)
        self.settings_dlg.PBResetSettings.clicked.connect(self._reset_crop_settings)
        self.settings_dlg.CBVariety.currentIndexChanged.connect(
            self._load_settings_for_current_variety)
        for attr, _field in _MODEL_SPIN_FIELDS:
            getattr(self.settings_dlg, attr).valueChanged.connect(
                self._recompute_settings_preview)
        for spin_box in (self.settings_dlg.SBClay, self.settings_dlg.SBOrganicMatter,
                        self.settings_dlg.SBSpacing):
            spin_box.valueChanged.connect(self._recompute_settings_preview)
        key = self.qsettings.value(LICENSE_KEY_SETTING, '') or ''
        self.license_dlg.LELicenseKey.setText(key)
        self._refresh_license_status()
        self._populate_crop_combo()
        self._apply_pro_rollout_gate()
        self.connect_buttons = True

    def _apply_pro_rollout_gate(self):
        """TEMPORARY: PRO_LICENSE_CHECKOUT_URL above is still a placeholder -
        the Lemon Squeezy store isn't live yet (pending their review - needs
        a demo video, Terms of Service and Refund Policy first). Disables
        just the "Get a Pro license" button, the one thing on this page that
        is actually broken right now, with a "coming soon" tooltip instead -
        unless the dev_bypass_license escape hatch (see is_licensed()'s
        docstring) is set, so development/testing isn't blocked by this.

        Deliberately does NOT touch Activate/Run/Crop settings/planned-event
        buttons: activation already works independently of the store's
        review status (Lemon Squeezy's license-verification API doesn't
        care whether the storefront is approved), Run is already correctly
        gated by the real is_licensed() check, and the planned-event/crop-
        settings buttons are free exploration unrelated to purchasing.
        Remove this whole method (and its call above) once
        PRO_LICENSE_CHECKOUT_URL is real and the store is approved."""
        if self.qsettings.value(DEV_BYPASS_LICENSE_SETTING, False, type=bool):
            return
        self.page.PBGetLicense.setEnabled(False)
        self.page.PBGetLicense.setToolTip(self.tr(
            "Coming soon - the Pro license store isn't live yet."))
        # Only override the "not licensed" status text, not a genuinely
        # licensed one (e.g. a manually-issued key from before the store
        # opened) - someone who already has a real license shouldn't be
        # told the feature they're using is "coming soon".
        key = self.qsettings.value(LICENSE_KEY_SETTING, '') or ''
        instance_id = self.qsettings.value(LICENSE_INSTANCE_SETTING, '') or ''
        if not (key and instance_id):
            self.license_dlg.LLicenseStatus.setText(self.tr(
                "Crop simulation (Pro) is coming soon - the license store "
                "isn't live yet."))

    # ------------------------------------------------------------------
    # Licensing
    # ------------------------------------------------------------------

    def activate_license(self):
        """Activates the license key typed into the page."""
        key = self.license_dlg.LELicenseKey.text().strip()
        if not key:
            report_warning(self.tr('Please enter a license key.'))
            return
        try:
            payload = self.license_client.activate(key, socket.gethostname())
        except LicenseError as e:
            report_error(str(e))
            return
        if not payload.get('activated'):
            self.license_dlg.LLicenseStatus.setText(self.tr(
                'Not licensed: {error}').format(
                    error=payload.get('error') or self.tr('invalid license key.')))
            return
        instance_id = (payload.get('instance') or {}).get('id', '')
        self.qsettings.setValue(LICENSE_KEY_SETTING, key)
        self.qsettings.setValue(LICENSE_INSTANCE_SETTING, instance_id)
        self.license_dlg.LLicenseStatus.setText(self.tr(
            'Licensed. The crop simulation is unlocked.'))
        report_success(self.tr('GeoDataFarm Pro license activated.'))

    def _refresh_license_status(self):
        """Updates the status label from what's already saved, without a
        network round trip (used at startup so the tab is instant;
        :meth:`is_licensed` does the real, live check before running)."""
        key = self.qsettings.value(LICENSE_KEY_SETTING, '') or ''
        instance_id = self.qsettings.value(LICENSE_INSTANCE_SETTING, '') or ''
        if key and instance_id:
            self.license_dlg.LLicenseStatus.setText(self.tr(
                'Licensed. The crop simulation is unlocked.'))
        else:
            self.license_dlg.LLicenseStatus.setText(self.tr(
                'Not licensed. Click "Get a Pro license" to purchase one, '
                'then paste the key you receive by email above and press '
                'Activate to unlock the crop simulation.'))

    def is_licensed(self):
        """Live-validates the saved license key/instance with Lemon Squeezy.

        Returns
        -------
        bool
            True if licensed. Fails open (returns True) on a network error
            when a key/instance is already saved, so a flaky connection
            doesn't lock out someone who already paid; fails closed (False)
            when there is nothing saved to validate.

            Also returns True, skipping the live check entirely, if the
            ``geodatafarm/dev_bypass_license`` QSettings flag is set - a
            manual developer escape hatch for testing before the Lemon
            Squeezy store/product exists, e.g. from the QGIS Python Console::

                from qgis.PyQt.QtCore import QSettings
                QSettings().setValue('geodatafarm/dev_bypass_license', True)

            Set it back to False (or remove it) to test the real gate again.
        """
        if self.qsettings.value(DEV_BYPASS_LICENSE_SETTING, False, type=bool):
            return True
        key = self.qsettings.value(LICENSE_KEY_SETTING, '') or ''
        instance_id = self.qsettings.value(LICENSE_INSTANCE_SETTING, '') or ''
        if not key or not instance_id:
            return False
        try:
            payload = self.license_client.validate(key, instance_id)
        except LicenseError:
            return True
        return bool(payload.get('valid'))

    def _populate_crop_combo(self):
        """Fills "Use crop" with the crops this model has a literature-
        tuned profile for (see support_scripts/crop_models.CROP_MODELS)
        first, a separator, then whatever else this farm has used (the
        ``crops`` table) that isn't already one of those, and finally
        :data:`_OTHER_CROP` - picking that reveals page.CBCropOther (see
        :meth:`_toggle_crop_other_field`) for typing any other name, which
        still works fine, just without a crop-specific profile behind it
        (crop_models.get_crop_model falls back to a generic default for
        an unrecognised name)."""
        self.page.CBCrop.clear()
        self.page.CBCrop.addItem(self.tr(_SELECT_CROP))
        known = sorted(crop_models.CROP_MODELS.keys())
        for crop_name in known:
            self.page.CBCrop.addItem(crop_name.capitalize())
        known_set = set(known)
        rows = db_rows(self.db.execute_and_return(
            "SELECT crop_name FROM crops ORDER BY crop_name"))
        farm_crops = [crop_name for (crop_name,) in rows
                     if crop_name and crop_name.strip().lower() not in known_set]
        if farm_crops:
            self.page.CBCrop.insertSeparator(self.page.CBCrop.count())
            for crop_name in farm_crops:
                self.page.CBCrop.addItem(crop_name)
        self.page.CBCrop.insertSeparator(self.page.CBCrop.count())
        self.page.CBCrop.addItem(self.tr(_OTHER_CROP))
        self.page.CBCropOther.clear()
        self.page.CBCropOther.setVisible(False)

    def _toggle_crop_other_field(self):
        self.page.CBCropOther.setVisible(
            self.page.CBCrop.currentText() == self.tr(_OTHER_CROP))

    def _current_crop_override(self):
        """The crop name "Use crop" is currently set to, or ``''`` if
        it's on the "not set" sentinel - :data:`_OTHER_CROP`'s own typed-in
        text from page.CBCropOther if that's what's selected, otherwise
        the combo box's own current text. Centralised here since every
        caller that used to read page.CBCrop.currentText() directly needs
        to go through this now (see :meth:`_toggle_crop_other_field`)."""
        current = self.page.CBCrop.currentText().strip()
        if current == self.tr(_SELECT_CROP):
            return ''
        if current == self.tr(_OTHER_CROP):
            return self.page.CBCropOther.text().strip()
        return current

    def add_planned_event(self):
        """Adds a fertilizer application typed into the page to this run's
        analysis only - never written to the database. One application can
        carry several nutrients at once (a real compound product, e.g. an
        NPK+Mg blend, delivers them together) - N is the only one that
        affects which per-application timing-risk tier this event can use
        (see fertilizer_timing_model.analyse_events), P/K/Mg are otherwise
        independent and any subset of the four may be left blank."""
        rate_text = self.page.LEPlannedRate.text().strip()
        rate_text_p = self.page.LEPlannedRateP.text().strip()
        rate_text_k = self.page.LEPlannedRateK.text().strip()
        rate_text_mg = self.page.LEPlannedRateMg.text().strip()
        if not (rate_text or rate_text_p or rate_text_k or rate_text_mg):
            report_warning(self.tr(
                'Enter at least one nutrient rate for the planned application first.'))
            return
        date_str = self.page.DEPlannedDate.date().toString('yyyy-MM-dd')
        crop = self._current_crop_override()
        event = FertilizerEvent(
            date=date_str, rate_text=rate_text, crop=crop, rate_text_p=rate_text_p,
            rate_text_k=rate_text_k, rate_text_mg=rate_text_mg)
        self._planned_events.append(event)
        summary_parts = []
        notes = ''
        if rate_text:
            summary_parts.append('N: {}'.format(rate_text))
            notes += self._rate_parse_note('N', event.rate_kg_n_ha,
                self.tr(' - won\'t count toward the nitrogen balance, and '
                        'can\'t use the advanced timing model'))
        if rate_text_p:
            summary_parts.append('P: {}'.format(rate_text_p))
            notes += self._rate_parse_note('P', event.rate_kg_p_ha)
        if rate_text_k:
            summary_parts.append('K: {}'.format(rate_text_k))
            notes += self._rate_parse_note('K', event.rate_kg_k_ha)
        if rate_text_mg:
            summary_parts.append('Mg: {}'.format(rate_text_mg))
            notes += self._rate_parse_note('Mg', event.rate_kg_mg_ha)
        self.page.LWPlannedEvents.addItem('{} - {}{}'.format(
            date_str, ', '.join(summary_parts), notes))
        self.page.LEPlannedRate.clear()
        self.page.LEPlannedRateP.clear()
        self.page.LEPlannedRateK.clear()
        self.page.LEPlannedRateMg.clear()

    def _rate_parse_note(self, nutrient_label, rate_kg_ha, not_understood_consequence=''):
        """" (N: read as X kg N/ha)", or a "not understood" warning if
        ``rate_kg_ha`` is None, for one nutrient - see
        FertilizerEvent's rate_kg_n_ha/rate_kg_p_ha/rate_kg_k_ha/
        rate_kg_mg_ha docstrings for the best-effort parse this confirms
        or flags. Without this, a typo or an unparseable rate silently
        contributes nothing to that nutrient's balance with no indication
        anywhere that that happened."""
        if rate_kg_ha is None:
            return self.tr(' ({label}: rate not understood as a number{consequence})').format(
                label=nutrient_label, consequence=not_understood_consequence)
        return self.tr(' ({label}: read as {rate:.0f} kg {label}/ha)').format(
            label=nutrient_label, rate=rate_kg_ha)

    def remove_planned_event(self):
        row = self.page.LWPlannedEvents.currentRow()
        if row < 0:
            report_warning(self.tr('Select a planned application to remove first.'))
            return
        self.page.LWPlannedEvents.takeItem(row)
        del self._planned_events[row]

    # ------------------------------------------------------------------
    # Simulation
    # ------------------------------------------------------------------

    def run_simulation(self):
        """Gathers this run's inputs from the page's widgets - which must
        happen here, on the main thread, since a background task's worker
        thread can't safely touch them - and hands the slow work (Open-
        Meteo fetch, per-application analysis, season balance, per-cell
        stress map) off to a background _RunSimulationTask, so the UI
        doesn't freeze while it runs. See :meth:`_compute_simulation` for
        that work and :meth:`_on_simulation_task_finished`/
        :meth:`_apply_simulation_result` for how the result gets rendered
        once it's done."""
        if not self.is_licensed():
            report_warning(self.tr(
                'The crop simulation is a Pro feature. Please activate a '
                'license key above first.'))
            return
        if self._running_task is not None:
            report_warning(self.tr(
                'A simulation is already running - please wait for it to finish.'))
            return
        field_name = self.page.CBField.currentText()
        if not field_name or field_name == self.tr('--- Select field ---'):
            report_warning(self.tr('Please select a field first.'))
            return
        date_from = self.page.DEFrom.date().toString('yyyy-MM-dd')
        date_to = self.page.DETo.date().toString('yyyy-MM-dd')
        if date_from >= date_to:
            report_warning(self.tr(
                'The "to date" must be later than the "from date".'))
            return
        override_crop = self._current_crop_override()
        growth_stop_date = None
        if self.page.CBGrowthStopEnabled.isChecked():
            growth_stop_date = self.page.DEGrowthStop.date().toString('yyyy-MM-dd')
            if growth_stop_date < date_from:
                report_warning(self.tr(
                    'The "growth stopped early on" date must be on or after '
                    'the "from" date.'))
                return

        inputs = _SimulationInputs(
            field_name=field_name, date_from=date_from, date_to=date_to,
            override_crop=override_crop, planned_events=list(self._planned_events),
            growth_stop_date=growth_stop_date)
        task = _RunSimulationTask(self.tr('Crop simulation'), self, inputs)
        self._running_task = task
        self._set_running_state(True)
        if getattr(self.parent, 'test_mode', False):
            # Deterministic and synchronous in tests - no real thread pool
            # or Qt event loop pumping needed, so this still behaves like
            # a plain blocking call from a test's point of view, even
            # though it goes through the exact same run()/finished() split
            # production code uses via the real task manager.
            success = task.run()
            task.finished(success)
        else:
            QgsApplication.taskManager().addTask(task)

    def _compute_simulation(self, inputs, task=None):
        """All of run_simulation()'s slow work, with no Qt widget access -
        this is what actually runs on _RunSimulationTask's worker thread.
        Warnings that would normally show immediately are collected into
        the result's ``warnings`` list instead of calling report_warning
        directly here (that touches the message bar - a Qt widget, only
        safe from the main thread); :meth:`_apply_simulation_result` shows
        them once execution is back there.

        Returns
        -------
        _SimulationResult
        """
        field_name, date_from, date_to = inputs.field_name, inputs.date_from, inputs.date_to
        warnings = []

        weather, weather_warning = self._load_weather(field_name, date_from, date_to)
        if weather_warning:
            warnings.append(weather_warning)
        if not weather:
            return _SimulationResult(
                field_name=field_name, date_from=date_from, date_to=date_to,
                auto_crop=None, override_crop=inputs.override_crop, crop_for_model='',
                results=[], season=None, weather=[], irrigation_by_date={},
                fertilizer_kg_n_by_date={}, fertilizer_kg_k_by_date={},
                phosphorus_applied_kg_ha=None, magnesium_applied_kg_ha=None,
                spacing_mm=None, clay_pct=None,
                organic_matter_pct=None, legacy_warning_text='', cell_polygons={},
                cell_traces={}, cell_varieties={}, trace_dates=[],
                growth_stop_date=inputs.growth_stop_date, warnings=warnings)

        auto_crop, auto_planting_date = self._load_crop(field_name, date_to)
        crop_for_model = inputs.override_crop or auto_crop or ''

        # Anchor the growing-degree-day (GDD) clock to a real logged
        # planting date instead of blindly treating "From" as day one -
        # only when the crop itself came from that same planting record
        # (an override_crop has no reliable planting date to pair with)
        # and the date actually falls inside the analysed period; if it's
        # earlier than "From", the weather fetch (and hence the per-cell
        # stress map/irrigation window) isn't widened to cover it, so the
        # clock still can't start any earlier than "From" - flagged here
        # instead, so the user knows to adjust "From" for an accurate run.
        planting_date = None
        if not inputs.override_crop and auto_planting_date:
            if auto_planting_date >= date_from:
                planting_date = auto_planting_date
            else:
                warnings.append(self.tr(
                    'This field\'s crop was logged as planted on {planted} - '
                    'before the selected "From" date ({date_from}). The '
                    'water/nitrogen timing below will start counting from '
                    '{date_from} instead of the real planting date, so '
                    'early-season values may not match the crop\'s actual '
                    'stage. Set "From" to on/before {planted} for an '
                    'accurate season model.').format(
                        planted=auto_planting_date, date_from=date_from))

        events = self._load_events(field_name, date_from, date_to)
        events.extend(inputs.planned_events)
        if inputs.override_crop:
            for event in events:
                if not event.crop:
                    event.crop = inputs.override_crop
        events.sort(key=lambda e: e.date)

        clay_pct, organic_matter_pct = self._load_soil(field_name)

        if events:
            results = analyse_events(events, weather, clay_pct, organic_matter_pct)
        else:
            warnings.append(self.tr(
                'No fertilizer applications found for that field/period - '
                'add a planned application above to see its timing risk.'))
            results = []

        irrigation_by_date = self._load_irrigation(field_name, date_from, date_to)
        fertilizer_kg_n_by_date = self._build_fertilizer_kg_n_by_date(events)
        fertilizer_kg_k_by_date = self._build_fertilizer_kg_k_by_date(events)
        phosphorus_applied_kg_ha = self._sum_nutrient_applied(events, 'rate_kg_p_ha')
        magnesium_applied_kg_ha = self._sum_nutrient_applied(events, 'rate_kg_mg_ha')
        spacing_mm = self._load_spacing(field_name)
        effective_model = crop_model_settings.effective_crop_model(self.db, crop_for_model)
        season = estimate_season(
            weather, crop_for_model, clay_pct, organic_matter_pct, irrigation_by_date,
            fertilizer_kg_n_by_date=fertilizer_kg_n_by_date,
            fertilizer_kg_k_by_date=fertilizer_kg_k_by_date,
            phosphorus_applied_kg_ha=phosphorus_applied_kg_ha,
            magnesium_applied_kg_ha=magnesium_applied_kg_ha, crop_model=effective_model,
            spacing_mm=spacing_mm, planting_date=planting_date,
            harvest_date=inputs.growth_stop_date)

        legacy_warning_text = self._compute_legacy_irrigation_warning(
            field_name, date_from, date_to, irrigation_by_date)

        cell_polygons, cell_traces, cell_varieties, trace_dates = self._compute_cell_traces(
            field_name, date_from, date_to, weather, crop_for_model, clay_pct,
            organic_matter_pct, task=task, planting_date=planting_date,
            harvest_date=inputs.growth_stop_date)

        return _SimulationResult(
            field_name=field_name, date_from=date_from, date_to=date_to,
            auto_crop=auto_crop, override_crop=inputs.override_crop,
            crop_for_model=crop_for_model, results=results, season=season, weather=weather,
            irrigation_by_date=irrigation_by_date,
            fertilizer_kg_n_by_date=fertilizer_kg_n_by_date,
            fertilizer_kg_k_by_date=fertilizer_kg_k_by_date,
            phosphorus_applied_kg_ha=phosphorus_applied_kg_ha,
            magnesium_applied_kg_ha=magnesium_applied_kg_ha, spacing_mm=spacing_mm,
            clay_pct=clay_pct, organic_matter_pct=organic_matter_pct,
            legacy_warning_text=legacy_warning_text, cell_polygons=cell_polygons,
            cell_traces=cell_traces, cell_varieties=cell_varieties, trace_dates=trace_dates,
            planting_date=planting_date, growth_stop_date=inputs.growth_stop_date,
            warnings=warnings)

    def _on_simulation_task_finished(self, task, success):
        """QgsTask.finished()'s callback - always runs on the main thread
        (see :class:`_RunSimulationTask`'s docstring), even though the
        task's own run() ran on a worker thread, so this is where it's
        safe to touch self.page/self.settings_dlg again."""
        self._running_task = None
        self._set_running_state(False)
        if not success:
            if task.error is not None:
                report_error(self.tr('The simulation failed: {error}').format(
                    error=str(task.error)))
            elif not task.isCanceled():
                report_error(self.tr('The simulation failed for an unknown reason.'))
            return
        if task.result is not None:
            self._apply_simulation_result(task.result)

    def _apply_simulation_result(self, result):
        """Renders a _SimulationResult onto the page/settings dialog - the
        main-thread half of what run_simulation() used to do in one
        synchronous pass before it moved to a background task; see
        :meth:`_compute_simulation` for the other half."""
        for message in result.warnings:
            report_warning(message)
        if result.season is None:
            return  # weather failed to load - nothing else to render
        self._set_crop_label(result.auto_crop, result.override_crop, result.planting_date)
        self._render_details(result.results)
        self._render_season(result.season)
        self._render_legacy_irrigation_warning(result.legacy_warning_text)

        # Cached so the Crop model settings popup can recompute a live
        # preview from here without a fresh Open-Meteo/DB round trip.
        self._last_run = {
            'field_name': result.field_name, 'weather': result.weather,
            'clay_pct': result.clay_pct, 'organic_matter_pct': result.organic_matter_pct,
            'crop_for_model': result.crop_for_model,
            'irrigation_by_date': result.irrigation_by_date,
            'fertilizer_kg_n_by_date': result.fertilizer_kg_n_by_date,
            'fertilizer_kg_k_by_date': result.fertilizer_kg_k_by_date,
            'phosphorus_applied_kg_ha': result.phosphorus_applied_kg_ha,
            'magnesium_applied_kg_ha': result.magnesium_applied_kg_ha,
            'spacing_mm': result.spacing_mm,
            # Planting-date anchor/forced-stop override actually used this
            # run - carried into the popup's live preview so it keeps
            # matching the main page instead of silently reverting to
            # date_from-anchored timing on every settings tweak.
            'planting_date': result.planting_date,
            'growth_stop_date': result.growth_stop_date,
            # Varieties actually seen in this run's cells - populates the
            # Crop model settings popup's variety picker (see
            # _populate_settings_dialog).
            'varieties': sorted(set(result.cell_varieties.values())),
        }
        self._cell_polygons = result.cell_polygons
        self._cell_traces = result.cell_traces
        self._cell_varieties = result.cell_varieties
        self._trace_dates = result.trace_dates
        self._setup_slider()

    def _set_running_state(self, running):
        """Disables this tab's inputs and shows/hides the busy spinner
        while a _RunSimulationTask is in flight - see that class's
        docstring for why the disabled controls matter for more than just
        visual feedback."""
        for widget in (self.page.PBRun, self.page.CBField, self.page.CBCrop,
                      self.page.DEFrom, self.page.DETo, self.page.PBAddPlanned,
                      self.page.PBRemovePlanned, self.page.CBGrowthStopEnabled):
            widget.setEnabled(not running)
        # DEGrowthStop's own enabled state otherwise tracks
        # CBGrowthStopEnabled's checked state (see crop_simulation_page.py) -
        # re-enabling it unconditionally here would leave it editable even
        # while its checkbox is unticked. CBCropOther similarly tracks
        # whether CBCrop is currently on the "Other" sentinel (see
        # _toggle_crop_other_field).
        self.page.DEGrowthStop.setEnabled(
            not running and self.page.CBGrowthStopEnabled.isChecked())
        self.page.CBCropOther.setEnabled(
            not running and self.page.CBCropOther.isVisible())
        if running:
            self.page.LStatus.setText(self.tr('Running simulation…'))
            self._show_simulation_spinner()
        else:
            self._hide_simulation_spinner()

    def _show_simulation_spinner(self):
        """Shows the busy spinner in the exact slot the heatmap canvas
        occupies (see widgets/crop_simulation_page.py's mplvl/spinner) -
        juggled in and out of that layout the same way _render_heatmap
        already juggles the canvas itself."""
        if self.canvas is not None:
            self.page.mplvl.removeWidget(self.canvas)
            # hide() before setParent(None), not after - a widget that
            # was visible a moment ago (this one was, it's the live
            # heatmap) stays visible once detached from its parent unless
            # explicitly hidden first, and a visible *parentless* widget
            # is exactly what Qt renders as its own top-level window -
            # the stray popup this was doing on every slider drag.
            self.canvas.hide()
            self.canvas.setParent(None)
            self.canvas = None
        self.page.mplvl.addWidget(self.page.spinner, 0, Qt.AlignmentFlag.AlignCenter)
        self.page.spinner.start()

    def _hide_simulation_spinner(self):
        self.page.spinner.stop()
        self.page.mplvl.removeWidget(self.page.spinner)
        self.page.spinner.hide()
        self.page.spinner.setParent(None)

    @staticmethod
    def _build_fertilizer_kg_n_by_date(events):
        """{date_str: total_kg_n_ha} from a list of FertilizerEvent - the
        nitrogen-balance counterpart to _load_irrigation, built from
        whatever numeric rate could be parsed from each event (see
        FertilizerEvent.rate_kg_n_ha; events with no parseable rate simply
        don't contribute)."""
        totals = {}
        for event in events:
            rate = event.rate_kg_n_ha
            if rate is None:
                continue
            totals[event.date] = totals.get(event.date, 0.0) + rate
        return totals

    @staticmethod
    def _build_fertilizer_kg_k_by_date(events):
        """{date_str: total_kg_k_ha} - the potassium counterpart to
        _build_fertilizer_kg_n_by_date (see FertilizerEvent.rate_kg_k_ha),
        since potassium gets the same day-by-day leaching treatment as
        nitrogen; unlike N, events with no parseable K rate just don't
        contribute (most events have no K figure at all)."""
        totals = {}
        for event in events:
            rate = event.rate_kg_k_ha
            if rate is None:
                continue
            totals[event.date] = totals.get(event.date, 0.0) + rate
        return totals

    @staticmethod
    def _sum_nutrient_applied(events, rate_attr):
        """Season-total kg/ha for a nutrient that season_water_model only
        tracks as a single lump sum rather than a day-by-day balance
        (phosphorus/magnesium - see estimate_season's
        phosphorus_applied_kg_ha/magnesium_applied_kg_ha). Returns None if
        no event carries a parseable rate for it, so the caller can tell
        "not modelled" apart from "modelled, zero applied" the same way
        _build_fertilizer_kg_n_by_date's dicts do for nitrogen/potassium."""
        total = None
        for event in events:
            rate = getattr(event, rate_attr)
            if rate is None:
                continue
            total = (total or 0.0) + rate
        return total

    def _set_crop_label(self, auto_crop, override_crop, planting_date=None):
        if auto_crop:
            if planting_date:
                self.page.LCrop.setText(self.tr(
                    'Crop: {} (planted {}, from the planting record)'
                ).format(auto_crop, planting_date))
            else:
                self.page.LCrop.setText(
                    self.tr('Crop: {} (from the planting record)').format(auto_crop))
        elif override_crop:
            self.page.LCrop.setText(
                self.tr('Crop: {} (set manually)').format(override_crop))
        else:
            self.page.LCrop.setText(self.tr(
                'Crop: not set - using the default crop profile'))

    # ------------------------------------------------------------------
    # Crop model settings popup
    # ------------------------------------------------------------------

    def open_crop_settings(self):
        """Opens the Crop model settings popup for whichever crop the last
        run used (or the crop override combo, if no run has happened yet).
        See widgets/crop_settings_dialog.py and
        support_scripts/crop_model_settings.py."""
        crop_name = (self._last_run['crop_for_model'] if self._last_run
                    else self._current_crop_override())
        if not crop_name:
            report_warning(self.tr(
                'Run a simulation first, or pick a crop in "Use crop" above, '
                'so there\'s something to configure settings for.'))
            return
        self._populate_settings_dialog(crop_name)
        self.settings_dlg.show()
        self.settings_dlg.exec()

    def _populate_settings_dialog(self, crop_name):
        """Opens the popup for ``crop_name``: fills the variety picker
        from the last run's per-cell data (:attr:`_last_run`'s
        ``'varieties'`` - see :meth:`_apply_simulation_result`) and loads that
        crop's own (no-variety) settings, since a crop-level row is what
        applies to any cell without a more specific variety of its own."""
        self._settings_crop_name = crop_name
        dlg = self.settings_dlg
        varieties = self._last_run.get('varieties', []) if self._last_run else []
        dlg.CBVariety.blockSignals(True)
        dlg.CBVariety.clear()
        dlg.CBVariety.addItem(self.tr('(crop default - no variety)'))
        for variety in varieties:
            dlg.CBVariety.addItem(variety)
        dlg.CBVariety.setCurrentIndex(0)
        dlg.CBVariety.blockSignals(False)
        self._load_settings_for_current_variety()

    def _load_settings_for_current_variety(self):
        """(Re)loads the spin boxes and heading text for whichever crop/
        variety combination is currently selected in the popup - called
        once by :meth:`_populate_settings_dialog` and again every time
        CBVariety's selection changes."""
        dlg = self.settings_dlg
        crop_name = self._settings_crop_name
        variety = dlg.CBVariety.currentText().strip() if dlg.CBVariety.currentIndex() > 0 else ''
        self._settings_variety = variety
        model = crop_model_settings.effective_crop_model(
            self.db, crop_name, variety=variety or None)
        if variety:
            dlg.LCropName.setText(
                self.tr('Crop: {crop} - variety: {variety}').format(
                    crop=crop_name, variety=variety))
            dlg.LModelHeading.setText(self.tr(
                '<b>Crop model</b> - saved for "{crop}" variety "{variety}", used '
                'for every field/run using that variety from now on. A field '
                'left unchanged here is inherited from "{crop}"\'s own settings.'
            ).format(crop=crop_name, variety=variety))
        else:
            dlg.LCropName.setText(self.tr('Crop: {}').format(crop_name))
            dlg.LModelHeading.setText(self.tr(
                '<b>Crop model</b> - saved for this crop name, used for every '
                'field/run from now on:'))
        model_spin_boxes = [getattr(dlg, attr) for attr, _field in _MODEL_SPIN_FIELDS]
        soil_spin_boxes = [dlg.SBClay, dlg.SBOrganicMatter, dlg.SBSpacing]
        for spin_box in model_spin_boxes + soil_spin_boxes:
            spin_box.blockSignals(True)
        for attr, field_name in _MODEL_SPIN_FIELDS:
            getattr(dlg, attr).setValue(getattr(model, field_name))
        if self._last_run:
            dlg.SBClay.setValue(self._last_run['clay_pct'] or 0.0)
            dlg.SBOrganicMatter.setValue(self._last_run['organic_matter_pct'] or 0.0)
            dlg.SBSpacing.setValue(self._last_run.get('spacing_mm') or 0.0)
        for spin_box in model_spin_boxes + soil_spin_boxes:
            spin_box.blockSignals(False)
        # Read back (not just getattr(model, field)) so the baseline
        # _save_crop_settings diffs against has already been through each
        # spin box's own rounding (setDecimals varies per field, e.g.
        # SBNUptakeSteepness's 4 vs SBSeasonEndGdd's 0) - comparing against
        # the raw unrounded model value would flag an untouched field as
        # "changed" purely from that rounding and over-save it.
        self._settings_starting_values = {
            attr: getattr(dlg, attr).value() for attr, _field in _MODEL_SPIN_FIELDS}
        dlg.LSettingsStatus.setText('')
        self._recompute_settings_preview()

    def _dialog_crop_model(self):
        """The CropModel the settings dialog's current spin-box values
        describe - the base model for whichever crop/variety this popup
        is currently open for, with the dialog's editable "Crop model"
        fields (both the always-visible ones and the "Advanced" curve-
        shape ones) applied on top (not yet saved)."""
        base = crop_model_settings.effective_crop_model(
            self.db, self._settings_crop_name, variety=self._settings_variety or None)
        dlg = self.settings_dlg
        overrides = {field: getattr(dlg, attr).value() for attr, field in _MODEL_SPIN_FIELDS}
        return dataclasses_replace(base, **overrides)

    def _recompute_settings_preview(self):
        """Recomputes the season estimate using the settings dialog's
        current (possibly unsaved) values and the last run's cached
        weather/events - no network/DB round trip, so this is cheap enough
        to run on every spin-box change for a genuinely live preview.

        This always previews against the field-wide weather/irrigation/
        fertilizer of the last run, even for a variety - a "what if every
        cell used this variety's settings" sandbox, the same spirit as
        the "This run's soil" what-if section below it."""
        dlg = self.settings_dlg
        crop_name = self._settings_crop_name
        if not crop_name:
            dlg.TEResults.setPlainText(self.tr('No crop selected.'))
            return
        model = self._dialog_crop_model()
        # The curve chart is a pure function of model - draw it regardless
        # of whether a run has happened yet to preview the text below.
        self._render_curve_chart(model)
        if not self._last_run:
            dlg.TEResults.setPlainText(self.tr(
                'Run a simulation on the main page first to see a live estimate '
                'here - these settings will apply to it once you do.'))
            return
        season = estimate_season(
            self._last_run['weather'], crop_name,
            dlg.SBClay.value(), dlg.SBOrganicMatter.value(),
            self._last_run['irrigation_by_date'],
            fertilizer_kg_n_by_date=self._last_run['fertilizer_kg_n_by_date'],
            fertilizer_kg_k_by_date=self._last_run['fertilizer_kg_k_by_date'],
            phosphorus_applied_kg_ha=self._last_run['phosphorus_applied_kg_ha'],
            magnesium_applied_kg_ha=self._last_run['magnesium_applied_kg_ha'],
            crop_model=model, spacing_mm=dlg.SBSpacing.value(),
            planting_date=self._last_run.get('planting_date'),
            harvest_date=self._last_run.get('growth_stop_date'))
        dlg.TEResults.setPlainText(self._season_full_text(season))

    def _render_curve_chart(self, model):
        """Draws ``model``'s water-demand (Kc) and nitrogen/potassium-uptake
        curves in the settings popup's "Advanced" section (see
        widgets/crop_settings_dialog.py's ``curve_chart_widget``) - a pure
        function of the model, so it updates on every spin-box change with
        no DB/network round trip, the same as :meth:`_recompute_settings_preview`.

        The primary (bottom) x-axis is cumulative growing-degree-days (GDD)
        - what the model actually runs on, and the only axis available
        with no run yet (this is called on every spin-box change,
        regardless). When a run's weather is cached (:attr:`_last_run`),
        a second (top) x-axis is added showing the calendar date each GDD
        tick actually fell on in that run - see
        :meth:`_gdd_ticks_to_dates`. This is deliberately not a generic/
        universal calendar mapping: the same GDD threshold lands on a
        different date depending on the actual weather that year, which is
        the whole point of driving the model by GDD rather than the
        calendar in the first place (see widgets/crop_simulation_page.py's
        "Crop duration" ABOUT_TEXT section) - so it's explicitly labelled
        as coming from that one run, not treated as a fixed axis.

        A bare Figure(), not pyplot.subplots() - see _render_heatmap's
        comment for why: pyplot's global figure registry both leaks memory
        and can pop up a stray native window when a figure is rebuilt
        repeatedly, which this is (on every spin-box change)."""
        dlg = self.settings_dlg
        fig = Figure(figsize=(5.0, 3.0))
        ax = fig.add_subplot(111)
        span = max(1.0, model.season_end_gdd)
        gdd_values = [span * i / 200.0 for i in range(201)]
        kc_values = [crop_models.crop_coefficient(model, g) for g in gdd_values]
        n_values = [crop_models.n_uptake_fraction(model, g) for g in gdd_values]
        k_values = [crop_models.k_uptake_fraction(model, g) for g in gdd_values]
        ax.plot(gdd_values, kc_values, color='#2b6cb0', label=self.tr('Water demand (Kc)'))
        ax.set_xlabel(self.tr('Cumulative GDD'))
        ax.set_ylabel(self.tr('Kc'), color='#2b6cb0')
        ax.tick_params(axis='y', labelcolor='#2b6cb0')
        ax.set_xlim(0.0, span)
        ax2 = ax.twinx()
        ax2.plot(gdd_values, n_values, color='#2f855a', label=self.tr('N uptake (cumulative)'))
        ax2.plot(gdd_values, k_values, color='#805ad5',
                label=self.tr('K uptake (cumulative)'))
        ax2.set_ylabel(self.tr('Nutrient uptake fraction'), color='#2f855a')
        ax2.set_ylim(0.0, 1.05)
        ax2.tick_params(axis='y', labelcolor='#2f855a')
        lines1, labels1 = ax.get_legend_handles_labels()
        lines2, labels2 = ax2.get_legend_handles_labels()
        ax.legend(lines1 + lines2, labels1 + labels2, loc='lower right', fontsize='small')

        weather = self._last_run['weather'] if self._last_run else None
        if weather:
            start_date = (self._last_run.get('planting_date')
                          or min(w.date for w in weather))
            # get_xticks() can hand back "nice round number" ticks that
            # overshoot the axis's actual [0, span] range (e.g. a tick at
            # -100 or one past season_end_gdd) - meaningless as a calendar
            # date, so drop those before mapping.
            gdd_ticks = [t for t in ax.get_xticks() if 0.0 <= t <= span]
            tick_dates = self._gdd_ticks_to_dates(
                weather, model.gdd_base_c, start_date, gdd_ticks)
            if any(tick_dates):
                ax_dates = ax.twiny()
                ax_dates.set_xlim(ax.get_xlim())
                ax_dates.set_xticks(gdd_ticks)
                ax_dates.set_xticklabels(
                    [d[5:] if d else '' for d in tick_dates],
                    rotation=45, fontsize=7, ha='left')
                ax_dates.set_xlabel(
                    self.tr('Calendar date (MM-DD) in the last run\'s weather, '
                            'from {start}').format(start=start_date), fontsize=8)
        fig.tight_layout()
        if dlg.curve_canvas is not None:
            dlg.curve_chart_layout.removeWidget(dlg.curve_canvas)
            dlg.curve_canvas.hide()  # see _show_simulation_spinner's comment
            dlg.curve_canvas.setParent(None)
        dlg.curve_canvas = FigureCanvas(fig)
        dlg.curve_canvas.setWindowFlags(Qt.WindowType.Widget)
        dlg.curve_chart_layout.addWidget(dlg.curve_canvas)
        dlg.curve_canvas.draw()

    @staticmethod
    def _gdd_ticks_to_dates(weather, gdd_base_c, start_date, gdd_ticks):
        """The calendar date (``YYYY-MM-DD``) each value in ``gdd_ticks``
        was first reached by, accumulating growing-degree-days (GDD) day
        by day from ``start_date`` through ``weather`` - the same
        accumulation season_water_model._daily_water_balance itself does
        (same :func:`crop_models.growing_degree_days` call, oldest day
        first), so what :meth:`_render_curve_chart` labels the calendar
        axis with matches what a real run against this weather would
        actually do. A tick beyond what ``weather`` covers (season runs
        longer than the analysed/fetched period) gets ``None``.

        Returns
        -------
        list
            Same length/order as ``gdd_ticks``.
        """
        daily = sorted(
            (w for w in weather if w.date >= start_date and w.temp_mean_c is not None),
            key=lambda w: w.date)
        order = sorted(range(len(gdd_ticks)), key=lambda i: gdd_ticks[i])
        dates = [None] * len(gdd_ticks)
        cumulative = 0.0
        pos = 0
        for w in daily:
            cumulative += crop_models.growing_degree_days(w.temp_mean_c, gdd_base_c)
            while pos < len(order) and cumulative >= gdd_ticks[order[pos]]:
                dates[order[pos]] = w.date
                pos += 1
            if pos >= len(order):
                break
        return dates

    def _save_crop_settings(self):
        dlg = self.settings_dlg
        crop_name = self._settings_crop_name
        if not crop_name:
            return
        variety = self._settings_variety
        # Only what's actually different from the dialog's starting values
        # (see _load_settings_for_current_variety) - not every field the
        # dialog has, since a variety with nothing of its own touched
        # should keep inheriting its crop's settings (see
        # crop_model_settings.effective_crop_model), not have all 20
        # fields frozen in at whatever the crop's current defaults happen
        # to be right now.
        overrides = {
            field: getattr(dlg, attr).value() for attr, field in _MODEL_SPIN_FIELDS
            if getattr(dlg, attr).value() != self._settings_starting_values.get(attr)}
        try:
            crop_model_settings.save_overrides(self.db, crop_name, variety, **overrides)
        except ValueError as e:
            # A curve-shape combination crop_models.validate_shape rejected
            # (e.g. stages out of order) - the user's fault, not a bug, so
            # this is a warning, not report_error, and nothing was saved.
            report_warning(self.tr(
                'Could not save - {error} Fix the "Advanced" values above '
                'and try again.').format(error=str(e)))
            return
        if variety:
            dlg.LSettingsStatus.setText(self.tr(
                'Saved - every field using "{crop}" variety "{variety}" will use '
                'these settings from now on. Re-run the simulation to see it '
                'reflected in the stress map.').format(crop=crop_name, variety=variety))
            report_success(self.tr('Crop model settings saved for {crop} / {variety}.').format(
                crop=crop_name, variety=variety))
        else:
            dlg.LSettingsStatus.setText(self.tr(
                'Saved - every field using "{crop}" will use these settings from '
                'now on.').format(crop=crop_name))
            report_success(self.tr('Crop model settings saved for {crop}.').format(crop=crop_name))
        if self._last_run and self._last_run['crop_for_model'] == crop_name and not variety:
            # Reflect the newly-saved crop-level settings in the main
            # page's summary without needing a full "Run simulation"
            # click again. A variety-level save never changes that
            # field-wide number - the season estimate stays single-crop/
            # field-wide by design (see module docstring); only the
            # per-cell stress map is variety-aware.
            season = estimate_season(
                self._last_run['weather'], crop_name,
                self._last_run['clay_pct'], self._last_run['organic_matter_pct'],
                self._last_run['irrigation_by_date'],
                fertilizer_kg_n_by_date=self._last_run['fertilizer_kg_n_by_date'],
                fertilizer_kg_k_by_date=self._last_run['fertilizer_kg_k_by_date'],
                phosphorus_applied_kg_ha=self._last_run['phosphorus_applied_kg_ha'],
                magnesium_applied_kg_ha=self._last_run['magnesium_applied_kg_ha'],
                crop_model=self._dialog_crop_model(),
                spacing_mm=self._last_run.get('spacing_mm'),
                planting_date=self._last_run.get('planting_date'),
                harvest_date=self._last_run.get('growth_stop_date'))
            self._render_season(season)

    def _reset_crop_settings(self):
        crop_name = self._settings_crop_name
        if not crop_name:
            return
        variety = self._settings_variety
        crop_model_settings.reset_overrides(self.db, crop_name, variety)
        # Reload in place (keeps the variety picker's current selection),
        # rather than _populate_settings_dialog which would rebuild the
        # picker and jump back to "crop default".
        self._load_settings_for_current_variety()
        if variety:
            self.settings_dlg.LSettingsStatus.setText(self.tr(
                'Reset - "{crop}" variety "{variety}" now inherits "{crop}"\'s '
                'own settings.').format(crop=crop_name, variety=variety))
            report_success(self.tr('Crop model settings for {crop} / {variety} reset.').format(
                crop=crop_name, variety=variety))
        else:
            self.settings_dlg.LSettingsStatus.setText(
                self.tr('Reset to the built-in default for {crop}.').format(crop=crop_name))
            report_success(self.tr('Crop model settings for {crop} reset to default.').format(
                crop=crop_name))

    # ------------------------------------------------------------------
    # Field-wide data loading (fertilizer/soil/crop/weather/irrigation)
    # ------------------------------------------------------------------

    def _load_irrigation(self, field_name, date_from, date_to):
        """Actually-logged, dated irrigation for the field/period, as
        ``{date_str: total_mm}`` - see import_data/handle_irrigation.py's
        ``_store_dated_operation``, which writes one
        ``weather.<field>_irrigation_events_<year>`` table per year from
        each Raindancer operation's own real date and flight-path geometry.

        This field-wide total is what the season summary uses; the
        per-cell stress map instead matches each row to whichever cells
        its own geometry actually covers - see
        :meth:`_resolve_irrigation_by_cell`. See
        :meth:`_render_legacy_irrigation_warning` for the older, spatially
        resolved but undated grid this can't use instead."""
        totals = {}
        for year in range(int(date_from[:4]), int(date_to[:4]) + 1):
            table = check_text('{}_irrigation_events_{}'.format(field_name, year))
            if not self.db.check_table_exists(table, 'weather', False):
                continue
            rows = db_rows(self.db.execute_and_return(
                pgsql.SQL(
                    "SELECT date_, irrigation_mm FROM weather.{tbl}"
                    " WHERE date_ >= %s AND date_ <= %s"
                ).format(tbl=pgsql.Identifier(table)),
                params=(date_from, date_to)))
            for date_value, mm in rows:
                if mm is None:
                    continue
                date_str = self._as_date_str(date_value)
                totals[date_str] = totals.get(date_str, 0.0) + float(mm)
        return totals

    def _compute_legacy_irrigation_warning(self, field_name, date_from, date_to,
                                           irrigation_by_date):
        """If the dated irrigation table came back empty, checks whether
        the field has data in the older, undated whole-farm grid
        (``weather.irrigation_<year>``, from "Create irrigation year" on
        the Irrigation card) for the same years - that data has real
        spatial resolution but no date, so it can't be placed on this
        simulation's calendar at all. Returns the warning text to show
        (see :meth:`_render_legacy_irrigation_warning`) telling the user
        how to get it in: re-running "Add from raindancer" logs each
        operation's own real date and flight path into the dated table
        this simulation reads (see import_data/handle_irrigation.py's
        ``_store_dated_operation``) - that's the only source of dated
        irrigation there is (no manual whole-field entry - see that
        module's docstring for why), so this only still applies to grid
        data that predates a re-fetch. Pure DB work, no widget access -
        safe to call from :meth:`_compute_simulation`'s background thread."""
        if irrigation_by_date:
            return ''
        for year in range(int(date_from[:4]), int(date_to[:4]) + 1):
            table = 'irrigation_{}'.format(year)
            if not self.db.check_table_exists(table, 'weather', False):
                continue
            rows = db_rows(self.db.execute_and_return(
                pgsql.SQL("SELECT sum(irrigation_mm) FROM weather.{tbl}")
                .format(tbl=pgsql.Identifier(table))))
            total = (rows[0][0] or 0.0) if rows and rows[0][0] is not None else 0.0
            if total > 0:
                return self.tr(
                    'This field has irrigation data in the old undated grid '
                    'for {year}, but it has no date so it can\'t be used '
                    'here - re-run "Add from raindancer" on the Irrigation '
                    'card for this period to log each operation with its '
                    'own real date.'
                ).format(year=year)
        return ''

    def _render_legacy_irrigation_warning(self, warning_text):
        """Sets the legacy-irrigation warning label to ``warning_text``
        (see :meth:`_compute_legacy_irrigation_warning`) - the widget-
        touching half, kept separate so the DB-checking half can safely
        run on a background thread (see :meth:`_compute_simulation`)."""
        self.page.LLegacyIrrigationWarning.setText(warning_text)

    def _load_crop(self, field_name, date_to):
        """The most recently planted crop on/before ``date_to``, and its
        logged planting date, from ``plant.manual`` - the crop is shown as
        context and used as the field-wide fallback for cells with no
        imported planting data of their own (see
        :meth:`_resolve_crop_by_cell`); the date lets the season model
        anchor its growing-degree-day (GDD) clock to when the crop was
        actually planted instead of just the "From" date picked for the
        run (see :meth:`_compute_simulation`).

        Returns
        -------
        (crop_or_None, planting_date_or_None)
        """
        rows = db_rows(self.db.execute_and_return(
            "SELECT crop, date_ FROM plant.manual WHERE field = %s"
            " AND crop IS NOT NULL AND crop != '' AND date_ <= %s"
            " ORDER BY date_ DESC LIMIT 1", params=(field_name, date_to)))
        if not rows or not rows[0][0]:
            return None, None
        crop, planting_date = rows[0]
        return crop, (self._as_date_str(planting_date) if planting_date else None)

    @staticmethod
    def _as_date_str(value):
        """Normalises a DB date/datetime value to a plain ``YYYY-MM-DD``
        string. Imported ferti/soil/plant tables store ``date_`` as
        TIMESTAMP (see import_data/handle_text_data.py), unlike
        ``.manual``'s plain DATE column - datetime.isoformat() would
        otherwise leave a 'T00:00:00' suffix that breaks every
        exact-string date match in fertilizer_timing_model.py."""
        if hasattr(value, 'date') and callable(value.date):
            return value.date().isoformat()
        if hasattr(value, 'isoformat'):
            return value.isoformat()
        return str(value)[:10]

    def _load_events(self, field_name, date_from, date_to):
        """Fertilizer events from both ``ferti.manual`` (has a plain
        ``field`` column) and imported ``ferti.*`` tables (matched to the
        field spatially instead - see :meth:`_candidate_tables`)."""
        rows = db_rows(self.db.execute_and_return(
            "SELECT date_, rate, crop FROM ferti.manual WHERE field = %s"
            " AND date_ >= %s AND date_ <= %s ORDER BY date_",
            params=(field_name, date_from, date_to)))
        events = [
            FertilizerEvent(date=self._as_date_str(row[0]), rate_text=row[1] or '',
                            crop=row[2] or '')
            for row in rows]
        for table in self._candidate_tables('ferti', field_name):
            events.extend(self._load_imported_ferti_events(table, date_from, date_to))
        events.sort(key=lambda e: e.date)
        return events

    def _load_imported_ferti_events(self, table, date_from, date_to):
        """One :class:`FertilizerEvent` per *day* found in an imported ferti
        table, not one per row - a single day's spreading pass can log
        hundreds of GPS-referenced rows (see
        import_data/handle_text_data.py's ``create_table``); the day's rate
        is the mean of its (parseable) rows."""
        rate_col = self._find_column('ferti', table, ('rate',))
        crop_col = self._find_column('ferti', table, ('crop',))
        select = pgsql.SQL(', ').join([
            pgsql.Identifier(rate_col) if rate_col else pgsql.SQL('NULL'),
            pgsql.Identifier(crop_col) if crop_col else pgsql.SQL('NULL')])
        query = pgsql.SQL(
            "SELECT date_, {select} FROM ferti.{tbl}"
            " WHERE date_ >= %s AND date_ <= %s"
        ).format(select=select, tbl=pgsql.Identifier(table))
        rows = db_rows(self.db.execute_and_return(query, params=(date_from, date_to)))
        by_date = {}
        for date_value, rate_value, crop_value in rows:
            date_str = self._as_date_str(date_value)
            day = by_date.setdefault(date_str, {'rates': [], 'crop': ''})
            if rate_value is not None:
                try:
                    day['rates'].append(float(rate_value))
                except (TypeError, ValueError):
                    pass
            if not day['crop'] and crop_value:
                day['crop'] = str(crop_value)
        events = []
        for date_str, day in by_date.items():
            rate_text = ('{:.1f}'.format(sum(day['rates']) / len(day['rates']))
                        if day['rates'] else '')
            events.append(FertilizerEvent(date=date_str, rate_text=rate_text,
                                          crop=day['crop']))
        return events

    def _candidate_tables(self, schema, field_name):
        """Tables in ``schema`` (excluding ``manual``) whose ``polygon``
        column overlaps the field - the only way to associate an imported
        ferti/soil/plant table with a field, since (unlike ``.manual``)
        those tables don't have a plain ``field`` text column."""
        rows = db_rows(self.db.execute_and_return(
            "SELECT DISTINCT table_name FROM information_schema.columns"
            " WHERE table_schema = %s AND table_name != 'manual'"
            " AND column_name = 'polygon'", params=(schema,)))
        matched = []
        for (table,) in rows:
            overlap = db_rows(self.db.execute_and_return(
                pgsql.SQL(
                    "SELECT EXISTS (SELECT 1 FROM {schema}.{tbl} t, fields f"
                    " WHERE f.field_name = %s AND t.polygon IS NOT NULL"
                    " AND st_intersects(t.polygon, f.polygon))"
                ).format(schema=pgsql.Identifier(schema), tbl=pgsql.Identifier(table)),
                params=(field_name,)))
            if overlap and overlap[0][0]:
                matched.append(table)
        return matched

    def _find_column(self, schema, table, prefixes):
        """Best-effort column lookup: the first column of ``table`` whose
        name *contains* one of ``prefixes`` as a substring - not just a
        strict prefix. Text-import mapped columns get a unit suffix (e.g.
        ``rate`` -> ``rate_kg_ha``, keyword first), but ISO-XML imports
        name columns straight from a machine/DDOP's free-text designator
        (see import_data/handle_iso11783.py, via
        support_scripts/pyagriculture/agriculture.py), where the keyword
        can land anywhere - e.g. a variety column literally named
        ``potato_variety`` (keyword last) or a spacing column named
        ``set_planting_distance_mm`` (keyword in the middle, and using a
        different word entirely - see callers for the synonym lists this
        makes necessary). None if no such column exists."""
        rows = db_rows(self.db.execute_and_return(
            "SELECT column_name FROM information_schema.columns"
            " WHERE table_schema = %s AND table_name = %s", params=(schema, table)))
        columns = [row[0] for row in rows]
        for prefix in prefixes:
            for col in columns:
                if prefix in col:
                    return col
        return None

    def _load_weather(self, field_name, date_from, date_to):
        """Returns ``(weather, warning)`` - ``warning`` is a ready-to-show
        message (or ``None`` on success) rather than being shown directly
        here, since this also runs from :meth:`_compute_simulation` on a
        background QgsTask's worker thread (see :class:`_RunSimulationTask`),
        where touching the message bar directly isn't safe - only the
        caller, back on the main thread, may actually call report_warning
        with it (see :meth:`_apply_simulation_result`)."""
        rows = db_rows(self.db.execute_and_return(
            "SELECT st_x(st_centroid(polygon)), st_y(st_centroid(polygon))"
            " FROM fields WHERE field_name = %s", params=(field_name,)))
        if not rows:
            return [], self.tr('Could not find the location of that field.')
        longitude, latitude = float(rows[0][0]), float(rows[0][1])
        # Fetch past the "to" date so events near it still have enough
        # forward-looking weather for the advanced tier's water/N balance -
        # but Open-Meteo's historical archive can't return data beyond
        # today, so cap the request there.
        today_str = datetime.now().strftime('%Y-%m-%d')
        horizon_to = min(
            (datetime.strptime(date_to, '%Y-%m-%d')
             + timedelta(days=ADVANCED_HORIZON_DAYS)).strftime('%Y-%m-%d'),
            today_str)
        api_start = min(date_from, horizon_to)
        try:
            daily = self.weather_client.daily_weather(
                latitude, longitude, api_start, horizon_to)
        except OpenMeteoError as e:
            return [], self.tr(
                'Could not fetch weather for that field/period: {error}'
            ).format(error=str(e))
        if not daily:
            return [], self.tr(
                'Open-Meteo returned no weather data for that field/period.')
        weather = [DailyWeather(date=d['date'], precipitation_mm=d['precipitation_mm'],
                                et0_mm=d['et0_mm'], temp_mean_c=d['temp_mean_c'],
                                solar_radiation_mj_m2=d.get('solar_radiation_mj_m2'),
                                daylight_hours=d.get('daylight_hours'))
                   for d in daily]
        return weather, None

    def _load_soil(self, field_name):
        """Most recent usable clay%/organic-matter% for the field, from
        either ``soil.manual`` or an imported ``soil.*`` table matched
        spatially (see :meth:`_candidate_tables`) - also the field-wide
        fallback for cells with no imported soil sample of their own (see
        :meth:`_resolve_soil_by_cell`)."""
        candidates = list(db_rows(self.db.execute_and_return(
            "SELECT date_, clay, humus FROM soil.manual WHERE field = %s",
            params=(field_name,))))
        for table in self._candidate_tables('soil', field_name):
            clay_col = self._find_column('soil', table, ('clay',))
            humus_col = self._find_column('soil', table, ('humus',))
            if not clay_col:
                continue
            select = pgsql.SQL(', ').join([
                pgsql.Identifier(clay_col),
                pgsql.Identifier(humus_col) if humus_col else pgsql.SQL('NULL')])
            query = pgsql.SQL(
                "SELECT date_, {select} FROM soil.{tbl}"
            ).format(select=select, tbl=pgsql.Identifier(table))
            candidates.extend(db_rows(self.db.execute_and_return(query)))
        candidates.sort(key=lambda row: self._as_date_str(row[0]) if row[0] else '',
                        reverse=True)
        for _date, clay_text, humus_text in candidates:
            if clay_text is not None and isfloat(clay_text):
                clay = float(clay_text)
                humus = float(humus_text) if humus_text is not None and isfloat(humus_text) else None
                return clay, humus
        return None, None

    def _load_spacing(self, field_name):
        """Most recent usable in-row planting spacing (mm) for the field,
        from either ``plant.manual`` or an imported ``plant.*`` table
        matched spatially (see :meth:`_candidate_tables`) - mirrors
        :meth:`_load_soil`. Field-wide only, unlike crop/variety/soil,
        since spacing only ever feeds the single season-estimate number
        (see support_scripts/crop_models.spacing_yield_multiplier) rather
        than the per-cell stress map. None if nothing usable is on file,
        which that function treats the same as "not modelled": no effect
        on the yield ceiling."""
        candidates = list(db_rows(self.db.execute_and_return(
            "SELECT date_, spacing FROM plant.manual WHERE field = %s",
            params=(field_name,))))
        for table in self._candidate_tables('plant', field_name):
            # 'distance' too - a real ISO-XML DDOP designator seen in the
            # field was "Set Planting Distance mm", not "spacing" at all.
            spacing_col = self._find_column('plant', table, ('spacing', 'distance'))
            if not spacing_col:
                continue
            query = pgsql.SQL(
                "SELECT date_, {col} FROM plant.{tbl}"
            ).format(col=pgsql.Identifier(spacing_col), tbl=pgsql.Identifier(table))
            candidates.extend(db_rows(self.db.execute_and_return(query)))
        candidates.sort(key=lambda row: self._as_date_str(row[0]) if row[0] else '',
                        reverse=True)
        for _date, spacing_text in candidates:
            if spacing_text is not None and isfloat(spacing_text):
                return float(spacing_text)
        return None

    # ------------------------------------------------------------------
    # Per-cell stress map
    # ------------------------------------------------------------------

    def _compute_cell_traces(self, field_name, date_from, date_to, weather,
                             field_crop, field_clay, field_organic_matter, task=None,
                             planting_date=None, harvest_date=None):
        """Runs the per-cell day-by-day water balance that drives the date
        slider's stress map - see :func:`support_scripts.season_water_model.
        daily_trace`. Each cell uses its own crop/variety/soil where
        imported data covers it
        (:meth:`_resolve_crop_and_variety_by_cell`/:meth:`_resolve_soil_by_cell`)
        and its own logged irrigation (:meth:`_resolve_irrigation_by_cell`),
        falling back to the field-wide crop/soil reading where nothing more
        specific is on file (a cell simply not being irrigated is left as
        zero, not backfilled from the field-wide total - see that method's
        docstring). Weather is shared field-wide - it doesn't vary
        meaningfully within one field, so it's fetched once for the whole
        run (see :meth:`_load_weather`). Every cell's saved crop/variety
        settings overrides (support_scripts/crop_model_settings.py) are
        looked up explicitly here, so the stress map - like the season
        estimate - reflects them, instead of relying on crop_models.
        get_crop_model's name-matching fallback.

        A pure computation - no widget access, and it *returns* what it
        found rather than assigning to ``self._cell_*`` directly, so it's
        safe to call from a background QgsTask's worker thread (see
        :meth:`_compute_simulation`); ``field_name`` is always passed
        explicitly for the same reason (see
        :meth:`_resolve_crop_and_variety_by_cell`'s docstring). ``task``,
        if given, is checked periodically so a cancelled task can stop
        early instead of grinding through every remaining cell.
        ``planting_date``/``harvest_date`` (field-wide - see
        :meth:`_compute_simulation`) are passed straight through to every
        cell's own :func:`season_water_model.daily_trace` call, so the
        stress map's GDD clock stays consistent with the season summary's
        even though each cell can still use its own crop/variety/soil.

        Returns
        -------
        tuple
            ``(cell_polygons, cell_traces, cell_varieties, trace_dates)``
        """
        cells = field_grid.build_grid(self.db, field_name)
        cell_polygons = {c.cell_id: c.polygon_wkt for c in cells}
        cell_traces = {}
        cell_varieties = {}
        trace_dates = sorted({w.date for w in weather})
        if not cells:
            field_grid.drop_grid(self.db)
            return cell_polygons, cell_traces, cell_varieties, trace_dates
        crop_variety_by_cell = self._resolve_crop_and_variety_by_cell(field_name)
        soil_by_cell = self._resolve_soil_by_cell(field_name)
        irrigation_by_cell = self._resolve_irrigation_by_cell(
            field_name, date_from, date_to)
        # Most cells share a handful of (crop, variety) combinations (often
        # just one) - resolving settings is a DB round trip, so cache by
        # that pair instead of re-querying per cell (a large field can have
        # thousands - see support_scripts/field_grid.py's cell budget).
        model_cache = {}
        for cell in cells:
            if task is not None and task.isCanceled():
                break
            cell_crop, cell_variety = crop_variety_by_cell.get(cell.cell_id, (None, None))
            crop_name = cell_crop or field_crop
            clay, organic_matter = soil_by_cell.get(
                cell.cell_id, (field_clay, field_organic_matter))
            cell_irrigation = irrigation_by_cell.get(cell.cell_id, {})
            cache_key = (crop_name, cell_variety)
            cell_model = model_cache.get(cache_key)
            if cell_model is None:
                cell_model = crop_model_settings.effective_crop_model(
                    self.db, crop_name, variety=cell_variety)
                model_cache[cache_key] = cell_model
            trace = daily_trace(weather, crop_name, clay, organic_matter,
                                cell_irrigation, crop_model=cell_model,
                                planting_date=planting_date, harvest_date=harvest_date)
            cell_traces[cell.cell_id] = {p.date: p for p in trace}
            if cell_variety:
                cell_varieties[cell.cell_id] = cell_variety
        field_grid.drop_grid(self.db)
        return cell_polygons, cell_traces, cell_varieties, trace_dates

    def _resolve_irrigation_by_cell(self, field_name, date_from, date_to):
        """cell_id -> {date_str: total_mm}, matching each logged irrigation
        row (support_scripts/field_grid.py's grid must already be built -
        see :meth:`_compute_cell_traces`) to whichever cells its own real
        (buffered) flight-path geometry actually covers - see
        import_data/handle_irrigation.py's ``_store_dated_operation``. A
        pass that only crossed part of the field only contributes to the
        cells it actually passed over. A cell with nothing logged for it
        gets an empty dict - genuinely zero irrigation, not a field-wide
        fallback, since unlike crop/soil "not irrigated" is a normal,
        meaningful state for a cell to be in."""
        result = {}
        for year in range(int(date_from[:4]), int(date_to[:4]) + 1):
            table = check_text('{}_irrigation_events_{}'.format(field_name, year))
            if not self.db.check_table_exists(table, 'weather', False):
                continue
            for row in field_grid.join_grid_to_table(
                    self.db, 'weather', table, ['date_', 'irrigation_mm']):
                mm = row.get('irrigation_mm')
                date_value = row.get('date_')
                if mm is None or date_value is None:
                    continue
                date_str = self._as_date_str(date_value)
                if not (date_from <= date_str <= date_to):
                    continue
                cell_totals = result.setdefault(row['cell_id'], {})
                cell_totals[date_str] = cell_totals.get(date_str, 0.0) + float(mm)
        return result

    def _resolve_crop_and_variety_by_cell(self, field_name=None):
        """cell_id -> (crop_or_None, variety_or_None), the most recent per
        cell across every imported ``plant.*`` table matched to the
        field's current grid (:func:`field_grid.build_grid` must already
        have been called - see :meth:`_compute_cell_traces`).

        ``field_name`` defaults to the field combo's current value (for
        existing callers), but :meth:`_compute_cell_traces` always passes
        it explicitly - reading a Qt widget's value isn't safe from the
        background thread that method can run on (see
        :class:`_RunSimulationTask`).

        Crop and variety are tracked from separate columns and never
        conflated: a plant table naming only a variety (no ``crop``
        column - a common shape for a machine-logged planting pass, which
        knows what product it planted but not its botanical name, e.g. a
        "arsenal"/"fontane"/"solist" variety layer with no "potato"
        anywhere in it) must never let that variety name masquerade as
        the crop - see :meth:`_compute_cell_traces`'s fallback to the
        field-wide crop, and support_scripts/crop_model_settings.py's
        ``effective_crop_model`` for how crop- and variety-level settings
        combine. Cells with nothing imported are absent."""
        field_name = field_name or self._current_field_name()
        best = {}  # cell_id -> (date_str, crop_or_None, variety_or_None)
        for table in self._candidate_tables('plant', field_name):
            crop_col = self._find_column('plant', table, ('crop',))
            variety_col = self._find_column('plant', table, ('variety',))
            if variety_col == crop_col:
                variety_col = None  # one ambiguous column: treat as crop only
            columns = ['date_'] + [c for c in (crop_col, variety_col) if c]
            if len(columns) == 1:
                continue  # neither a crop nor a variety column on this table
            for row in field_grid.join_grid_to_table(self.db, 'plant', table, columns):
                crop_value = row.get(crop_col) if crop_col else None
                variety_value = row.get(variety_col) if variety_col else None
                if not crop_value and not variety_value:
                    continue
                date_str = self._as_date_str(row['date_']) if row['date_'] else ''
                prev = best.get(row['cell_id'])
                if prev is None or date_str >= prev[0]:
                    best[row['cell_id']] = (
                        date_str, str(crop_value) if crop_value else None,
                        str(variety_value) if variety_value else None)
        return {cid: (v[1], v[2]) for cid, v in best.items()}

    def _resolve_soil_by_cell(self, field_name=None):
        """cell_id -> (clay, organic_matter), the most recent per cell
        across every imported ``soil.*`` table matched to the field's
        current grid. Cells with nothing imported are absent; the caller
        falls back to the field-wide soil reading. ``field_name`` defaults
        to the field combo's current value - see
        :meth:`_resolve_crop_and_variety_by_cell`'s docstring for why
        :meth:`_compute_cell_traces` always passes it explicitly instead."""
        field_name = field_name or self._current_field_name()
        best = {}  # cell_id -> (date_str, clay, humus)
        for table in self._candidate_tables('soil', field_name):
            clay_col = self._find_column('soil', table, ('clay',))
            humus_col = self._find_column('soil', table, ('humus',))
            if not clay_col:
                continue
            columns = ['date_', clay_col] + ([humus_col] if humus_col else [])
            for row in field_grid.join_grid_to_table(self.db, 'soil', table, columns):
                clay_text = row.get(clay_col)
                if clay_text is None or not isfloat(clay_text):
                    continue
                humus_text = row.get(humus_col) if humus_col else None
                date_str = self._as_date_str(row['date_']) if row['date_'] else ''
                prev = best.get(row['cell_id'])
                if prev is None or date_str >= prev[0]:
                    humus = (float(humus_text)
                            if humus_text is not None and isfloat(humus_text) else None)
                    best[row['cell_id']] = (date_str, float(clay_text), humus)
        return {cid: (v[1], v[2]) for cid, v in best.items()}

    def _current_field_name(self):
        return self.page.CBField.currentText()

    def _setup_slider(self):
        n = len(self._trace_dates)
        self.page.SLDate.blockSignals(True)
        self.page.SLDate.setEnabled(n > 0)
        self.page.SLDate.setMinimum(0)
        self.page.SLDate.setMaximum(max(0, n - 1))
        self.page.SLDate.setValue(max(0, n - 1))  # default: the latest date
        self.page.SLDate.blockSignals(False)
        self.page.LSliderDate.setText(self._trace_dates[-1] if n else '-')
        self._render_heatmap(self._trace_dates[-1] if n else None)

    def _on_slider_changed(self, index):
        if not self._trace_dates or not (0 <= index < len(self._trace_dates)):
            return
        date_str = self._trace_dates[index]
        self.page.LSliderDate.setText(date_str)
        self._render_heatmap(date_str)

    def _render_heatmap(self, date_str):
        # A bare Figure(), not pyplot's plt.subplots(): pyplot keeps every
        # figure it creates registered in its own global state until
        # explicitly closed, which - for a figure rebuilt on every slider
        # tick - both leaks memory and can make a stray native window pop
        # up outside the embedded canvas (matplotlib's Qt/pyplot
        # interaction, not something this code controls once a figure is
        # pyplot-tracked). A plain Figure never enters that registry.
        fig = Figure()
        ax = fig.add_subplot(111)
        if date_str is not None and self._cell_polygons:
            cmap = mpl.colormaps['RdYlGn']
            patches, colors = [], []
            for cell_id, polygon_wkt in self._cell_polygons.items():
                point = self._cell_traces.get(cell_id, {}).get(date_str)
                geom = shapely_wkt.loads(polygon_wkt)
                patches.append(MplPolygon(list(geom.exterior.coords), closed=True))
                colors.append(cmap(point.wetness_fraction) if point is not None
                              else (0.6, 0.6, 0.6, 1.0))
            ax.add_collection(PatchCollection(patches, facecolor=colors, edgecolor='none'))
            ax.autoscale_view()
            ax.set_title(self.tr('Field water stress on {}').format(date_str))
        else:
            ax.text(0.5, 0.5, self.tr('Run a simulation to see the field stress map.'),
                    ha='center', va='center', transform=ax.transAxes)
        ax.set_aspect('equal')
        ax.set_xticks([])
        ax.set_yticks([])
        if self.canvas is not None:
            self.page.mplvl.removeWidget(self.canvas)
            # The actual cause of the stray popup on every slider drag:
            # this canvas was visible a moment ago (it's the previous
            # day's heatmap), and setParent(None) alone doesn't hide a
            # widget that was already visible - it just detaches it,
            # leaving Qt to render it as its own top-level window since
            # it's now both visible and parentless. hide() first closes
            # that gap. See _show_simulation_spinner's matching comment.
            self.canvas.hide()
            self.canvas.setParent(None)
        self.canvas = FigureCanvas(fig)
        # Belt-and-braces against the same stray-window risk _plt.ioff()
        # (module top) targets from a different angle: force this to
        # always be treated as a plain child widget, never its own
        # top-level window, regardless of what triggers it.
        self.canvas.setWindowFlags(Qt.WindowType.Widget)
        self.page.mplvl.addWidget(self.canvas)
        self.canvas.draw()

    # ------------------------------------------------------------------
    # Field-wide rendering (per-application detail, season estimate)
    # ------------------------------------------------------------------

    def _render_details(self, results):
        self.page.TEDetails.setPlainText('\n\n'.join(
            '{date} [{tier} model - {crop}, {risk} risk]: {detail}{rate_notes}'.format(
                date=r.date, tier=r.tier, crop=r.crop_model, risk=r.risk,
                detail=r.detail, rate_notes=self._event_result_rate_notes(r))
            for r in results))
        self.page.LStatus.setText(summarize(results))

    def _event_result_rate_notes(self, result):
        """N's note always shows (a rate_kg_n_ha of None is always
        meaningful - see _rate_parse_note - regardless of source), but
        P/K/Mg only show a *positive* confirmation when a rate was
        actually parsed. EventResult (unlike the FertilizerEvent
        add_planned_event works from) doesn't carry the raw rate_text, so
        None here can't be told apart from "no rate given for this
        nutrient at all" (true for essentially every logged/imported
        event today, since none of the ferti.manual/imported tables have
        P/K/Mg columns wired up yet) - staying silent for those three
        avoids flagging every ordinary N-only application as if its P/K/Mg
        were typoed."""
        notes = self._rate_parse_note('N', result.rate_kg_n_ha,
            self.tr(' - won\'t count toward the nitrogen balance, and '
                    'can\'t use the advanced timing model'))
        for label, rate in (('P', result.rate_kg_p_ha), ('K', result.rate_kg_k_ha),
                           ('Mg', result.rate_kg_mg_ha)):
            if rate is not None:
                notes += self._rate_parse_note(label, rate)
        return notes

    def _render_season(self, season):
        """Shows a one-line pointer on the main page - the full breakdown
        (see :meth:`_season_full_text`) lives in the Crop model settings
        popup instead, next to the controls that actually change it."""
        if season.estimated_yield_t_ha is None:
            self.page.LSeasonEstimate.setText(
                self.tr('Irrigation/yield estimate: {note}').format(note=season.note))
            return
        pct_of_potential = (
            season.estimated_yield_t_ha / season.potential_yield_t_ha * 100
            if season.potential_yield_t_ha else 0.0)
        self.page.LSeasonEstimate.setText(self.tr(
            'Estimated yield ({crop}): {yield_t:.1f} t/ha ({pct:.0f}% of the '
            '{potential:.1f} t/ha well-managed baseline) - open "Crop model '
            'settings" above for the full breakdown and to adjust these '
            'assumptions.'
        ).format(crop=season.crop_model, yield_t=season.estimated_yield_t_ha,
                pct=pct_of_potential, potential=season.potential_yield_t_ha))

    def _season_full_text(self, season):
        """The detailed irrigation/water/nitrogen breakdown - shown in the
        Crop model settings popup's live-results area (see
        :meth:`_recompute_settings_preview`)."""
        if season.estimated_yield_t_ha is None:
            return self.tr('Irrigation/yield estimate: {note}').format(note=season.note)
        pct_of_potential = (
            season.estimated_yield_t_ha / season.potential_yield_t_ha * 100
            if season.potential_yield_t_ha else 0.0)
        if season.logged_irrigation_mm > 0:
            irrigation_line = self.tr(
                'You logged {logged:.0f} mm of irrigation for this period; an '
                'estimated {irrig:.0f} mm more would have avoided water stress '
                'on {days} day(s).'
            ).format(logged=season.logged_irrigation_mm,
                    irrig=season.irrigation_need_mm, days=season.water_stress_days)
        else:
            irrigation_line = self.tr(
                'No irrigation logged for this period - an estimated {irrig:.0f} '
                'mm would have avoided water stress on {days} day(s). Log '
                'irrigation from the "Irrigation" card on Add data to refine '
                'this estimate.'
            ).format(irrig=season.irrigation_need_mm, days=season.water_stress_days)
        text = self.tr(
            'Estimated yield ({crop}): {yield_t:.1f} t/ha ({pct:.0f}% of the '
            '{potential:.1f} t/ha well-managed baseline). {irrigation_line} {note}'
        ).format(crop=season.crop_model, yield_t=season.estimated_yield_t_ha,
                pct=pct_of_potential, potential=season.potential_yield_t_ha,
                irrigation_line=irrigation_line, note=season.note)
        if season.limiting_factor == 'nitrogen':
            text += self.tr(
                '\n\nWater alone would have allowed an estimated {water_only:.1f} '
                't/ha - nitrogen is what\'s holding this estimate back.'
            ).format(water_only=season.estimated_yield_water_only_t_ha)
        elif season.limiting_factor == 'heat':
            text += self.tr(
                '\n\nWater alone would have allowed an estimated {water_only:.1f} '
                't/ha - heat stress is what\'s holding this estimate back.'
            ).format(water_only=season.estimated_yield_water_only_t_ha)
        elif season.limiting_factor == 'potassium':
            text += self.tr(
                '\n\nWater alone would have allowed an estimated {water_only:.1f} '
                't/ha - potassium is what\'s holding this estimate back.'
            ).format(water_only=season.estimated_yield_water_only_t_ha)
        return text
