# Copyright (c) 2025 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError

from datetime import datetime, timedelta
import random
import re
import uuid

from flask import Flask, request, jsonify
from sqlalchemy.exc import IntegrityError, DisconnectionError, OperationalError

from oslo_log import log as logging
import oslo_messaging
from oslo_messaging.target import Target

from openstack import connection
from keystoneauth1 import loading as ks_loading
from keystoneauth1 import session as ks_session

from vigie import utils as vgutils
from vigie.config import get_config as get_vigie_config
from vigie.db.sqlalchemy.database import init_database_config
from vigie.db.sqlalchemy.database import get_session as get_db_session
from vigie.db.sqlalchemy.models import VigieTasks, TaskState, TaskType, Service as ServiceModel
from vigie.policy import enforcer as policy_enforcer

# Initialize logging
LOG = logging.getLogger(__name__)

get_config = get_vigie_config
CONF = get_config()
logging.setup(CONF, 'vigie-api')

# Create single Flask app with all routes
app = Flask(__name__)

_rpc_executor = ThreadPoolExecutor(max_workers=1)

policy_checker = policy_enforcer.require_policy_factory(get_config)


def is_valid_hostname(hostname):
    # Remove trailing dot if present (FQDN notation)
    if hostname.endswith('.'):
        hostname = hostname[:-1]

    # Check total length
    if len(hostname) > 253 or len(hostname) == 0:
        return False

    pattern = r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$'
    return bool(re.match(pattern, hostname))


def get_openstack_connection():
    """
    Create and return an OpenStack SDK connection using keystone_authtoken config.

    This function loads authentication credentials from the keystone_authtoken
    section of the config file, ensuring consistency with the Keystone middleware
    used for API authentication.

    Returns:
        openstack.connection.Connection: OpenStack SDK connection object

    Raises:
        Exception: If connection cannot be established (auth failure, config error, etc.)
    """
    try:
        # Load authentication from keystone_authtoken section in config
        auth = ks_loading.load_auth_from_conf_options(CONF, 'keystone_authtoken')
        # Load session from config
        session = ks_session.Session(auth=auth, verify=True)
        # Create and return OpenStack connection using the session
        return connection.Connection(session=session, region_name=CONF.keystone_authtoken.region_name)

    except Exception as e:
        LOG.error(f"Failed to create OpenStack connection: {e}", exc_info=True)
        raise


@app.route('/healthcheck', methods=['GET'])
def health_check():
    """Health check endpoint - no authentication required"""
    return jsonify({'status': 'ok', 'service': 'vigie'}), 200


@app.route('/v2/services-list', methods=['GET'])
@policy_checker('vigie:services_list')
def services_list():
    """List all registered Vigie services (workers)."""
    session = None
    try:
        session = get_db_session()

        services = session.query(ServiceModel).all()
        result = [svc.to_dict() for svc in services]

        return jsonify({
            'services': result,
            'count': len(result)
        }), 200

    except Exception as e:
        LOG.error(f"Error listing services: {e}", exc_info=True)
        return jsonify({'error': str(e)}), 500
    finally:
        if session:
            session.close()


