"""
PowerShell data-collection API endpoints.

Compatibility layer for the PowerShell scripts that report PC state. Uses an
API key (not JWT) for unattended scripts. Writes the asset/computer model
(ADR-001), not the retired Machine model.
"""

from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, current_app, g

from shopdb.extensions import db
from shopdb.core.models import Asset, Application
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX

from shopdb.utils.responses import success_response, error_response, ErrorCodes

collector_bp = Blueprint('collector', __name__)

# A managed token scoped to this permission is a collector service token: it
# authorizes the collector ingest API and nothing else (ADR-006, machine
# identity - no require_permission on these routes).
COLLECTOR_SCOPE = 'collector.ingest'


def _computer_models():
    """Lazily import the computers plugin models.

    The legacy /pc, /apps, /heartbeat, /bulk endpoints predate the generic
    collector contract and are computers-specific. Importing the plugin lazily
    (instead of at module load) keeps core importable when the computers plugin
    is absent or disabled. Returns (Computer, ComputerInstalledApp) or None.
    """
    try:
        from plugins.computers.models import Computer, ComputerInstalledApp
        return Computer, ComputerInstalledApp
    except ImportError:
        return None


def _token_is_collector(token):
    """True when this managed token is a collector service token: scoped for
    collector.ingest AND its owner actually holds that permission (admins do)."""
    from shopdb.core.models import User

    scopelist = token.scopelist
    if not scopelist or COLLECTOR_SCOPE not in scopelist:
        return False
    user = db.session.get(User, token.userid)
    return (user is not None and user.isactive
            and user.haspermission(COLLECTOR_SCOPE))


def _collector_managed_token(api_key):
    """Find the managed token backing this collector request, or None.

    Two transports are accepted (GE-Enforce sends X-API-Key today; Bearer is
    the standard PAT wire form):
      X-API-Key: the before_request PAT shim ignores this header, so resolve the
                 secret here.
      Bearer:    the shim already resolved+swapped it (the raw Bearer secret is
                 gone by now), leaving g.apitokenid. Reuse that.
    """
    from shopdb.utils.apitoken_auth import resolve_api_token

    if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
        resolved = resolve_api_token(api_key)
        return resolved[0] if resolved else None
    tokenid = getattr(g, 'apitokenid', None)
    if tokenid is not None:
        return db.session.get(ApiToken, tokenid)
    return None


def _check_collector_auth(expected_key):
    """Authorize a collector request. Returns None when authorized, else the
    error_response to return.

    Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env
    key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so
    nothing breaks; a valid managed token works even with no env key set.
    """
    from shopdb.utils.apitoken_auth import touch_apitoken_lastused

    api_key = request.headers.get('X-API-Key')

    token = _collector_managed_token(api_key)
    if token is not None and _token_is_collector(token):
        touch_apitoken_lastused(token)
        return None

    if expected_key and api_key == expected_key:
        return None

    # Fail-closed only on pure server misconfiguration: no env key AND the
    # caller presented no managed token at all. Otherwise it is a bad credential.
    presented_pat = token is not None or (
        api_key is not None and api_key.startswith(TOKEN_SECRET_PREFIX))
    if not expected_key and not presented_pat:
        return error_response(ErrorCodes.INTERNAL_ERROR,
                              'Collector API key not configured', http_code=500)
    return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
                          http_code=401)


def require_api_key(f):
    """Require collector API-key OR collector-scoped managed-token auth."""
    @wraps(f)
    def decorated(*args, **kwargs):
        # Header only. Querystring api_key was dropped so keys do not land in
        # access logs / proxy history (breaking change, see COLLECTOR-INTEGRATION.md).
        error = _check_collector_auth(current_app.config.get('COLLECTOR_API_KEY'))
        if error is not None:
            return error
        return f(*args, **kwargs)
    return decorated


def _find_pc(hostname):
    """Find a computer by hostname, falling back to its asset number.

    Returns None if not found or if the computers plugin is unavailable;
    callers already treat None as a 404.
    """
    models = _computer_models()
    if not models:
        return None
    Computer, _ = models
    comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
    if comp:
        return comp
    return (
        Computer.query.join(Asset, Asset.assetid == Computer.assetid)
        .filter(Asset.assetnumber.ilike(hostname))
        .first()
    )


def _parse_boot(value):
    try:
        return datetime.fromisoformat(value.replace('Z', '+00:00'))
    except (ValueError, AttributeError):
        return None


