"""Assets API endpoints - unified asset queries."""

from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload, subqueryload

from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus, AssetRelationship, RelationshipType
from shopdb.utils.responses import (
    success_response,
    error_response,
    paginated_response,
    ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query

from shopdb.utils.authz import require_permission
from shopdb.utils.import_mode import apply_import_timestamps

assets_bp = Blueprint('assets', __name__)


# =============================================================================
# Asset Types
# =============================================================================

@assets_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_asset_types():
    """List all asset types."""
    page, per_page = get_pagination_params(request)

    query = AssetType.query

    if request.args.get('active', 'true').lower() != 'false':
        query = query.filter(AssetType.isactive == True)

    query = query.order_by(AssetType.assettype)

    items, total = paginate_query(query, page, per_page)
    data = [t.to_dict() for t in items]

    return paginated_response(data, page, per_page, total)


@assets_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_asset_type(type_id: int):
    """Get a single asset type."""
    t = db.session.get(AssetType, type_id)

    if not t:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset type with ID {type_id} not found',
            http_code=404
        )

    return success_response(t.to_dict())


@assets_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_type():
    """Create a new asset type."""
    data = request.get_json()

    if not data or not data.get('assettype'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'assettype is required')

    if AssetType.query.filter_by(assettype=data['assettype']).first():
        return error_response(
            ErrorCodes.CONFLICT,
            f"Asset type '{data['assettype']}' already exists",
            http_code=409
        )

    t = AssetType(
        assettype=data['assettype'],
        pluginname=data.get('pluginname'),
        tablename=data.get('tablename'),
        description=data.get('description'),
        icon=data.get('icon'),
        color=data.get('color')
    )

    db.session.add(t)
    db.session.commit()

    return success_response(t.to_dict(), message='Asset type created', http_code=201)


@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_type(type_id: int):
    """Update an asset type's display fields (color/icon/description). The name,
    plugin, and table are structural and not editable here."""
    t = db.session.get(AssetType, type_id)
    if not t:
        return error_response(ErrorCodes.NOT_FOUND, 'Asset type not found', http_code=404)
    data = request.get_json() or {}
    for key in ('description', 'icon', 'color', 'isactive'):
        if key in data:
            setattr(t, key, data[key])
    db.session.commit()
    return success_response(t.to_dict(), message='Asset type updated')


# =============================================================================
# Asset Statuses
# =============================================================================

@assets_bp.route('/statuses', methods=['GET'])
@jwt_required(optional=True)
def list_asset_statuses():
    """List all asset statuses."""
    page, per_page = get_pagination_params(request)

    query = AssetStatus.query

    if request.args.get('active', 'true').lower() != 'false':
        query = query.filter(AssetStatus.isactive == True)

    query = query.order_by(AssetStatus.status)

    items, total = paginate_query(query, page, per_page)
    data = [s.to_dict() for s in items]

    return paginated_response(data, page, per_page, total)


@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
@jwt_required(optional=True)
def get_asset_status(status_id: int):
    """Get a single asset status."""
    s = db.session.get(AssetStatus, status_id)

    if not s:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset status with ID {status_id} not found',
            http_code=404
        )

    return success_response(s.to_dict())