@app.route('/v2/services-delete', methods=['POST'])
@policy_checker('vigie:services_delete')
def services_delete():
    """Delete one or more services by ID."""
    try:
        data = request.get_json()

        if not data or 'service_ids' not in data:
            LOG.error("Error parsing services_delete() params: missing service_ids parameter.")
            return jsonify({'error': 'Missing required field: service_ids'}), 400

        service_ids = data['service_ids']

        if not isinstance(service_ids, list) or len(service_ids) == 0:
            LOG.error("service_ids must be a non-empty list.")
            return jsonify({'error': 'service_ids must be a non-empty list'}), 400

        # Validate all service_ids are valid UUIDs
        validated_ids = []
        for service_id in service_ids:
            if not isinstance(service_id, str):
                LOG.error(f"Service ID must be a string, got: {type(service_id)}")
                return jsonify({'error': f'Service ID must be a string: {service_id}'}), 400
            try:
                uuid.UUID(service_id, version=4)
                validated_ids.append(service_id)
            except ValueError:
                LOG.error(f"Invalid UUID format for deletion: {service_id}")
                return jsonify({'error': f'Invalid UUID format: {service_id}'}), 400

    except Exception as e:
        LOG.error(f"Error parsing services_delete() params: {e}", exc_info=True)
        return jsonify({'error': 'Invalid parameters'}), 400

    session = None
    try:
        session = get_db_session()

        # Check which IDs actually exist
        existing_ids = {row[0] for row in session.query(ServiceModel.id).filter(
            ServiceModel.id.in_(validated_ids)
        ).all()}

        # If any IDs are missing, return 404
        missing_ids = set(validated_ids) - existing_ids
        if missing_ids:
            LOG.error(f"Services not found: {missing_ids}")
            return jsonify({
                'error': 'One or more services not found',
                'missing_ids': list(missing_ids)
            }), 404

        # Bulk delete
        deleted_count = session.query(ServiceModel).filter(
            ServiceModel.id.in_(validated_ids)
        ).delete(synchronize_session=False)

        session.commit()

        LOG.info(f"Deleted {deleted_count} services: {validated_ids}")

        return jsonify({
            'message': f'Deleted {deleted_count} service(s)',
            'deleted_count': deleted_count,
            'service_ids': validated_ids
        }), 200

    except Exception as e:
        if session:
            session.rollback()
        LOG.error(f"Error deleting services: {e}", exc_info=True)
        return jsonify({'error': str(e)}), 500
    finally:
        if session:
            session.close()


@app.route('/v2/service/<service_id>', methods=['PUT'])
@policy_checker('vigie:service_update')
def update_service(service_id):
    """Enable or disable a service with optional reason."""

    # Example JSON to disable: '{"enabled": false, "disabled_reason": "Hardware failure"}'
    # Example JSON to enable: '{"enabled": true}'

    session = None
    try:
        data = request.get_json()
        if not data:
            return jsonify({'error': 'No JSON data provided'}), 400

        # Validate UUID
        try:
            uuid.UUID(service_id, version=4)
        except ValueError:
            return jsonify({'error': 'Invalid service UUID'}), 400

        session = get_db_session()
        service = session.query(ServiceModel).filter(ServiceModel.id == service_id).first()

        if not service:
            return jsonify({'error': 'Service not found'}), 404

        # Update enabled state
        enabled = data.get('enabled')
        if enabled is not None:
            if not isinstance(enabled, bool):
                return jsonify({'error': 'enabled must be a boolean'}), 400
            service.disabled = not enabled  # Note: disabled = not enabled

        # Update disabled reason (only if disabling)
        disabled_reason = data.get('disabled_reason')
        if disabled_reason is not None:
            if not isinstance(disabled_reason, str):
                return jsonify({'error': 'disabled_reason must be a string'}), 400
            # If disabling, reason is required
            if service.disabled and disabled_reason == "":
                return jsonify({'error': 'disabled_reason cannot be empty when disabling'}), 400
            service.disabled_reason = disabled_reason
        elif service.disabled and disabled_reason is None:
            # If disabling and no reason provided, clear it (or leave as is)
            pass

        session.commit()

        return jsonify({
            'message': f'Service {service_id} updated',
            'service': service.to_dict()
        }), 200

    except Exception as e:
        if session:
            session.rollback()
        LOG.error(f"Error updating service {service_id}: {e}", exc_info=True)
        return jsonify({'error': 'Failed to update service'}), 500
    finally:
        if session:
            session.close()


