import datetime
import os
import secrets
import typing as t
from typing import TYPE_CHECKING, Any, Literal

import sentry_sdk
from flask import Flask, Response, current_app, redirect, render_template, request, url_for
from flask.typing import ResponseReturnValue
from flask_admin import Admin
from flask_babel import Babel
from flask_sqlalchemy_lite import SQLAlchemy
from flask_wtf.csrf import generate_csrf
from govuk_frontend_wtf.main import WTFormsHelpers
from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader
from sqlalchemy.exc import NoResultFound, ProgrammingError
from werkzeug.routing import BaseConverter, ValidationError
from xgovuk_flask_admin import XGovukFlaskAdmin
from xgovuk_flask_admin.theme import XGovukFrontendTheme

from app import logging
from app.common.auth.authorisation_helper import AuthorisationHelper
from app.common.data import interfaces
from app.common.data.interfaces.system import seed_system_data
from app.common.data.types import (
    CollectionAdminEmailTypeEnum,
    CollectionStatusEnum,
    CollectionType,
    ComponentType,
    DataSourceType,
    ExpressionType,
    FileUploadTypes,
    FormRunnerState,
    GrantStatusEnum,
    MaximumFileSize,
    NumberTypeEnum,
    OrganisationType,
    QuestionDataType,
    RoleEnum,
    SubmissionAssessmentStatusEnum,
    SubmissionModeEnum,
    SubmissionStatusEnum,
    TasklistSectionStatusEnum,
)
from app.common.exceptions import RedirectException
from app.common.expressions.references import ExpressionReference
from app.common.filters import (
    format_date,
    format_date_approximate,
    format_date_range,
    format_date_range_short,
    format_date_short,
    format_datetime,
    format_datetime_range,
    format_datetime_short,
    format_thousands,
    iso_utc,
    to_ordinal,
)
from app.common.helpers.collections import SubmissionAuthorisationError
from app.common.helpers.feature_flags import FeatureFlags
from app.common.helpers.request_tracing import get_tracing_state
from app.common.utils import comma_join_items, slugify, uppercase_first
from app.config import get_settings
from app.constants import DATA_SET_EXTERNAL_ID_COLUMN_HEADER, DATA_SET_GRANT_RECIPIENT_COLUMN_HEADER
from app.extensions import (
    auto_commit_after_request,
    db,
    flask_assets_vite,
    govuk_markdown,
    login_manager,
    migrate,
    notification_service,
    psycopg_citext,
    record_sqlalchemy_queries,
    register_signals,
    s3_service,
    talisman,
    toolbar,
)
from app.monkeypatch import patch_sqlalchemy_lite_async
from app.sentry import init_sentry
from app.types import FlashMessageType

if TYPE_CHECKING:
    from app.common.data.models_user import User

init_sentry(os.environ.get("SECRET_KEY", secrets.token_urlsafe(32)))


def _register_global_error_handlers(app: Flask) -> None:
    def _determine_service_components_based_on_request_url(request_url: str) -> tuple[str, str]:
        if "/deliver/" in request_url:
            service_desk_url = str(app.config["DELIVER_SERVICE_DESK_URL"])
            base_template = "deliver_grant_funding/base.html"
        elif "/access/" in request_url:
            service_desk_url = str(app.config["ACCESS_SERVICE_DESK_URL"])
            base_template = "access_grant_funding/base.html"
        else:
            service_desk_url = str(app.config["SERVICE_DESK_URL"])
            base_template = "common/base.html"
        return service_desk_url, base_template

    @app.errorhandler(403)
    def handle_403(error: Literal[403]) -> ResponseReturnValue:
        service_desk_url, base_template = _determine_service_components_based_on_request_url(request.url)
        return render_template(
            "common/errors/403.html", service_desk_url=service_desk_url, base_template=base_template
        ), 403

    @app.errorhandler(SubmissionAuthorisationError)
    def handle_submission_authorisation_error(error: SubmissionAuthorisationError) -> ResponseReturnValue:
        service_desk_url, base_template = _determine_service_components_based_on_request_url(request.url)
        return render_template(
            "common/errors/403.html", service_desk_url=service_desk_url, base_template=base_template
        ), 403

    @app.errorhandler(NoResultFound)
    def handle_sqlalchemy_no_result(error: NoResultFound) -> ResponseReturnValue:
        service_desk_url, base_template = _determine_service_components_based_on_request_url(request.url)

        return render_template(
            "common/errors/404.html", service_desk_url=service_desk_url, base_template=base_template
        ), 404

    @app.errorhandler(404)
    def handle_404(error: Literal[404]) -> ResponseReturnValue:
        service_desk_url, base_template = _determine_service_components_based_on_request_url(request.url)

        return render_template(
            "common/errors/404.html", service_desk_url=service_desk_url, base_template=base_template
        ), 404

    @app.errorhandler(500)
    def handle_500(error: Literal[500]) -> ResponseReturnValue:
        current_app.logger.error("Internal server error", exc_info=True)
        service_desk_url, base_template = _determine_service_components_based_on_request_url(request.url)

        return render_template(
            "common/errors/500.html", service_desk_url=service_desk_url, base_template=base_template
        ), 500

    @app.errorhandler(RedirectException)
    def handle_redirect(error: RedirectException) -> ResponseReturnValue:
        return redirect(error.url)


