"""Flask application factory."""

import os
import logging
from flask import Flask, send_from_directory

from .config import config
from .extensions import db, jwt, init_extensions
from .plugins import plugin_manager

# Platform contract version. See ADR-001 for the contract surface and
# ADR-002 for the bump rules. Plugins declare a compatible range in
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges.
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
# model bases, core models, response + pagination helpers, employee_connection)
# so plugins no longer import internal core paths. Additive, hence minor bump.
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
# core concern over the asset model) and wired the get_dashboard_widgets hook to
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
# minor bump.
# 0.10.0: added the get_permissions hook so a plugin declares the RBAC
# permissions its own routes enforce, instead of core accumulating them in one
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
# which backs seeding, the role grid, and API-token scope validation. Additive
# optional hook, minor bump.
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
# 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting
# rows it owns (key, value, type, category, description, public). The framework
# seeds them at install, at enable, and on `flask plugin upgrade-all`, files a
# first-time write under the declared category, and lets a plugin mark a key
# readable without auth for pages that run logged out. Additive optional hook,
# minor bump.
# 0.18.0: added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role
# beside DashboardDefault (which 0.17.0 imported but never listed in __all__).
# The role vocabulary is now the kiosk's own - Dashboard / Lobby / 3DPrintRoom,
# the literal values of C:\Enrollment\display-type.txt - so a plugin holding its
# own copy of that map (geenforce did) can read core's instead of drifting from
# it. Additive names on the import surface, minor bump.
# 0.19.0: get_dashboard_widgets returns DATA AND SHAPE, not a component name.
# The old shape ('name' + 'component' + 'size') named a Vue component per
# widget, which cannot survive a lean build - a plugin's component may never be
# staged into the frontend bundle (ADR-013) - and five plugins were declaring
# widgets that pointed at components nobody had written. Core now owns the
# renderers and a card declares id / title / endpoint / render / severity /
# permission / empty / position. BREAKING for any plugin still using the old
# shape, which is why it is recorded here: the change itself shipped earlier
# without a bump, and a contract that changes silently is not a contract.
__contract_version__ = '0.20.0'

# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
# bump rules. Not part of the shopdb.api contract surface, so it is
# not re-exported there.
__version__ = '0.12.0'


def create_app(config_name: str = None) -> Flask:
    """
    Application factory.

    Args:
        config_name: Configuration name ('development', 'production', 'testing')

    Returns:
        Configured Flask application
    """
    if config_name is None:
        config_name = os.environ.get('FLASK_ENV', 'development')

    app = Flask(__name__, instance_relative_config=True)

    config_class = config.get(config_name, config['default'])

    # Production must validate its env-driven config before boot.
    if config_name == 'production' and hasattr(config_class, 'validate'):
        config_class.validate()

    app.config.from_object(config_class)

    # The product and contract versions, in config so a PLUGIN can read them
    # through current_app instead of importing core (ADR-002 forbids that, and
    # the contract test enforces it). Core code may still use the module
    # constants directly.
    app.config['VERSION'] = __version__
    app.config['CONTRACT_VERSION'] = __contract_version__

    # Load instance config if exists
    app.config.from_pyfile('config.py', silent=True)

    # Per-plugin collector keys (ADR-006) are dynamic env-vars
    # (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
    # they are not class attributes. Copy them in explicitly so per-plugin
    # credential isolation works in real deploys, not just tests.
    for envname, envvalue in os.environ.items():
        if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
            app.config[envname] = envvalue

    # Ensure instance folder exists
    os.makedirs(app.instance_path, exist_ok=True)

    # Configure logging
    configure_logging(app)

    # Initialize extensions
    init_extensions(app)

    # An optional unique column accepts any number of NULLs but exactly one
    # empty string, so a second blank code collided and became a 500. Normalise
    # blank to NULL once, at the mapper, rather than in each endpoint.
    from .utils.blankunique import register_blank_unique_normaliser
    register_blank_unique_normaliser()

    # Initialize plugin manager
    with app.app_context():
        plugin_manager.init_app(app, db)

    # Register core blueprints
    register_blueprints(app)

    # Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
    # any JWT decode and mint a request-scoped JWT for the token's owner.
    from .utils.apitoken_auth import install_apitoken_auth
    install_apitoken_auth(app)

    # Register CLI commands
    register_cli_commands(app)

    # Register error handlers
    register_error_handlers(app)

    # Serve Vue frontend
    register_frontend_routes(app)

    # JWT user loader (identity is a string in JWT, convert to int for DB lookup)
    @jwt.user_lookup_loader
    def user_lookup_callback(_jwt_header, jwt_data):
        from .core.models import User
        identity = jwt_data["sub"]
        return db.session.get(User, int(identity))

    @app.after_request
    def apply_security_headers(response):
        """Baseline response headers. The app shipped with none of these.

        Deliberately the three that cost nothing and break nothing:

          nosniff        a response whose bytes and declared type disagree is
                         not re-typed by the browser into something executable.
                         This is the general form of the upload problem that
                         shopdb/utils/uploads.py addresses per-file.
          frame-ancestors
                         same-origin only. The kiosks open routes directly
                         rather than framing them, so this costs the fleet
                         nothing and stops the UI being framed elsewhere and
                         clicked through. Sent as X-Frame-Options too, because
                         the display bays run browsers old enough to want it.
          Referrer-Policy
                         an asset id or a hostname in a path is not handed to
                         whatever a user clicks through to.

        NOT a full page CSP. This app serves an SPA with inline styles, so a
        real script-src policy is a change worth making on its own with the
        frontend in front of you - claiming one here by adding a permissive
        header would be worse than having none.
        """
        response.headers.setdefault('X-Content-Type-Options', 'nosniff')
        response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
        response.headers.setdefault('Referrer-Policy',
                                    'strict-origin-when-cross-origin')
        # setdefault: an upload response has already declared its own, stricter
        # Content-Security-Policy, and this must not weaken it.
        response.headers.setdefault('Content-Security-Policy',
                                    "frame-ancestors 'self'")
        return response

    return app