@app.route('/v2/tasks-delete', methods=['POST'])
@policy_checker('vigie:tasks_delete')
def tasks_delete():
    """Delete one or more tasks"""
    try:
        data = request.get_json()

        if not data or 'task_ids' not in data:
            LOG.error(f"Error parsing tasks_delete() params: missing task_ids parameter.")
            return jsonify({'error': 'Missing required field: task_ids'}), 400

        task_ids = data['task_ids']

        if not isinstance(task_ids, list) or len(task_ids) == 0:
            LOG.error("task_ids must be a non-empty list.")
            return jsonify({'error': 'task_ids must be a non-empty list'}), 400

        # Validate all task_ids are valid UUIDs
        validated_ids = []
        for task_id in task_ids:
            if not isinstance(task_id, str):
                LOG.error(f"Task ID must be a string, got: {type(task_id)}")
                return jsonify({'error': f'Task ID must be a string: {task_id}'}), 400
            try:
                # Validate UUID format
                uuid.UUID(task_id, version=4)
                validated_ids.append(task_id)
            except ValueError:
                LOG.error(f"Invalid UUID format for deletion: {task_id}")
                return jsonify({'error': f'Invalid UUID format: {task_id}'}), 400

    except Exception as e:
        LOG.error(f"Error parsing tasks_delete() params: {e}", exc_info=True)
        return jsonify({'error': 'Invalid parameters'}), 400

    session = None
    try:
        session = get_db_session()

        existing_ids = {row[0] for row in session.query(VigieTasks.id).filter(VigieTasks.id.in_(validated_ids)).all()}

        # Validate that all task IDs posted exist in DB.
        # If any miss-match, exit with 404.
        missing_ids = set(validated_ids) - existing_ids
        if missing_ids:
            LOG.error(f"Tasks not found: {missing_ids}")
            return jsonify({
                'error': 'One or more tasks not found',
                'missing_ids': list(missing_ids)
            }), 404

        # Bulk delete all at once.
        deleted_count = session.query(VigieTasks).filter(VigieTasks.id.in_(validated_ids)).delete(
            synchronize_session=False
        )

        session.commit()

        LOG.info(f"Deleted {deleted_count} tasks: {validated_ids}")

        return jsonify({
            'message': f'Deleted {deleted_count} task(s)',
            'deleted_count': deleted_count,
            'task_ids': validated_ids
        }), 200

    except Exception as e:
        if session:
            session.rollback()
        LOG.error(f"Error deleting tasks: {e}", exc_info=True)
        return jsonify({'error': str(e)}), 500

    finally:
        if session:
            session.close()


@app.route('/v2/tasks-list', methods=['GET'])
@policy_checker('vigie:tasks_list')
def tasks_list():
    """List all tasks"""
    try:
        state_filter = request.args.get('state', None)
        # For pagination, get the limit param
        limit = request.args.get('limit', 1000, type=int)

        if state_filter:
            valid_states = [state.value for state in TaskState]
            if state_filter not in valid_states:
                return jsonify({
                    'error': f'Invalid state. Allowed: {", ".join(valid_states)}'
                }), 400
    except Exception as e:
        LOG.error(f"Error parsing tasks_list() params: {e}", exc_info=True)
        return jsonify({'error': 'Invalid parameters'}), 400

    session = None
    try:
        session = get_db_session()

        query = session.query(VigieTasks)
        if state_filter:
            query = query.filter(VigieTasks.state == TaskState(state_filter))

        # Query with pagination
        tasks = query.limit(limit).all()
        result = [task.to_dict() for task in tasks]
        return jsonify({
            'tasks': result,
            'count': len(result),
            'limit': limit,
            'state_filter': state_filter
        }), 200

    except Exception as e:
        LOG.error(f"Error listing tasks: {e}", exc_info=True)
        return jsonify({'error': str(e)}), 500
    finally:
        if session:
            session.close()