def _plugin_api_key(pluginname):
    """Per-plugin collector key, falling back to the shared key (ADR-006).

    Looks up COLLECTOR_API_KEY_<PLUGINNAME> (uppercased) first so each
    collector can carry its own credential, then COLLECTOR_API_KEY.
    """
    per_plugin = current_app.config.get(
        f'COLLECTOR_API_KEY_{pluginname.upper()}')
    return per_plugin or current_app.config.get('COLLECTOR_API_KEY')


def _collector_plugins():
    """Map of pluginname -> (plugin, schema) for plugins accepting collector input."""
    pm = current_app.extensions.get('plugin_manager')
    result = {}
    if not pm:
        return result
    for name, plugin in pm.get_all_plugins().items():
        if not pm.registry.is_enabled(name):
            continue
        try:
            schema = plugin.get_collector_schema()
        except Exception:
            # Fail loud in dev/test so a broken hook is visible; isolate the
            # misbehaving plugin in prod and keep serving the healthy ones.
            if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
                raise
            current_app.logger.exception(
                'Plugin %s get_collector_schema failed', name)
            continue
        if schema:
            result[name] = (plugin, schema)
    return result


@collector_bp.route('/<pluginname>', methods=['POST'])
def generic_collect(pluginname):
    """Generic collector ingest for any plugin (ADR-006).

    Per-plugin API key auth, schema-driven validation of the identity field,
    idempotent upsert via the plugin's apply_collector_payload. Returns the
    ADR-006 response contract: status, action, assetid, identityvalue, warnings.
    """
    from shopdb.core.models import AuditLog

    plugins = _collector_plugins()
    if pluginname not in plugins:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'No collector registered for plugin {pluginname}',
            http_code=404)

    plugin, schema = plugins[pluginname]

    # Per-plugin env key (fallback) OR a collector-scoped managed token. Header
    # only (querystring fallback dropped, see require_api_key).
    error = _check_collector_auth(_plugin_api_key(pluginname))
    if error is not None:
        return error

    payload = request.get_json(silent=True)
    if not payload or not isinstance(payload, dict):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    identityfield = schema.get('identityfield')
    identityvalue = (payload.get(identityfield) or '').strip() if identityfield else ''
    if identityfield and not identityvalue:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              f'{identityfield} is required')

    try:
        outcome = plugin.apply_collector_payload(payload)
    except NotImplementedError:
        return error_response(
            ErrorCodes.INTERNAL_ERROR,
            f'Plugin {pluginname} does not implement apply_collector_payload',
            http_code=500)
    except ValueError as exc:
        # ValueError is the plugin's controlled validation signal; its message
        # is safe to return to the caller.
        db.session.rollback()
        return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
    except Exception:
        # Do not leak internals (stack detail, DB errors) to the caller; log it.
        db.session.rollback()
        current_app.logger.exception('Collector upsert failed for %s', pluginname)
        return error_response(ErrorCodes.INTERNAL_ERROR,
                              'Internal error processing collector payload',
                              http_code=500)

    action = outcome.get('action', 'noop')
    AuditLog.log(
        action if action in ('created', 'updated') else 'updated',
        'Collector',
        entityid=outcome.get('assetid'),
        entityname=outcome.get('identityvalue', identityvalue),
        details=f'collector:{pluginname} action={action}',
    )
    db.session.commit()

    response = {
        'status': 'ok',
        'action': action,
        'assetid': outcome.get('assetid'),
        'identityvalue': outcome.get('identityvalue', identityvalue),
        'warnings': outcome.get('warnings', []),
    }
    # Generic passthrough: a plugin may surface extra result fields (e.g. the
    # computers plugin returns printerlinks). Known keys win over extras.
    for key, value in (outcome.get('extra') or {}).items():
        response.setdefault(key, value)
    return success_response(response, message=f'{pluginname} collector {action}')


@collector_bp.route('/_schemas', methods=['GET'])
def collector_schemas():
    """List collector schemas for all enabled plugins (JWT-protected)."""
    from flask_jwt_extended import verify_jwt_in_request
    verify_jwt_in_request()

    schemas = {
        name: schema for name, (plugin, schema) in _collector_plugins().items()
    }
    return success_response({'schemas': schemas})


@collector_bp.route('/pc', methods=['POST'])
@require_api_key
def update_pc_info():
    """Update one PC from the collection script (matched by hostname)."""
    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

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

    comp = _find_pc(hostname)
    if not comp:
        return error_response(ErrorCodes.NOT_FOUND,
                              f'PC with hostname {hostname} not found',
                              http_code=404)

    comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)

    if data.get('lastboottime'):
        boot = _parse_boot(data['lastboottime'])
        if boot:
            comp.lastboottime = boot
    if data.get('currentuser'):
        comp.loggedinuser = data['currentuser']
    if data.get('serialnumber') and comp.asset:
        comp.asset.serialnumber = data['serialnumber']

    db.session.commit()

    return success_response({
        'computerid': comp.computerid,
        'hostname': comp.hostname,
        'updated': True
    }, message='PC info updated')