def _register_custom_converters(app: Flask) -> None:
    """
    This registers some custom converters for URL routing in Flask.

    We're used to standard converts like uuid, eg:

        @app.route("/grant/<uuid:grant_id")
        def handler(grant_id: uuid.UUID):
            ...

    Here we register some Enums to use as converters as well, so that the plaintext representation in a URL
    is converted automatically to an enum value, eg:

        @app.route("/submissions/<submission_mode:mode>")
        def handler(submission_mode: SubmissionModeEnum):
            ...
    """

    class SubmissionModeConverter(BaseConverter):
        def to_python(self, value: str) -> t.Any:
            value = value.lower()
            return SubmissionModeEnum(value.lower())

        def to_url(self, value: t.Any) -> str:
            return str(value).lower()

    class ExpressionReferenceConverter(BaseConverter):
        def to_python(self, value: str) -> ExpressionReference:
            return ExpressionReference(value)

        def to_url(self, value: str | ExpressionReference) -> str | ExpressionReference:
            return value

    class CollectionTypeConverter(BaseConverter):
        def to_python(self, value: str) -> CollectionType:
            try:
                return CollectionType.from_slug(value)
            except ValueError as e:
                # validation error raised in the context of rule converter will indicate the URL doesn't match
                # and move on to match other routes or eventually 404
                raise ValidationError from e

        def to_url(self, value: CollectionType) -> str:
            return value.constants.slug

    app.url_map.converters["submission_mode"] = SubmissionModeConverter
    app.url_map.converters["expression_reference"] = ExpressionReferenceConverter
    app.url_map.converters["collection_type"] = CollectionTypeConverter


def _setup_flask_admin(app: Flask, db_: SQLAlchemy) -> None:
    from app.deliver_grant_funding.admin import register_admin_views
    from app.deliver_grant_funding.admin.views import PlatformAdminIndexView

    flask_admin = Admin(
        app,
        name="Funding Service admin",
        endpoint="platform_admin",
        url="/deliver/admin",
        index_view=PlatformAdminIndexView(url="/deliver/admin", endpoint="platform_admin"),
        theme=XGovukFrontendTheme(base_template="deliver_grant_funding/admin/mhclg_base.html"),
        csp_nonce_generator=app.jinja_env.globals["csp_nonce"],  # ty:ignore[invalid-argument-type]
    )
    XGovukFlaskAdmin(app)
    with app.app_context():
        register_admin_views(flask_admin, db_)