@assets_bp.route('/statuses', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_status():
    """Create a new asset status."""
    data = request.get_json()

    if not data or not data.get('status'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required')

    if AssetStatus.query.filter_by(status=data['status']).first():
        return error_response(
            ErrorCodes.CONFLICT,
            f"Asset status '{data['status']}' already exists",
            http_code=409
        )

    s = AssetStatus(
        status=data['status'],
        description=data.get('description'),
        color=data.get('color')
    )

    db.session.add(s)
    db.session.commit()

    return success_response(s.to_dict(), message='Asset status created', http_code=201)


@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset_status(status_id: int):
    """Update an asset status."""
    s = db.session.get(AssetStatus, status_id)
    if not s:
        return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
                              http_code=404)

    data = request.get_json() or {}

    # Conflict check on rename
    if 'status' in data and data['status'] != s.status:
        if AssetStatus.query.filter_by(status=data['status']).first():
            return error_response(
                ErrorCodes.CONFLICT,
                f"Asset status '{data['status']}' already exists",
                http_code=409
            )

    for key in ('status', 'description', 'color', 'isactive'):
        if key in data:
            setattr(s, key, data[key])

    db.session.commit()
    return success_response(s.to_dict(), message='Asset status updated')


@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_status(status_id: int):
    """Delete an asset status. Refused if any asset still uses it."""
    s = db.session.get(AssetStatus, status_id)
    if not s:
        return error_response(ErrorCodes.NOT_FOUND, 'Asset status not found',
                              http_code=404)

    inuse = Asset.query.filter_by(statusid=status_id).count()
    if inuse:
        return error_response(
            ErrorCodes.CONFLICT,
            f"Cannot delete: {inuse} asset(s) still use this status",
            http_code=409
        )

    db.session.delete(s)
    db.session.commit()
    return success_response(message='Asset status deleted')


# =============================================================================
# Relationship Types
# =============================================================================

@assets_bp.route('/relationshiptypes', methods=['GET'])
@jwt_required(optional=True)
def list_relationship_types():
    """List all asset relationship types."""
    types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
    return success_response([_rel_type_dict(t) for t in types])


@assets_bp.route('/relationshiptypes', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_relationship_type():
    """Create a new asset relationship type."""
    data = request.get_json()
    if not data or not data.get('relationshiptype'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required')

    if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
        return error_response(
            ErrorCodes.CONFLICT,
            f"Relationship type '{data['relationshiptype']}' already exists",
            http_code=409
        )

    rel_type = RelationshipType(
        relationshiptype=data['relationshiptype'],
        description=data.get('description'),
        color=data.get('color'),
        isdirectional=data.get('isdirectional', True)
    )
    db.session.add(rel_type)
    db.session.commit()

    return success_response(_rel_type_dict(rel_type), message='Relationship type created', http_code=201)


def _rel_type_dict(t):
    return {
        'relationshiptypeid': t.relationshiptypeid,
        'relationshiptype': t.relationshiptype,
        'description': t.description,
        'color': t.color,
        'isdirectional': t.isdirectional,
        # read-only view of the propagation rails (seed/CLI-managed, no UI)
        'propagatesthrough': [
            {
                'relationshiptypeid': p.relationshiptypeid,
                'relationshiptype': p.relationshiptype,
                'isdirectional': p.isdirectional,
            }
            for p in t.propagatesthrough
        ],
    }


@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_relationship_type(type_id: int):
    """Update a relationship type."""
    t = db.session.get(RelationshipType, type_id)
    if not t:
        return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
    data = request.get_json() or {}
    if 'relationshiptype' in data and data['relationshiptype'] != t.relationshiptype:
        if RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first():
            return error_response(ErrorCodes.CONFLICT,
                                  f"Relationship type '{data['relationshiptype']}' already exists", http_code=409)
    for key in ('relationshiptype', 'description', 'color', 'isdirectional'):
        if key in data:
            setattr(t, key, data[key])
    db.session.commit()
    return success_response(_rel_type_dict(t), message='Relationship type updated')


@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_relationship_type(type_id: int):
    """Delete a relationship type. Refused if any relationship still uses it."""
    t = db.session.get(RelationshipType, type_id)
    if not t:
        return error_response(ErrorCodes.NOT_FOUND, 'Relationship type not found', http_code=404)
    inuse = AssetRelationship.query.filter_by(relationshiptypeid=type_id).count()
    if inuse:
        return error_response(ErrorCodes.CONFLICT,
                              f"Cannot delete: {inuse} relationship(s) still use this type", http_code=409)
    db.session.delete(t)
    db.session.commit()
    return success_response(message='Relationship type deleted')


# =============================================================================
# Assets
# =============================================================================

@assets_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_assets():
    """
    List all assets with filtering and pagination.

    Query parameters:
    - page: Page number (default: 1)
    - per_page: Items per page (default: 20, max: 100)
    - active: Filter by active status (default: true)
    - search: Search by assetnumber or name
    - type: Filter by asset type name (e.g., 'machine', 'computer')
    - type_id: Filter by asset type ID
    - status_id: Filter by status ID
    - location_id: Filter by location ID
    - businessunit_id: Filter by business unit ID
    - include_type_data: Include category-specific extension data (default: false)
    """
    page, per_page = get_pagination_params(request)

    query = Asset.query

    # Active filter
    if request.args.get('active', 'true').lower() != 'false':
        query = query.filter(Asset.isactive == True)

    # Search filter. The asset type is a column in the list, so it has to be
    # searchable. Matched via .has() (a correlated EXISTS) rather than a join,
    # because the type-name filter below joins AssetType itself.
    if search := request.args.get('search'):
        pattern = f'%{search}%'
        query = query.filter(
            db.or_(
                Asset.assetnumber.ilike(pattern),
                Asset.name.ilike(pattern),
                Asset.serialnumber.ilike(pattern),
                Asset.assettype.has(AssetType.assettype.ilike(pattern))
            )
        )

    # Type filter by name
    if type_name := request.args.get('type'):
        query = query.join(AssetType).filter(AssetType.assettype == type_name)

    # Type filter by ID
    if type_id := request.args.get('typeid', request.args.get('type_id')):
        query = query.filter(Asset.assettypeid == int(type_id))

    # Status filter
    if status_id := request.args.get('statusid', request.args.get('status_id')):
        query = query.filter(Asset.statusid == int(status_id))

    # Location filter
    if location_id := request.args.get('locationid', request.args.get('location_id')):
        query = query.filter(Asset.locationid == int(location_id))

    # Business unit filter
    if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
        query = query.filter(Asset.businessunitid == int(bu_id))

    # Sorting
    sort_by = request.args.get('sort', 'assetnumber')
    sort_dir = request.args.get('dir', 'asc')

    sort_columns = {
        'assetnumber': Asset.assetnumber,
        'name': Asset.name,
        'createddate': Asset.createddate,
        'modifieddate': Asset.modifieddate,
    }

    if sort_by in sort_columns:
        col = sort_columns[sort_by]
        query = query.order_by(col.desc() if sort_dir == 'desc' else col)
    else:
        query = query.order_by(Asset.assetnumber)

    items, total = paginate_query(query, page, per_page)

    # Include type data if requested
    include_type_data = request.args.get('include_type_data', 'false').lower() == 'true'
    data = [a.to_dict(include_type_data=include_type_data) for a in items]

    return paginated_response(data, page, per_page, total)


@assets_bp.route('/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_asset(asset_id: int):
    """
    Get a single asset with full details.

    Query parameters:
    - include_type_data: Include category-specific extension data (default: true)
    """
    asset = db.session.get(Asset, asset_id)

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with ID {asset_id} not found',
            http_code=404
        )

    include_type_data = request.args.get('include_type_data', 'true').lower() != 'false'
    return success_response(asset.to_dict(include_type_data=include_type_data))


@assets_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset():
    """Create a new asset."""
    data = request.get_json()

    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    # Validate required fields
    if not data.get('assetnumber'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
    if not data.get('assettypeid'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'assettypeid is required')

    # Check for duplicate assetnumber
    if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
        return error_response(
            ErrorCodes.CONFLICT,
            f"Asset with number '{data['assetnumber']}' already exists",
            http_code=409
        )

    # Validate foreign keys exist
    if not db.session.get(AssetType, data['assettypeid']):
        return error_response(
            ErrorCodes.VALIDATION_ERROR,
            f"Asset type with ID {data['assettypeid']} not found"
        )

    asset = Asset(
        assetnumber=data['assetnumber'],
        name=data.get('name'),
        serialnumber=data.get('serialnumber'),
        assettypeid=data['assettypeid'],
        statusid=data.get('statusid', 1),
        locationid=data.get('locationid'),
        businessunitid=data.get('businessunitid'),
        mapx=data.get('mapx'),
        levelid=data.get('levelid'),
        mapy=data.get('mapy'),
        notes=data.get('notes')
    )

    db.session.add(asset)
    apply_import_timestamps(asset, data)
    db.session.commit()

    return success_response(asset.to_dict(), message='Asset created', http_code=201)


@assets_bp.route('/<int:asset_id>', methods=['PUT'])
@jwt_required()
@require_permission('assets.edit')
def update_asset(asset_id: int):
    """Update an asset."""
    asset = db.session.get(Asset, asset_id)

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with ID {asset_id} not found',
            http_code=404
        )

    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    # An UPDATE may not blank the asset number. Create validates it and the
    # column is NOT NULL, but the conflict check below only runs when the value
    # DIFFERS, and '' never collides with anything - so a payload carrying an
    # empty assetnumber wrote it straight through. A form that loaded blank
    # (a failed reference load, a partial fetch) then saved the blank over a
    # real record.
    if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
        return error_response(
            ErrorCodes.VALIDATION_ERROR,
            'assetnumber cannot be empty')

    # Check for conflicting assetnumber
    if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
        if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
            return error_response(
                ErrorCodes.CONFLICT,
                f"Asset with number '{data['assetnumber']}' already exists",
                http_code=409
            )

    # Update allowed fields
    allowed_fields = [
        'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
        'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive'
    ]

    for key in allowed_fields:
        if key in data:
            setattr(asset, key, data[key])

    apply_import_timestamps(asset, data)
    db.session.commit()
    return success_response(asset.to_dict(), message='Asset updated')


@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset(asset_id: int):
    """Delete (soft delete) an asset."""
    asset = db.session.get(Asset, asset_id)

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with ID {asset_id} not found',
            http_code=404
        )

    asset.isactive = False
    db.session.commit()

    return success_response(message='Asset deleted')


@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
@jwt_required(optional=True)
def lookup_asset_by_number(assetnumber: str):
    """
    Look up an asset by its asset number.

    Useful for finding the asset ID when you only have the machine/asset number.
    """
    asset = Asset.query.filter_by(assetnumber=assetnumber, isactive=True).first()

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with number {assetnumber} not found',
            http_code=404
        )

    return success_response(asset.to_dict(include_type_data=True))


# =============================================================================
# Asset Relationships
# =============================================================================

def _partners_via(assetid, throughtypeid):
    """Return the set of asset ids linked to assetid via throughtypeid.

    Direction-blind: matches rows where assetid is source OR target, so a
    symmetric through-type (Dualpath) stored in either direction is caught.
    """
    rows = AssetRelationship.query.filter(
        AssetRelationship.relationshiptypeid == throughtypeid,
        AssetRelationship.isactive == True,
        db.or_(
            AssetRelationship.sourceassetid == assetid,
            AssetRelationship.targetassetid == assetid,
        ),
    ).all()
    partners = set()
    for row in rows:
        other = row.targetassetid if row.sourceassetid == assetid else row.sourceassetid
        partners.add(other)
    return partners


def _relationship_exists(sourceid, targetid, typeid):
    # existence check ignores isactive so a soft-deleted row still blocks a
    # duplicate insert (the uniqueness constraint spans all rows).
    return AssetRelationship.query.filter_by(
        sourceassetid=sourceid,
        targetassetid=targetid,
        relationshiptypeid=typeid,
    ).first() is not None


def _add_propagated(sourceid, targetid, rel, created):
    # idempotent add; skip if source-T->target already exists (any isactive)
    if sourceid in (targetid,):
        return
    if _relationship_exists(sourceid, targetid, rel.relationshiptypeid):
        return
    # Never fan out INTO an existing inverse. The same one-way rule the create
    # path enforces: if target-T->source is already stored, adding source-T->
    # target would manufacture a reciprocal pair that no user asked for, on a
    # rail that is meant to spread one direction across sibling bays.
    if _relationship_exists(targetid, sourceid, rel.relationshiptypeid):
        return
    newrel = AssetRelationship(
        sourceassetid=sourceid,
        targetassetid=targetid,
        relationshiptypeid=rel.relationshiptypeid,
        label=rel.label,
    )
    db.session.add(newrel)
    created.append(newrel)


def propagate_relationship(rel):
    """Fan out rel across the SYMMETRIC through-types of its type.

    A Dualpath pair is one physical dual-bay machine, so a relationship that
    touches one bay must be mirrored to the partner bay - whichever end (source
    or target) carries the Dualpath link. For each symmetric through-type P
    (isdirectional false) in the type's propagation set we mirror BOTH ends,
    keeping the relationship's direction:

      * for each partner X of the TARGET via P: create source -T-> X
      * for each partner X of the SOURCE via P: create X -T-> target

    In practice only one end has partners (the bay end of a controls edge,
    whichever direction the row is stored), so there is no double fan-out.
    Canonical controls direction is PC -> machine (the PC is the controller:
    it sends programs to the machine and receives logs); reversed legacy rows
    are corrected by `flask relationships fix-controls-direction`, and
    propagation preserves whatever direction the row carries. Same-direction
    idempotency: controls is directional, so we skip when the same
    source-T->target row already exists. Returns the newly created
    AssetRelationship rows (added to the session, not committed).

    Directional through-types (partof) are declared but NOT consumed: the
    parent/child fan-out direction is ambiguous, so their propagation semantics
    are deferred and partof stays inert exactly as before.
    """
    created = []
    reltype = rel.relationshiptype or db.session.get(RelationshipType, rel.relationshiptypeid)
    if not reltype:
        return created
    for through in reltype.propagatesthrough:
        if through.isdirectional:
            continue  # directional propagation deferred (parent/child ambiguity)
        # target-side partners inherit being controlled BY the source
        for partnerid in _partners_via(rel.targetassetid, through.relationshiptypeid):
            if partnerid != rel.sourceassetid:
                _add_propagated(rel.sourceassetid, partnerid, rel, created)
        # source-side partners inherit controlling the target
        for partnerid in _partners_via(rel.sourceassetid, through.relationshiptypeid):
            if partnerid != rel.targetassetid:
                _add_propagated(partnerid, rel.targetassetid, rel, created)
    return created


@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
@jwt_required(optional=True)
def get_asset_relationships(asset_id: int):
    """
    Get all relationships for an asset.

    Returns both outgoing (source) and incoming (target) relationships.
    """
    asset = db.session.get(Asset, asset_id)

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with ID {asset_id} not found',
            http_code=404
        )

    # Get outgoing relationships (this asset is source)
    outgoing = AssetRelationship.query.filter_by(
        sourceassetid=asset_id
    ).filter(AssetRelationship.isactive == True).all()

    # Get incoming relationships (this asset is target)
    incoming = AssetRelationship.query.filter_by(
        targetassetid=asset_id
    ).filter(AssetRelationship.isactive == True).all()

    outgoing_data = []
    for rel in outgoing:
        r = rel.to_dict()
        r['targetasset'] = rel.targetasset.to_dict() if rel.targetasset else None
        r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
        # directionality drives card display (symmetric collapse vs arrows)
        r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
        outgoing_data.append(r)

    incoming_data = []
    for rel in incoming:
        r = rel.to_dict()
        r['sourceasset'] = rel.sourceasset.to_dict() if rel.sourceasset else None
        r['relationshiptypename'] = rel.relationshiptype.relationshiptype if rel.relationshiptype else None
        r['isdirectional'] = rel.relationshiptype.isdirectional if rel.relationshiptype else True
        incoming_data.append(r)

    return success_response({
        'outgoing': outgoing_data,
        'incoming': incoming_data
    })


