"""Buildings and levels: a map is a drawing per level, not one image per site.

See ADR-017. `map_blueprint_light`, `map_blueprint_dark`, `map_width` and
`map_height` described one image for the whole site, and `assets.mapx`/`mapy`
were pixels in it. A second level and a likely second building make that a
table.

This migration is written so nothing renders differently the day it lands: the
four settings become one building and one level, marked default, and every asset
that has a position points at it. The settings rows are left in place here and
retired separately, so a rollback does not lose the blueprint paths.

`levelid` is nullable because an asset with no position needs no level. A
position WITHOUT a level is the case the UI refuses to guess about, and after
this migration no such row exists.

Revision ID: 7d33_buildings_and_levels
Revises: 7d32_displayrole_kiosk_vocabulary
"""
from alembic import op
import sqlalchemy as sa


revision = '7d33_buildings_and_levels'
down_revision = '7d32_displayrole_kiosk_vocabulary'
branch_labels = None
depends_on = None

# What the settings said before this table existed. Read at upgrade time; these
# are only the fallbacks for a site that never set them.
DEFAULT_WIDTH = 3300
DEFAULT_HEIGHT = 2550
PLACEHOLDER = '/static/images/floorplan-placeholder.svg'


def _existing(insp, name):
    return name in insp.get_table_names()


