"""Apply the user's own process-setting choices to an outgoing slice.

Bambuddy's slice modal can edit OrcaSlicer's full process parameter set (layer
height, wall count, supports, speeds — the same tree the desktop slicer shows
under Print Settings). Those edits arrive as a sparse ``{key: value}`` map and
are written into the process JSON that goes out as ``--load-settings``, using
the same mechanism ``_patch_process_support_settings`` (#1881) and
``apply_design_overrides`` (#2622) already use.

Precedence is deliberate and is the reason this runs last: the picked preset is
the base, the source 3MF's support configuration and the designer's own tweaks
layer on top, and an explicit choice the user made in the modal beats all of
them. Anything else would silently discard a setting the user just typed.

Values are normalised to the string forms a process preset actually stores
(``"1"`` for a bool, ``"20%"`` for a percent, a list of strings for the
per-extruder vector options). The frontend already serialises through the option
schema, so this is a second line of defence for clients that don't — the slicer
CLI validates far more strictly than the GUI and a wrongly-typed value fails the
whole slice rather than being coerced.
"""

from __future__ import annotations

import json
import logging
import re

logger = logging.getLogger(__name__)

# Config keys are lowercase identifiers. Anything else did not come from the
# option schema, so it cannot be a real process setting.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")

# A process JSON is a flat string map; nesting a structure inside it produces a
# file the CLI rejects outright.
_ScalarTypes = (str, int, float, bool)


def _normalise_scalar(value: object) -> str | None:
    """Render one scalar the way a process preset stores it, or ``None`` if it
    is not a value a process setting can hold."""
    if isinstance(value, bool):
        # Checked before int on purpose — bool is a subclass of int, and a
        # process JSON spells booleans "1"/"0", never "True"/"False".
        return "1" if value else "0"
    if isinstance(value, (int, float)):
        return str(value)
    if isinstance(value, str):
        return value
    return None


def normalise_process_overrides(overrides: dict[str, object]) -> dict[str, str | list[str]]:
    """Filter and normalise a client-supplied override map.

    Keys that don't look like config keys, and values that a process preset
    cannot hold, are dropped with a warning rather than failing the slice: the
    user's other settings are still worth applying, and a hard failure here
    would be reported as "slicing failed" with no clue which field caused it.
    """
    clean: dict[str, str | list[str]] = {}
    for key, value in overrides.items():
        if not isinstance(key, str) or not _KEY_RE.match(key):
            logger.warning("Ignoring process override with unusable key: %r", key)
            continue

        if isinstance(value, list):
            parts = [_normalise_scalar(v) for v in value]
            if any(p is None for p in parts):
                logger.warning("Ignoring process override %s: list contains a non-scalar entry", key)
                continue
            clean[key] = [p for p in parts if p is not None]
            continue

        scalar = _normalise_scalar(value)
        if scalar is None:
            logger.warning("Ignoring process override %s: unsupported value type %s", key, type(value).__name__)
            continue
        clean[key] = scalar

    return clean


def apply_process_overrides(process_json: str, overrides: dict[str, object]) -> str:
    """Write the user's process settings into the outgoing process JSON.

    Returns ``process_json`` unchanged when there is nothing to apply or the
    JSON is unparseable, so a bad input degrades to a slice with the picked
    preset rather than failing it — matching ``apply_design_overrides``.
    """
    if not overrides:
        return process_json

    clean = normalise_process_overrides(overrides)
    if not clean:
        return process_json

    try:
        process_cfg = json.loads(process_json)
    except json.JSONDecodeError:
        logger.warning("Process preset JSON is unparseable; skipping %d user override(s)", len(clean))
        return process_json
    if not isinstance(process_cfg, dict):
        return process_json

    process_cfg.update(clean)
    logger.info("Applying %d user process override(s): %s", len(clean), sorted(clean))
    return json.dumps(process_cfg)