@assets_bp.route('/relationships', methods=['POST'])
@jwt_required()
@require_permission('assets.create')
def create_asset_relationship():
    """Create a relationship between two assets.

    After the row is created, the relationship is fanned out across its type's
    propagation rails (relationshiptypepropagations). For each SYMMETRIC
    through-type (e.g. Dualpath) the edge is mirrored to the partner of
    whichever endpoint carries the through-type link, keeping direction.
    Concretely, `controls` between a bay of a dual-bay machine and its PC is
    also created for the partner bay, since a Dualpath pair is one physical
    machine with a single controller. Directional through-types (partof) are
    declared but not consumed yet. The response carries `propagated` and
    `propagatedcount`. Propagation pairs are seed/CLI-managed (there is no
    management UI); backfill legacy data with `flask relationships propagate`.
    """
    data = request.get_json()

    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    # Validate required fields
    required = ['sourceassetid', 'targetassetid', 'relationshiptypeid']
    for field in required:
        if not data.get(field):
            return error_response(ErrorCodes.VALIDATION_ERROR, f'{field} is required')

    source_id = data['sourceassetid']
    target_id = data['targetassetid']
    type_id = data['relationshiptypeid']

    # Validate assets exist
    if not db.session.get(Asset, source_id):
        return error_response(ErrorCodes.NOT_FOUND, f'Source asset {source_id} not found', http_code=404)
    if not db.session.get(Asset, target_id):
        return error_response(ErrorCodes.NOT_FOUND, f'Target asset {target_id} not found', http_code=404)
    if not db.session.get(RelationshipType, type_id):
        return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)

    # An asset cannot relate to itself. It renders as a duplicate row on that
    # asset's own page (the card lists both ends) and means nothing.
    if source_id == target_id:
        return error_response(
            ErrorCodes.VALIDATION_ERROR,
            'An asset cannot be related to itself'
        )

    # Check for duplicate relationship.
    #
    # Deleting a relationship is SOFT (isactive = False, see
    # delete_asset_relationship). The row therefore survives, and this lookup
    # found it - so deleting a link and adding it back answered 409 "this
    # relationship already exists" about a link the page no longer shows, with
    # no way forward from the UI at all. The row cannot simply be inserted
    # again either: assetrelationships is unique on (source, target, type).
    #
    # Reactivating IS the create, for an inactive row.
    existing = AssetRelationship.query.filter_by(
        sourceassetid=source_id,
        targetassetid=target_id,
        relationshiptypeid=type_id
    ).first()

    if existing and existing.isactive:
        return error_response(
            ErrorCodes.CONFLICT,
            'This relationship already exists',
            http_code=409
        )

    # A SINGULAR type allows one active row per source, and setting a new one
    # REPLACES rather than refusing: "make this the default printer" means
    # exactly that, and a card that answered 409 would leave the user to find
    # and delete the old row first.
    #
    # Without this the schema is happy to hold two defaults - the unique
    # constraint is (source, target, type), so two different targets are two
    # valid rows - and the resolver takes the OLDEST, so the new default
    # silently loses.
    reltype = db.session.get(RelationshipType, type_id)
    replaced = 0
    if reltype is not None and getattr(reltype, 'issingular', False):
        others = AssetRelationship.query.filter(
            AssetRelationship.sourceassetid == source_id,
            AssetRelationship.relationshiptypeid == type_id,
            AssetRelationship.isactive == True,
            AssetRelationship.targetassetid != target_id).all()
        for row in others:
            row.isactive = False
            replaced += 1

    # And it cannot relate BOTH WAYS on a directional type. Only one direction
    # can be true - a PC drives a machine, never the reverse - but the check
    # above is keyed on (source, target, type), so the inverse used to insert
    # cleanly and both rows then rendered on both asset pages. The dialog offers
    # an `incoming` direction, so this is reachable by hand, and the legacy
    # import stores controls the wrong way round.
    #
    # Symmetric types are exempt: they store both directions ON PURPOSE and the
    # card collapses them to one entry per peer.
    reltype = db.session.get(RelationshipType, type_id)
    if reltype is not None and reltype.isdirectional:
        inverse = AssetRelationship.query.filter_by(
            sourceassetid=target_id,
            targetassetid=source_id,
            relationshiptypeid=type_id
        ).first()
        # isactive, for the same reason as the duplicate check above: a soft
        # deleted inverse is a link somebody already removed, and blocking on it
        # made "remove the existing one first" - the instruction in this very
        # message - fail to unblock anything.
        if inverse is not None and inverse.isactive:
            return error_response(
                ErrorCodes.CONFLICT,
                f"The reverse of this relationship already exists "
                f"(relationship {inverse.relationshipid}). A "
                f"'{reltype.relationshiptype}' link points one way only - "
                f"remove the existing one first if the direction is wrong.",
                http_code=409
            )

    if existing is not None:
        # The soft-deleted row from the duplicate check. Reuse it: the unique
        # index on (source, target, type) means there is no second row to add.
        rel = existing
        rel.isactive = True
        rel.notes = data.get('notes')
    else:
        rel = AssetRelationship(
            sourceassetid=source_id,
            targetassetid=target_id,
            relationshiptypeid=type_id,
            notes=data.get('notes')
        )
        db.session.add(rel)
    apply_import_timestamps(rel, data)
    db.session.flush()

    # fan out across symmetric propagation rails (e.g. Dualpath partner bays)
    propagated = propagate_relationship(rel)
    for prop in propagated:
        apply_import_timestamps(prop, data)
    db.session.commit()

    payload = rel.to_dict()
    payload['propagated'] = [prop.to_dict() for prop in propagated]
    payload['propagatedcount'] = len(propagated)
    return success_response(payload, message='Relationship created', http_code=201)