def upgrade():
    bind = op.get_bind()
    insp = sa.inspect(bind)

    # Guarded like every other table-creating migration in this project: on a
    # FRESH database the tables are built from the SQLAlchemy models, which
    # already declare them, so an unconditional create fails.
    if not _existing(insp, 'buildings'):
        op.create_table(
            'buildings',
            sa.Column('buildingid', sa.Integer, primary_key=True),
            sa.Column('buildingname', sa.String(100), nullable=False,
                      unique=True),
            sa.Column('sortorder', sa.Integer, nullable=False,
                      server_default='0'),
            sa.Column('createddate', sa.DateTime, nullable=False),
            sa.Column('modifieddate', sa.DateTime, nullable=False),
            sa.Column('isactive', sa.Boolean, nullable=False,
                      server_default=sa.true()),
        )

    if not _existing(insp, 'maplevels'):
        op.create_table(
            'maplevels',
            sa.Column('levelid', sa.Integer, primary_key=True),
            sa.Column('buildingid', sa.Integer,
                      sa.ForeignKey('buildings.buildingid'), nullable=False,
                      index=True),
            sa.Column('levelname', sa.String(100), nullable=False),
            sa.Column('sortorder', sa.Integer, nullable=False,
                      server_default='0'),
            sa.Column('blueprintlight', sa.String(255), nullable=True),
            sa.Column('blueprintdark', sa.String(255), nullable=True),
            sa.Column('mapwidth', sa.Integer, nullable=False,
                      server_default=str(DEFAULT_WIDTH)),
            sa.Column('mapheight', sa.Integer, nullable=False,
                      server_default=str(DEFAULT_HEIGHT)),
            sa.Column('isdefault', sa.Boolean, nullable=False,
                      server_default=sa.false()),
            sa.Column('createddate', sa.DateTime, nullable=False),
            sa.Column('modifieddate', sa.DateTime, nullable=False),
            sa.Column('isactive', sa.Boolean, nullable=False,
                      server_default=sa.true()),
            sa.UniqueConstraint('buildingid', 'levelname',
                                name='uq_maplevel_building_name'),
        )

    # Positions have no history, and a bulk transform rewrites hundreds of them
    # at once. A snapshot table is what makes that reversible - without it, the
    # honest advice would be "back up the database first", which nobody does
    # before a UI action.
    if not _existing(insp, 'mappositionsnapshots'):
        op.create_table(
            'mappositionsnapshots',
            sa.Column('snapshotid', sa.Integer, primary_key=True),
            sa.Column('levelid', sa.Integer, nullable=True),
            sa.Column('reason', sa.String(255), nullable=True),
            sa.Column('assetcount', sa.Integer, nullable=False,
                      server_default='0'),
            # The positions themselves, as JSON: assetid, mapx, mapy, levelid,
            # mapverifiedat per row. Deliberately not a child table - a snapshot
            # is read back whole or not at all, and one row per snapshot keeps
            # restore a single statement.
            sa.Column('positionsjson', sa.Text, nullable=False),
            sa.Column('restoredat', sa.DateTime, nullable=True),
            sa.Column('createdby', sa.String(100), nullable=True),
            sa.Column('createddate', sa.DateTime, nullable=False),
            sa.Column('modifieddate', sa.DateTime, nullable=False),
            sa.Column('isactive', sa.Boolean, nullable=False,
                      server_default=sa.true()),
        )

    assetcolumns = {c['name'] for c in insp.get_columns('assets')}
    if 'levelid' not in assetcolumns:
        op.add_column('assets', sa.Column('levelid', sa.Integer, nullable=True))
        op.create_index('idx_assets_levelid', 'assets', ['levelid'])
        # The FK is added separately from the column so a site whose assets
        # table is large is not rewritten twice.
        op.create_foreign_key('fk_assets_levelid', 'assets', 'maplevels',
                              ['levelid'], ['levelid'])
    if 'mapverifiedat' not in assetcolumns:
        op.add_column('assets',
                      sa.Column('mapverifiedat', sa.DateTime, nullable=True))

    # Locations carry map coordinates too - they are the default position for
    # assets at that location - so they need a level for exactly the same
    # reason. Missed on the first pass and caught by the payload gate.
    locationcolumns = {c['name'] for c in insp.get_columns('locations')}
    if 'levelid' not in locationcolumns:
        op.add_column('locations', sa.Column('levelid', sa.Integer, nullable=True))
        op.create_index('idx_locations_levelid', 'locations', ['levelid'])
        op.create_foreign_key('fk_locations_levelid', 'locations', 'maplevels',
                              ['levelid'], ['levelid'])

    # --- carry the settings forward -------------------------------------
    # Only when there is nothing here yet: re-running must not create a second
    # default level, and a site that has already set its levels up must not have
    # them joined by a stale one built from retired settings.
    existinglevels = bind.execute(
        sa.text('SELECT COUNT(*) FROM maplevels')).scalar() or 0
    if existinglevels:
        return

    settings = dict(bind.execute(sa.text(
        "SELECT `key`, value FROM settings WHERE `key` IN "
        "('map_blueprint_light','map_blueprint_dark','map_width','map_height')"
    )).fetchall())

    def _int(value, fallback):
        try:
            number = int(str(value).strip())
            return number if number > 0 else fallback
        except (TypeError, ValueError):
            return fallback

    bind.execute(sa.text(
        'INSERT INTO buildings (buildingname, sortorder, createddate, '
        'modifieddate, isactive) VALUES (:name, 0, :now, :now, :active)'),
        {'name': 'Main', 'now': sa.func.now(), 'active': True})
    buildingid = bind.execute(sa.text(
        'SELECT buildingid FROM buildings WHERE buildingname = :name'),
        {'name': 'Main'}).scalar()

    bind.execute(sa.text(
        'INSERT INTO maplevels (buildingid, levelname, sortorder, '
        'blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, '
        'createddate, modifieddate, isactive) VALUES (:building, :name, 0, '
        ':light, :dark, :width, :height, :isdefault, :now, :now, :active)'),
        {'building': buildingid,
         'name': 'Ground floor',
         'light': settings.get('map_blueprint_light') or PLACEHOLDER,
         'dark': settings.get('map_blueprint_dark') or PLACEHOLDER,
         'width': _int(settings.get('map_width'), DEFAULT_WIDTH),
         'height': _int(settings.get('map_height'), DEFAULT_HEIGHT),
         'isdefault': True, 'now': sa.func.now(), 'active': True})

    levelid = bind.execute(sa.text(
        'SELECT levelid FROM maplevels WHERE isdefault = :flag'),
        {'flag': True}).scalar()

    # Every asset that already has a position had it in this one drawing's
    # coordinate space, so it belongs to this level. An asset with no position
    # is left null: it needs no level until somebody places it.
    bind.execute(sa.text(
        'UPDATE assets SET levelid = :levelid '
        'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
        {'levelid': levelid})
    bind.execute(sa.text(
        'UPDATE locations SET levelid = :levelid '
        'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
        {'levelid': levelid})


def downgrade():
    bind = op.get_bind()
    insp = sa.inspect(bind)
    assetcolumns = {c['name'] for c in insp.get_columns('assets')}
    if 'levelid' in assetcolumns:
        op.drop_constraint('fk_assets_levelid', 'assets', type_='foreignkey')
        op.drop_index('idx_assets_levelid', table_name='assets')
        op.drop_column('assets', 'levelid')
    if 'mapverifiedat' in assetcolumns:
        op.drop_column('assets', 'mapverifiedat')
    locationcolumns = {c['name'] for c in insp.get_columns('locations')}
    if 'levelid' in locationcolumns:
        op.drop_constraint('fk_locations_levelid', 'locations', type_='foreignkey')
        op.drop_index('idx_locations_levelid', table_name='locations')
        op.drop_column('locations', 'levelid')
    if _existing(insp, 'mappositionsnapshots'):
        op.drop_table('mappositionsnapshots')
    if _existing(insp, 'maplevels'):
        op.drop_table('maplevels')
    if _existing(insp, 'buildings'):
        op.drop_table('buildings')