CORE_BLUEPRINT_NAMES = (
    'auth',
    'assets',
    'modeltypes',
    'plugins',
    'vendors',
    'models',
    'businessunits',
    'locations',
    'maplevels',
    'mappositions',
    'operatingsystems',
    'dashboard',
    'dashboarddefaults',
    'applications',
    'supportteams',
    'search',
    'reports',
    'collector',
    'settings',
    'auditlogs',
    'users',
    'customfields',
    'setup',
    'pluginui',
    'apitokens',
    'docs',
)


def register_blueprints(app: Flask):
    """Register core API blueprints from CORE_BLUEPRINT_NAMES.

    Each entry maps to an attribute `<name>_bp` exported by
    `shopdb.core.api` and a URL prefix `/api/<name>`. Adding a new
    core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line
    edit in this function.
    """
    from .core import api as api_module

    api_prefix = '/api'
    for name in CORE_BLUEPRINT_NAMES:
        attr_name = f'{name}_bp'
        if not hasattr(api_module, attr_name):
            raise RuntimeError(
                f'Core blueprint "{attr_name}" missing from shopdb.core.api. '
                f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.'
            )
        bp = getattr(api_module, attr_name)
        app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}')


def register_cli_commands(app: Flask):
    """Register Flask CLI commands."""
    from .plugins.cli import plugin_cli
    from .cli import db_cli, seed_cli, relationships_cli, csv_cli

    app.cli.add_command(plugin_cli)
    app.cli.add_command(db_cli)
    app.cli.add_command(seed_cli)
    app.cli.add_command(relationships_cli)
    app.cli.add_command(csv_cli)


def register_error_handlers(app: Flask):
    """Register error handlers."""
    from .utils.responses import error_response, ErrorCodes
    from .exceptions import ShopDBException

    # A uniqueness violation is the caller's problem, not a server fault. Without
    # this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and
    # nothing usable on screen - the operator saw "internal server error" for
    # having reused a code that was already taken.
    from sqlalchemy.exc import IntegrityError

    @app.errorhandler(IntegrityError)
    def handle_integrity_error(error):
        from .extensions import db
        db.session.rollback()
        message = str(getattr(error, 'orig', error))
        app.logger.warning('integrity error: %s', message)
        if 'Duplicate entry' in message or 'UNIQUE constraint' in message:
            return error_response(
                ErrorCodes.CONFLICT,
                'That value is already in use. Codes and identifiers must be unique.',
                http_code=409)
        if 'foreign key constraint' in message.lower():
            return error_response(
                ErrorCodes.VALIDATION_ERROR,
                'That record refers to something that does not exist, or is still in use elsewhere.',
                http_code=400)
        return error_response(ErrorCodes.VALIDATION_ERROR,
                              'The database rejected that change.', http_code=400)

    @app.errorhandler(ShopDBException)
    def handle_shopdb_exception(error):
        http_codes = {
            'NOT_FOUND': 404,
            'UNAUTHORIZED': 401,
            'FORBIDDEN': 403,
            'CONFLICT': 409,
            'VALIDATION_ERROR': 400,
        }
        http_code = http_codes.get(error.code, 400)
        return error_response(
            error.code,
            error.message,
            details=error.details,
            http_code=http_code
        )

    @app.errorhandler(404)
    def not_found_error(error):
        return error_response(
            ErrorCodes.NOT_FOUND,
            'Resource not found',
            http_code=404
        )

    @app.errorhandler(500)
    def internal_error(error):
        return error_response(
            ErrorCodes.INTERNAL_ERROR,
            'An internal error occurred',
            http_code=500
        )

    @app.errorhandler(401)
    def unauthorized_error(error):
        return error_response(
            ErrorCodes.UNAUTHORIZED,
            'Authentication required',
            http_code=401
        )


def register_frontend_routes(app: Flask):
    """Serve Vue frontend static files."""
    frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')

    @app.route('/', defaults={'path': ''})
    @app.route('/<path:path>')
    def serve_frontend(path):
        # Don't serve API routes as frontend
        if path.startswith('api/'):
            from .utils.responses import error_response, ErrorCodes
            return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)

        # Try to serve a static asset directly. send_from_directory handles
        # the safe-join + 404 itself; no explicit existence probe needed
        # (the probe was a path-traversal risk surface).
        if path:
            try:
                response = send_from_directory(frontend_dist, path)
                # Asset filenames carry a content hash, so a given URL never
                # changes - cache them hard. Everything else stays revalidated.
                if path.startswith('assets/'):
                    response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
                else:
                    response.headers['Cache-Control'] = 'no-cache'
                return response
            except Exception:
                pass

        # index.html names the hashed chunks, so a stale copy points at files a
        # deploy has already deleted and the SPA stops navigating. Always
        # revalidate it.
        response = send_from_directory(frontend_dist, 'index.html')
        response.headers['Cache-Control'] = 'no-cache'
        return response


def configure_logging(app: Flask):
    """Configure application logging."""
    log_level = app.config.get('LOG_LEVEL', 'INFO')

    logging.basicConfig(
        level=getattr(logging, log_level),
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