@collector_bp.route('/apps', methods=['POST'])
@require_api_key
def update_installed_apps():
    """Update installed applications for a PC (matched by hostname)."""
    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

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

    apps = data.get('apps', [])
    if not apps:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')

    comp = _find_pc(hostname)
    if not comp:
        return error_response(ErrorCodes.NOT_FOUND,
                              f'PC with hostname {hostname} not found',
                              http_code=404)

    # comp existing implies the computers plugin is loaded.
    _, ComputerInstalledApp = _computer_models()

    updated_count = created_count = skipped_count = 0

    for app_data in apps:
        app_name = app_data.get('appname')
        if not app_name:
            skipped_count += 1
            continue

        app = Application.query.filter(Application.appname.ilike(app_name)).first()
        if not app:
            # Only track applications we manage
            skipped_count += 1
            continue

        version = app_data.get('version')
        installed = ComputerInstalledApp.query.filter_by(
            computerid=comp.computerid, appid=app.appid).first()

        if installed:
            changed = False
            if version and installed.installedversion != version:
                installed.installedversion = version
                changed = True
            if not installed.isactive:
                installed.isactive = True
                changed = True
            if changed:
                updated_count += 1
        else:
            db.session.add(ComputerInstalledApp(
                computerid=comp.computerid,
                appid=app.appid,
                installedversion=version,
            ))
            created_count += 1

    db.session.commit()

    return success_response({
        'hostname': hostname,
        'computerid': comp.computerid,
        'created': created_count,
        'updated': updated_count,
        'skipped': skipped_count
    }, message='Installed apps updated')


@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
def pc_heartbeat():
    """Record PC online status / heartbeat (single or batch)."""
    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    hostnames = data.get('hostnames', [])
    if not hostnames and data.get('hostname'):
        hostnames = [data['hostname']]
    if not hostnames:
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'hostname or hostnames required')

    updated = 0
    not_found = []
    now = datetime.now(timezone.utc).replace(tzinfo=None)
    for hostname in hostnames:
        comp = _find_pc(hostname)
        if comp:
            comp.lastreporteddate = now
            updated += 1
        else:
            not_found.append(hostname)

    db.session.commit()

    return success_response({
        'updated': updated,
        'notfound': not_found,
        'timestamp': now.isoformat()
    }, message=f'{updated} PC(s) heartbeat recorded')


@collector_bp.route('/bulk', methods=['POST'])
@require_api_key
def bulk_update():
    """Bulk update multiple PCs at once."""
    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    pcs = data.get('pcs', [])
    if not pcs:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required')

    updated = 0
    not_found = []
    errors = []
    now = datetime.now(timezone.utc).replace(tzinfo=None)

    for pc_data in pcs:
        hostname = pc_data.get('hostname')
        if not hostname:
            continue

        comp = _find_pc(hostname)
        if not comp:
            not_found.append(hostname)
            continue

        try:
            comp.lastreporteddate = now
            if pc_data.get('currentuser'):
                comp.loggedinuser = pc_data['currentuser']
            if pc_data.get('lastboottime'):
                boot = _parse_boot(pc_data['lastboottime'])
                if boot:
                    comp.lastboottime = boot
            updated += 1
        except Exception:
            # Keep the hostname so the caller knows which PC failed, but do not
            # leak the exception detail; log it server-side.
            current_app.logger.exception('Bulk update failed for %s', hostname)
            errors.append({'hostname': hostname, 'error': 'processing failed'})

    db.session.commit()

    return success_response({
        'updated': updated,
        'notfound': not_found,
        'errors': errors,
        'timestamp': now.isoformat()
    }, message=f'{updated} PC(s) updated')


@collector_bp.route('/status', methods=['GET'])
@require_api_key
def collector_status():
    """Check collector API status."""
    return success_response({
        'status': 'ok',
        'timestamp': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
        'endpoints': [
            'POST /api/collector/<plugin>',
            'GET /api/collector/_schemas',
            'POST /api/collector/pc',
            'POST /api/collector/apps',
            'POST /api/collector/heartbeat',
            'POST /api/collector/bulk',
            'GET /api/collector/status'
        ]
    })