def create_app() -> Flask:  # noqa: C901
    from app.common.data.base import BaseModel

    app = Flask(__name__, static_folder="assets/dist/", static_url_path="/static")
    app.config.from_object(get_settings())

    # Initialise extensions
    logging.init_app(app)
    patch_sqlalchemy_lite_async()
    db.init_app(app)
    psycopg_citext.init_app(app, db)
    auto_commit_after_request.init_app(app)
    migrate.init_app(
        app,
        db,  # ty: ignore[invalid-argument-type]  # not natively compatible with Flask-SQLAlchemy-Lite; but is fine for us.
        directory="app/common/data/migrations",
        compare_type=True,
        render_as_batch=True,
        metadatas=BaseModel.metadata,
    )
    flask_assets_vite.init_app(app)
    if toolbar:
        toolbar.init_app(app)
    notification_service.init_app(app)
    s3_service.init_app(app)
    talisman.init_app(app, **app.config["TALISMAN_SETTINGS"])
    login_manager.init_app(app)
    register_signals(app)
    record_sqlalchemy_queries.init_app(app, db)
    govuk_markdown.init_app(app)

    @app.after_request
    def set_cache_control_headers(response: Response) -> Response:
        if request.blueprint == "static" or (request.endpoint and request.endpoint.split(".")[-1] == "static"):
            return response

        # Allow cache-control to be set by the view/endpoint and respected, but if it's not set then these are our
        # defaults.
        if "Cache-Control" not in response.headers:
            response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private, max-age=0"
            response.headers["Pragma"] = "no-cache"
            response.headers["Expires"] = "0"

        return response

    Babel(app)
    _setup_flask_admin(app, db)

    @login_manager.user_loader
    def load_user(user_id: str) -> User | None:
        user = interfaces.user.get_user(user_id)
        if user:
            sentry_sdk.set_user({"email": user.email, "name": user.name, "id": user_id})
        return user

    # Configure templates
    app.jinja_loader = ChoiceLoader(
        [
            PackageLoader("app.common"),
            PackageLoader("app.access_grant_funding"),
            PackageLoader("app.deliver_grant_funding"),
            PrefixLoader({"govuk_frontend_jinja": PackageLoader("govuk_frontend_jinja")}),
            PrefixLoader({"govuk_frontend_wtf": PackageLoader("govuk_frontend_wtf")}),
            PackageLoader("xgovuk_flask_admin"),
        ]
    )
    WTFormsHelpers(app)

    app.jinja_env.add_extension("jinja2.ext.i18n")
    app.jinja_env.add_extension("jinja2.ext.do")

    def get_google_tag_manager_id() -> str:
        return str(current_app.config["GOOGLE_TAG_MANAGER_ID"])

    def is_deliver_grant_funding() -> bool:
        return request.blueprint == "deliver_grant_funding"

    def get_current_env_name() -> str:
        return str(current_app.config["FLASK_ENV"].value)

    app.jinja_env.filters["comma_join_items"] = comma_join_items
    app.jinja_env.filters["uppercase_first"] = uppercase_first
    app.jinja_env.filters["slugify"] = slugify
    app.jinja_env.globals["csrf_token"] = generate_csrf  # ty: ignore[invalid-assignment]

    @app.context_processor
    def _jinja_template_context() -> dict[str, Any]:
        return dict(
            cspNonce=app.jinja_env.globals["csp_nonce"](),  # ty: ignore[no-matching-overload]
            format_date=format_date,
            format_date_short=format_date_short,
            format_date_approximate=format_date_approximate,
            format_datetime=format_datetime,
            format_date_range=format_date_range,
            format_date_range_short=format_date_range_short,
            format_datetime_range=format_datetime_range,
            format_datetime_short=format_datetime_short,
            format_thousands=format_thousands,
            iso_utc=iso_utc,
            timedelta=datetime.timedelta,
            to_ordinal=to_ordinal,
            get_google_tag_manager_id=get_google_tag_manager_id,
            get_current_env_name=get_current_env_name,
            is_deliver_grant_funding=is_deliver_grant_funding,
            constants=dict(
                data_set_external_id_column_header=DATA_SET_EXTERNAL_ID_COLUMN_HEADER,
                data_set_grant_recipient_column_header=DATA_SET_GRANT_RECIPIENT_COLUMN_HEADER,
            ),
            enum=dict(
                submission_mode=SubmissionModeEnum,
                flash_message_type=FlashMessageType,
                question_type=QuestionDataType,
                form_runner_state=FormRunnerState,
                submission_status=SubmissionStatusEnum,
                submission_assessment_status=SubmissionAssessmentStatusEnum,
                tasklist_section_status=TasklistSectionStatusEnum,
                expression_type=ExpressionType,
                grant_status=GrantStatusEnum,
                collection_status=CollectionStatusEnum,
                organisation_type=OrganisationType,
                role_enum=RoleEnum,
                number_type_enum=NumberTypeEnum,
                component_type_enum=ComponentType,
                report_admin_email_type_enum=CollectionAdminEmailTypeEnum,
                file_upload_types_enum=FileUploadTypes,
                maximum_file_size_enum=MaximumFileSize,
                data_source_type_enum=DataSourceType,
                collection_type_enum=CollectionType,
            ),
            feature_flags=FeatureFlags,
            request_tracing_state=get_tracing_state(),
        )

    # TODO: Remove our basic auth application code when the app is deployed behind CloudFront and the app is not
    #       otherwise publicly accessible; we can then do basic auth through something like a cloudfront edge function
    #       rather than application code.
    if app.config["BASIC_AUTH_ENABLED"]:

        @app.before_request
        def basic_auth() -> ResponseReturnValue | None:
            from flask import request

            unauth_response = Response(status=401, headers={"WWW-Authenticate": "Basic"})

            if request.endpoint == "healthcheck.healthcheck":
                return None

            auth = request.authorization
            if not auth:
                return unauth_response

            if auth.type != "basic":
                return unauth_response

            username, password = auth.parameters["username"], auth.parameters["password"]
            if username != app.config["BASIC_AUTH_USERNAME"] or password != app.config["BASIC_AUTH_PASSWORD"]:
                return unauth_response

            return None

    # Attach routes
    _register_custom_converters(app)

    from app.access_grant_funding.routes import access_grant_funding_blueprint
    from app.common.auth import auth_blueprint
    from app.deliver_grant_funding.routes import deliver_grant_funding_blueprint
    from app.developers import developers_blueprint
    from app.healthcheck import healthcheck_blueprint

    app.register_blueprint(healthcheck_blueprint)
    app.register_blueprint(access_grant_funding_blueprint)
    app.register_blueprint(deliver_grant_funding_blueprint)
    app.register_blueprint(developers_blueprint)
    app.register_blueprint(auth_blueprint)

    _register_global_error_handlers(app)

    if app.config["SEED_SYSTEM_DATA"]:
        with app.app_context():
            try:
                seed_system_data(app)
            except ProgrammingError as e:
                sentry_sdk.capture_exception(e)
                app.logger.warning("Seeding system data failed")

    @app.route("/", methods=["GET"])
    def index() -> ResponseReturnValue:
        return redirect(url_for("deliver_grant_funding.list_grants"))

    app.add_template_global(AuthorisationHelper, "authorisation_helper")
    return app