def _send_rpc_to_enabled_workers(task_id, compute_hostname, project_id, timeout=10):
    """Send RPC cast to ONE random enabled and alive vigie-worker.

    Queries the database for enabled vigie-worker services, filters out those
    with stale heartbeats, picks one randomly, and sends the RPC message to
    that worker's specific hostname-based topic.

    :param task_id: The task UUID to process
    :param compute_hostname: The compute node hostname to validate
    :param project_id: The project ID for the task
    :param timeout: RPC send timeout in seconds
    :return: The selected service object
    :raises: oslo_messaging.MessagingException if no workers available
    """
    session = None
    try:
        session = get_db_session()

        # Query enabled vigie-worker services
        enabled_services = session.query(ServiceModel).filter(
            ServiceModel.binary == 'vigie-worker',
            ServiceModel.disabled == False
        ).all()

        if not enabled_services:
            raise oslo_messaging.MessagingException("No enabled vigie-worker services found")

        # Filter out workers with stale heartbeats
        heartbeat_timeout = CONF.rpc_heartbeat_timeout
        cutoff_time = datetime.utcnow() - timedelta(seconds=heartbeat_timeout * 2)  # Allow 2x tolerance

        alive_services = [
            svc for svc in enabled_services
            if svc.last_seen_up and svc.last_seen_up >= cutoff_time
        ]

        if not alive_services:
            LOG.error(f"No alive vigie-workers found (heartbeat timeout: {heartbeat_timeout}s)")
            raise oslo_messaging.MessagingException("No alive vigie-worker services available")

        # Pick ONE worker randomly for load balancing
        selected_service = random.choice(alive_services)

        transport = oslo_messaging.get_transport(CONF)
        target = Target(topic=selected_service.topic, server=selected_service.host)
        client = oslo_messaging.RPCClient(transport, target)

        future = _rpc_executor.submit(
            client.cast,
            {},
            'validate_compute',
            task_id=task_id,
            compute_hostname=compute_hostname,
            project_id=project_id,
            project_name='admin'
        )
        future.result(timeout=timeout)

        LOG.info(f"Sent validate_compute task {task_id} to {selected_service.host} ({selected_service.topic})")
        return selected_service

    except FuturesTimeoutError:
        raise oslo_messaging.MessagingTimeout(
            f"Failed to send message within {timeout} seconds"
        )
    except Exception as e:
        LOG.error(f"Failed to send task {task_id} to worker: {e}")
        raise
    finally:
        if session:
            session.close()


def _cleanup_failed_task(task_id):
    """Delete a task that failed to be dispatched to worker."""
    session = None
    try:
        session = get_db_session()
        session.query(VigieTasks).filter(VigieTasks.id == task_id).delete()
        session.commit()
        LOG.info(f"Cleaned up failed task {task_id}")
    except Exception as e:
        LOG.error(f"Failed to cleanup task {task_id}: {e}", exc_info=True)
    finally:
        if session:
            session.close()


@app.route('/v2/validate-compute', methods=['POST'])
@policy_checker('vigie:validate_compute')
def validate_compute():
    """Validate a compute node by spawning a VM on it."""
    try:
        # Get query parameters
        to_validate_hostname = request.args.get('hostname', None)

        if to_validate_hostname is None:
            LOG.error("Missing hostname parameter.", exc_info=True)
            return jsonify({'error': 'Missing hostname parameter'}), 500
    except Exception as e:
        LOG.error(f"Error {e} when parsing validate_compute() params.", exc_info=True)
        return jsonify({'error': 'Invalid hostname parameters'}), 500

    if not is_valid_hostname(to_validate_hostname):
        LOG.error(f"Hostname parameter is not a valid hostname.", exc_info=True)
        return jsonify({'error': 'Invalid hostname format'}), 500

    try:
        conn = get_openstack_connection()
        services = vgutils.get_nova_compute_service(conn, to_validate_hostname)
    except Exception as e:
        LOG.error(f"OpenStack operation failed: {e}.")
        return jsonify({'error': f'OpenStack API error getting compute service: {e}'}), 500

    if not services:
        LOG.error(f"nova-compute service not found for '{to_validate_hostname}'.")
        return jsonify({'error': f'Compute service not found for {to_validate_hostname}'}), 404

    service = services[0]
    if service.state != 'up':
        LOG.error(f"Hypervisor {to_validate_hostname} is down.")
        return jsonify({'error': f'Hypervisor {to_validate_hostname} is down.'}), 500

    try:
        admin_project = conn.identity.find_project('admin', ignore_missing=False)
        if admin_project is None:
            LOG.error("Admin project not found in Keystone.")
            return jsonify({'error': 'Admin project not found'}), 500

        admin_project_id = admin_project.id

    except Exception as e:
        LOG.error(f"Failed to fetch admin project: {e}", exc_info=True)
        return jsonify({'error': 'Failed to fetch admin project'}), 500

    task = None
    try:
        session = get_db_session()

        task = VigieTasks(
            id=str(uuid.uuid4()),
            project_id=admin_project_id,  # System-level task, not project-specific
            project_name='admin',
            compute_hostname=to_validate_hostname,
            scheduled_time=datetime.utcnow(),
            state=TaskState.SCHEDULED,
            task_type=TaskType.VALIDATE_COMPUTE
        )

        session.add(task)
        session.commit()
        LOG.info(f"Task {task.id} created for validation of {to_validate_hostname}.")

    except IntegrityError as e:
        session.rollback()
        LOG.error(f"Database integrity error: {e}", exc_info=True)
        return jsonify({'error': 'Database error'}), 500
    except (DisconnectionError, OperationalError) as e:
        session.rollback()
        LOG.error(f"Database connection error: {e}", exc_info=True)
        return jsonify({'error': 'Database connection error'}), 500
    except Exception as e:
        session.rollback()
        LOG.error(f"Failed to create task record: {e}", exc_info=True)
        return jsonify({'error': 'Failed to schedule validation'}), 500
    finally:
        session.close()

    try:
        _send_rpc_to_enabled_workers(task.id, to_validate_hostname, admin_project_id)

        LOG.info(f"Message sent to vigie-worker for task {task.id}.")

    except oslo_messaging.MessagingTimeout as e:
        # Timeout - rollback the task since worker will never process it
        LOG.error(f"Messaging timeout sending task {task.id}: {e}", exc_info=True)
        _cleanup_failed_task(task.id)
        return jsonify({
            'error': 'Messaging service timeout - rabbitmq unavailable'
        }), 503
    except oslo_messaging.MessagingException as e:
        # Other messaging errors (connection failed, etc.)
        LOG.error(f"Messaging error sending task {task.id}: {e}", exc_info=True)
        _cleanup_failed_task(task.id)
        return jsonify({
            'error': 'Messaging service unavailable'
        }), 503

    except Exception as e:
        LOG.error(f"Failed to send notification to worker: {e}", exc_info=True)
        _cleanup_failed_task(task.id)
        return jsonify({'error': f'Error sending message to worker: {e}'}), 500

    return jsonify({
        'message': f'Scheduled validation of {to_validate_hostname}.',
        'task_id': task.id
    }), 202


