"""Store an empty optional unique field as NULL, not as an empty string.

A column that is `unique=True` and nullable accepts any number of NULLs, which
is what makes "optional but unique" work at all. It accepts exactly ONE empty
string. So the first record saved with a blank code succeeds and the second
fails with "Duplicate entry '' for key", surfacing as a 500 from an endpoint
that did nothing wrong.

That is not a business-unit problem. A dozen columns across core and the plugins
are unique and nullable - asset numbers, host names, item codes, subnet names -
and every one of them is a blank web form away from the same collision. Fixing
them one endpoint at a time would leave the next one to be discovered by a user.

So it is fixed once, at the mapper: before any insert or update, a string column
that is unique and nullable and has been set to '' or whitespace is stored as
NULL instead. An empty string carries no information in a unique column - there
is no case where two records both meaning "blank" should collide - so nothing is
lost by normalising it.

Registered from create_app. Applies to every model, including plugin models
loaded later, because it hooks the shared Mapper class rather than a list of
models known at import time.
"""

from sqlalchemy import event, String, inspect
from sqlalchemy.orm import Session

_CACHE = {}


def _blankable_columns(mapper):
    """Unique, nullable, string columns on this mapper - worked out once each."""
    cached = _CACHE.get(mapper)
    if cached is not None:
        return cached

    columns = []
    for prop in mapper.column_attrs:
        for column in prop.columns:
            if not column.unique or not column.nullable:
                continue
            # Only text. A unique nullable integer cannot be handed '' by a form
            # without failing type coercion long before it reaches here.
            if not isinstance(column.type, String):
                continue
            columns.append(prop.key)
            break
    _CACHE[mapper] = columns
    return columns


def _normalise_instance(obj):
    try:
        mapper = inspect(obj).mapper
    except Exception:
        return
    for key in _blankable_columns(mapper):
        value = getattr(obj, key, None)
        if isinstance(value, str) and not value.strip():
            setattr(obj, key, None)


def _before_flush(session, _flush_context, _instances):
    # Both new and modified: editing a record to clear its code has to become
    # NULL for the same reason creating one with a blank code does.
    for obj in list(session.new) + list(session.dirty):
        _normalise_instance(obj)


def register_blank_unique_normaliser():
    """Hook every session flush, for every model including plugins'.

    Listening on Session rather than on individual mappers means models imported
    later - which is every plugin model - are covered without registration, and
    it avoids depending on mapper-level event semantics that differ across
    SQLAlchemy versions.
    """
    if getattr(register_blank_unique_normaliser, '_installed', False):
        return
    event.listen(Session, 'before_flush', _before_flush)
    register_blank_unique_normaliser._installed = True