@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
@jwt_required()
@require_permission('assets.delete')
def delete_asset_relationship(rel_id: int):
    """Delete an asset relationship.

    Deletion is manual and per-row: propagated rows (e.g. the mirrored controls
    link on a Dualpath partner bay) are real independent rows and each is
    deleted on its own. Removing one bay's link does NOT cascade to the other.
    """
    rel = db.session.get(AssetRelationship, rel_id)

    if not rel:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Relationship with ID {rel_id} not found',
            http_code=404
        )

    rel.isactive = False
    db.session.commit()

    return success_response(message='Relationship deleted')


# =============================================================================
# Asset Communications
# =============================================================================

# =============================================================================
# Unified Asset Map
# =============================================================================

@assets_bp.route('/map', methods=['GET'])
@jwt_required(optional=True)
def get_assets_map():
    """
    Get all assets with map positions for unified floor map display.

    Returns assets with mapx/mapy coordinates, joined with type-specific data.

    Query parameters:
    - assettype: Filter by asset type name (machine, computer, network_device, printer)
    - subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer)
    - businessunitid: Filter by business unit ID
    - statusid: Filter by status ID
    - locationid: Filter by location ID
    - search: Search by assetnumber, name, or serialnumber
    """
    from shopdb.core.models import Location, BusinessUnit, Communication

    # Eager-load all relationships to avoid N+1 queries.
    # Core relationships via joinedload, extension tables via subqueryload
    # with their nested relationships (vendor, model, type) also eager-loaded.
    eager_options = [
        joinedload(Asset.assettype),
        joinedload(Asset.status),
        joinedload(Asset.location),
        joinedload(Asset.businessunit),
    ]

    # Eager-load plugin extension tables AND their relationships
    try:
        from plugins.machines.models import Machine
        eager_options.append(
            subqueryload(Asset.machine)
            .joinedload(Machine.machinetype)
        )
        eager_options.append(
            subqueryload(Asset.machine)
            .joinedload(Machine.vendor)
        )
        eager_options.append(
            subqueryload(Asset.machine)
            .joinedload(Machine.model)
        )
        eager_options.append(
            subqueryload(Asset.machine)
            .joinedload(Machine.controllervendor)
        )
        eager_options.append(
            subqueryload(Asset.machine)
            .joinedload(Machine.controllermodel)
        )
    except (ImportError, AttributeError):
        pass
    try:
        from plugins.computers.models import Computer
        eager_options.append(
            subqueryload(Asset.computer)
            .joinedload(Computer.computertype)
        )
        eager_options.append(
            subqueryload(Asset.computer)
            .joinedload(Computer.operatingsystem)
        )
    except (ImportError, AttributeError):
        pass
    try:
        from plugins.network.models import NetworkDevice
        eager_options.append(
            subqueryload(Asset.network_device)
            .joinedload(NetworkDevice.networkdevicetype)
        )
        eager_options.append(
            subqueryload(Asset.network_device)
            .joinedload(NetworkDevice.vendor)
        )
    except (ImportError, AttributeError):
        pass
    try:
        from plugins.printers.models import Printer
        eager_options.append(
            subqueryload(Asset.printer)
            .joinedload(Printer.printertype)
        )
        eager_options.append(
            subqueryload(Asset.printer)
            .joinedload(Printer.vendor)
        )
        eager_options.append(
            subqueryload(Asset.printer)
            .joinedload(Printer.model)
        )
    except (ImportError, AttributeError):
        pass
    try:
        from plugins.measuringtools.models import MeasuringTool
        eager_options.append(
            subqueryload(Asset.measuringtool)
            .joinedload(MeasuringTool.measuringtooltype)
        )
    except (ImportError, AttributeError):
        pass

    query = Asset.query.options(*eager_options).filter(
        Asset.isactive == True,
        Asset.mapx.isnot(None),
        Asset.mapy.isnot(None)
    )

    # Dualpath single-machine collapse: hide the secondary bay marker so a
    # dual-bay pair shows one marker; the primary carries the partner label.
    # Gated on the site setting (default on).
    from shopdb.core.services.dualpath import (
        resolve_dualpath_pairs, dualpath_single_machine_enabled)
    dualpath_collapse = None
    if dualpath_single_machine_enabled():
        dualpath_collapse = resolve_dualpath_pairs()
        if dualpath_collapse.secondaryassetids:
            query = query.filter(
                Asset.assetid.notin_(dualpath_collapse.secondaryassetids))

    selected_assettype = request.args.get('assettype')

    # Filter by asset type name
    if selected_assettype:
        query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)

    # Filter by subtype (depends on asset type) - case-insensitive matching
    if subtype_id := request.args.get('subtype'):
        subtype_id = int(subtype_id)
        # Normalize the underscore DB form (measuring_tool, network_device) to
        # the space form the branches below compare against.
        asset_type_lower = (
            selected_assettype.lower().replace('_', ' ') if selected_assettype else '')
        if asset_type_lower == 'machine':
            try:
                from plugins.machines.models import Machine
                query = query.join(Machine, Machine.assetid == Asset.assetid).filter(
                    Machine.machinetypeid == subtype_id
                )
            except ImportError:
                pass
        elif asset_type_lower == 'computer':
            try:
                from plugins.computers.models import Computer
                query = query.join(Computer, Computer.assetid == Asset.assetid).filter(
                    Computer.computertypeid == subtype_id
                )
            except ImportError:
                pass
        elif asset_type_lower == 'network device':
            try:
                from plugins.network.models import NetworkDevice
                query = query.join(NetworkDevice, NetworkDevice.assetid == Asset.assetid).filter(
                    NetworkDevice.networkdevicetypeid == subtype_id
                )
            except ImportError:
                pass
        elif asset_type_lower == 'printer':
            try:
                from plugins.printers.models import Printer
                query = query.join(Printer, Printer.assetid == Asset.assetid).filter(
                    Printer.printertypeid == subtype_id
                )
            except ImportError:
                pass
        elif asset_type_lower == 'measuring tool':
            try:
                from plugins.measuringtools.models import MeasuringTool
                query = query.join(
                    MeasuringTool, MeasuringTool.assetid == Asset.assetid).filter(
                    MeasuringTool.measuringtooltypeid == subtype_id
                )
            except ImportError:
                pass

    # Filter by business unit
    if bu_id := request.args.get('businessunitid'):
        query = query.filter(Asset.businessunitid == int(bu_id))

    # Filter by status
    if status_id := request.args.get('statusid'):
        query = query.filter(Asset.statusid == int(status_id))

    # Filter by location
    if location_id := request.args.get('locationid'):
        query = query.filter(Asset.locationid == int(location_id))

    # Search filter
    if search := request.args.get('search'):
        query = query.filter(
            db.or_(
                Asset.assetnumber.ilike(f'%{search}%'),
                Asset.name.ilike(f'%{search}%'),
                Asset.serialnumber.ilike(f'%{search}%')
            )
        )

    assets = query.all()

    # Batch-load primary IPs in a single query instead of N+1 per asset.
    # Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
    asset_ids = [a.assetid for a in assets]
    primary_ip_map = {}
    if asset_ids:
        ip_rows = db.session.query(
            Communication.assetid,
            Communication.ipaddress,
            Communication.isprimary
        ).filter(
            Communication.assetid.in_(asset_ids),
            Communication.comtypeid == 1,
            Communication.isactive == True
        ).order_by(
            Communication.isprimary.desc()
        ).all()

        for row in ip_rows:
            # First match wins (isprimary=True sorted first)
            if row.assetid not in primary_ip_map:
                primary_ip_map[row.assetid] = row.ipaddress

    # Build response - all relationship data is already loaded, no extra queries
    data = []
    for asset in assets:
        item = {
            'assetid': asset.assetid,
            'assetnumber': asset.assetnumber,
            'name': asset.name,
            'displayname': asset.display_name,
            'serialnumber': asset.serialnumber,
            'mapx': asset.mapx,
            'levelid': asset.levelid,
            'mapy': asset.mapy,
            'assettype': asset.assettype.assettype if asset.assettype else None,
            'assettypeid': asset.assettypeid,
            'status': asset.status.status if asset.status else None,
            'statusid': asset.statusid,
            'statuscolor': asset.status.color if asset.status else None,
            'location': asset.location.locationname if asset.location else None,
            'locationid': asset.locationid,
            'businessunit': asset.businessunit.businessunit if asset.businessunit else None,
            'businessunitid': asset.businessunitid,
            'primaryip': primary_ip_map.get(asset.assetid),
        }

        # Extension data is already eager-loaded via lazy='joined' backrefs
        type_data = asset._get_extension_data()
        if type_data:
            item['typedata'] = type_data

        # combined-label partner for a collapsed dual-bay pair (label only)
        if dualpath_collapse:
            partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
            item['dualpathpartner'] = partner['assetnumber'] if partner else None

        data.append(item)

    # Get filter options - these are small reference tables, no N+1 concern
    asset_types = AssetType.query.filter(AssetType.isactive == True).all()
    types_data = [{'assettypeid': t.assettypeid, 'assettype': t.assettype, 'icon': t.icon, 'color': t.color} for t in asset_types]

    statuses = AssetStatus.query.filter(AssetStatus.isactive == True).all()
    status_data = [{'statusid': s.statusid, 'status': s.status, 'color': s.color} for s in statuses]

    business_units = BusinessUnit.query.filter(BusinessUnit.isactive == True).all()
    bu_data = [{'businessunitid': bu.businessunitid, 'businessunit': bu.businessunit} for bu in business_units]

    locations = Location.query.filter(Location.isactive == True).all()
    loc_data = [{'locationid': loc.locationid, 'locationname': loc.locationname} for loc in locations]

    # Get subtypes for filter dropdowns
    subtypes = {}

    try:
        from plugins.machines.models import MachineType
        machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all()
        subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types]
    except ImportError:
        subtypes['Machine'] = []

    try:
        from plugins.computers.models import ComputerType
        computer_types = ComputerType.query.filter(ComputerType.isactive == True).order_by(ComputerType.computertype).all()
        subtypes['Computer'] = [{'id': ct.computertypeid, 'name': ct.computertype, 'color': ct.color} for ct in computer_types]
    except ImportError:
        subtypes['Computer'] = []

    try:
        from plugins.network.models import NetworkDeviceType
        net_types = NetworkDeviceType.query.filter(NetworkDeviceType.isactive == True).order_by(NetworkDeviceType.networkdevicetype).all()
        subtypes['Network Device'] = [{'id': nt.networkdevicetypeid, 'name': nt.networkdevicetype, 'color': nt.color} for nt in net_types]
    except ImportError:
        subtypes['Network Device'] = []

    try:
        from plugins.printers.models import PrinterType
        printer_types = PrinterType.query.filter(PrinterType.isactive == True).order_by(PrinterType.printertype).all()
        subtypes['Printer'] = [{'id': pt.printertypeid, 'name': pt.printertype, 'color': pt.color} for pt in printer_types]
    except ImportError:
        subtypes['Printer'] = []

    try:
        from plugins.measuringtools.models import MeasuringToolType
        measuringtool_types = MeasuringToolType.query.filter(
            MeasuringToolType.isactive == True).order_by(MeasuringToolType.name).all()
        subtypes['Measuring Tool'] = [{'id': mt.measuringtooltypeid, 'name': mt.name, 'color': mt.color} for mt in measuringtool_types]
    except ImportError:
        subtypes['Measuring Tool'] = []

    return success_response({
        'assets': data,
        'total': len(data),
        'filters': {
            'assettypes': types_data,
            'statuses': status_data,
            'businessunits': bu_data,
            'locations': loc_data,
            'subtypes': subtypes
        }
    })


@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
@jwt_required(optional=True)
def get_asset_communications(asset_id: int):
    """Get all communications for an asset."""
    from shopdb.core.models import Communication

    asset = db.session.get(Asset, asset_id)

    if not asset:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Asset with ID {asset_id} not found',
            http_code=404
        )

    comms = Communication.query.filter_by(
        assetid=asset_id,
        isactive=True
    ).all()

    data = []
    for comm in comms:
        c = comm.to_dict()
        c['comtype_name'] = comm.comtype.comtype if comm.comtype else None
        data.append(c)

    return success_response(data)