@app.route('/v2/purge-project', methods=['POST'])
@policy_checker('vigie:purge_project')
def purge_project():
    """Purge a project"""
    try:
        data = request.get_json()
        
        if not data or ('project_id' not in data
                        and 'project_name' not in data):
            return jsonify({'error': 'Missing required field: project_id '
                                     'or project_name'}), 400
    except Exception as e:
        LOG.error(f"Error purging project: {e}", exc_info=True)
        return jsonify({'error': str(e)}), 500


@app.route('/v2/evacuate-compute', methods=['POST'])
@policy_checker('vigie:evacuate_compute')
def evacuate_compute():
    """Evacuate a compute node"""
    try:
        # Get query parameters
        source_hostname = request.args.get('hostname', None)

        if source_hostname is None:
            LOG.error("Missing hostname parameter.", exc_info=True)
            return jsonify({'error': 'Missing hostname parameter'}), 500
    except Exception as e:
        LOG.error(f"Error {e} when parsing evacuate_compute() params.", exc_info=True)
        return jsonify({'error': 'Error hostname parsing arguments'}), 500

    if not is_valid_hostname(source_hostname):
        LOG.error(f"Hostname parameter is not a valid hostname.", exc_info=True)
        return jsonify({'error': 'Hostname parameter is not a valid hostname'}), 500

    # TODO(zigo): Implement evacuation

    return jsonify({'message': f'Scheduled evacuation of {source_hostname}.'}), 200


def get_application():
    """Create WSGI application with Keystone authentication"""
    try:
        # Initialize database configuration
        init_database_config(CONF)

        # Apply Keystone middleware with delay_auth_decision=true
        from keystonemiddleware import auth_token
        middleware_conf = dict(CONF.keystone_authtoken)
        # Ensure delay_auth_decision is true for selective auth
        middleware_conf['delay_auth_decision'] = True
        application = auth_token.filter_factory(middleware_conf)(app)
        return application
    except Exception as e:
        LOG.error(f"Error creating application: {e}", exc_info=True)
        return app
