"""
Base settings to build other settings files upon.
"""

from datetime import timedelta
from pathlib import Path
from typing import Any

import environ

from opencontractserver.constants.agent_memory import (
    MEMORY_CURATION_CHECK_INTERVAL_SECONDS,
)
from opencontractserver.constants.celery import CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS
from opencontractserver.constants.document_processing import (
    DEFAULT_GOTENBERG_SERVICE_URL,
    DEFAULT_MAX_CORPUS_MANIFEST_SIZE_BYTES,
    DEFAULT_MAX_CORPUS_REINGEST_SOURCE_BYTES,
    DOCLING_PARSER_REQUEST_TIMEOUT_SECONDS,
    GOTENBERG_CONVERTER_REQUEST_TIMEOUT_SECONDS,
    MAX_FILE_UPLOAD_SIZE_BYTES,
)
from opencontractserver.constants.stats import SYSTEM_STATS_REFRESH_INTERVAL_SECONDS

ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
# opencontractserver/
APPS_DIR = ROOT_DIR / "opencontractserver"

env = environ.Env()

READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True)
if READ_DOT_ENV_FILE:
    # OS environment variables take precedence over variables from .env
    env.read_env(str(ROOT_DIR / ".env"))

# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env.list(
    "DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.1", "0.0.0.0"]
)

# https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = env.bool("DJANGO_DEBUG", False)

# Storage backend selection - choose between LOCAL, AWS, or GCP
STORAGE_BACKEND = env.str("STORAGE_BACKEND", default="LOCAL").upper()

# Validate storage backend choice
VALID_STORAGE_BACKENDS = ["LOCAL", "AWS", "GCP"]
if STORAGE_BACKEND not in VALID_STORAGE_BACKENDS:
    raise ValueError(
        f"Invalid STORAGE_BACKEND: {STORAGE_BACKEND}. "
        f"Must be one of: {', '.join(VALID_STORAGE_BACKENDS)}"
    )

# Legacy support: Map old USE_AWS env var to STORAGE_BACKEND if present
# This provides backward compatibility for existing deployments
if env.bool("USE_AWS", default=None) is not None:
    import warnings

    warnings.warn(
        "USE_AWS is deprecated. Please use STORAGE_BACKEND='AWS' instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    if env.bool("USE_AWS", default=False):
        STORAGE_BACKEND = "AWS"

# Activate Open Contracts Analyzer Functionality
USE_ANALYZER = env.bool("USE_ANALYZER", False)
CALLBACK_ROOT_URL_FOR_ANALYZER = env.str("CALLBACK_ROOT_URL_FOR_ANALYZER", None)

# Allow Graphene Django Debug Toolbar middleware
# Default to False for performance - only enable when actually debugging
ALLOW_GRAPHQL_DEBUG = env.bool("ALLOW_GRAPHQL_DEBUG", default=False)

# Set max file upload size to 5 GB for large corpuses
DATA_UPLOAD_MAX_MEMORY_SIZE = MAX_FILE_UPLOAD_SIZE_BYTES
# Local time zone. Choices are
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# though not all of them may be available with every OS.
# In Windows, this must be set to your system time zone.
TIME_ZONE = "UTC"
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us"
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1
# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
USE_I18N = True
# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
USE_TZ = True
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
LOCALE_PATHS = [str(ROOT_DIR / "locale")]

# DATABASES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {"default": env.db("DATABASE_URL")}
DATABASES["default"]["ATOMIC_REQUESTS"] = True

# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# URLS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = "config.urls"
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = "config.wsgi.application"

REDIS_URL = env("REDIS_URL", default="redis://127.0.0.1:6379/0")
host, port = REDIS_URL[:-2].split("://")[1].split(":")
ASGI_APPLICATION = "config.asgi.application"
try:
    from channels_redis.core import RedisChannelLayer  # noqa

    CHANNEL_LAYERS = {
        "default": {
            "BACKEND": "channels_redis.core.RedisChannelLayer",
            "CONFIG": {
                # Host MUST be a dict (not a (host, port) tuple) so socket_timeout
                # can be set explicitly. redis-py 8.0 changed the default
                # socket_timeout from None to 5s. channels-redis' idle receive
                # loop issues a 5s *server-side* blocking pop (bzpopmin/brpop,
                # brpop_timeout=5); with a 5s *client* read timeout the client's
                # deadline fires before the server's nil reply returns, raising
                # redis.TimeoutError out of the consumer -> Daphne closes the
                # socket with code 1011 -> the browser reconnects, goes idle, and
                # trips again ~5s later (the idle reconnect churn in #1886).
                # These are long-lived idle WebSockets, so the client read
                # timeout must stay disabled (the historical channels-redis
                # default that every redis-py < 8.0 provided). Do NOT pin redis
                # back below 8.0 to "fix" this. See issue #1886.
                "hosts": [{"host": host, "port": int(port), "socket_timeout": None}],
            },
        },
    }
except ImportError:
    print(
        "channels_redis is not installed. Please install it with: pip install channels-redis"
    )

# APPS
# ------------------------------------------------------------------------------
DJANGO_APPS = [
    "daphne",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.admin",
    "django.forms",
]

THIRD_PARTY_APPS = [
    "channels",
    "corsheaders",
    "django_filters",
    "graphene_django",
    "guardian",
    "django_celery_beat",
    "rest_framework",
    "rest_framework.authtoken",
    "tree_queries",
]

LOCAL_APPS = [
    "opencontractserver.users",
    "opencontractserver.documents",
    "opencontractserver.corpuses",
    "opencontractserver.annotations",
    "opencontractserver.analyzer",
    "opencontractserver.extracts",
    "opencontractserver.feedback",
    "opencontractserver.conversations",
    "opencontractserver.badges",
    "opencontractserver.notifications",
    "opencontractserver.agents",
    "opencontractserver.worker_uploads",
    "opencontractserver.document_imports",
    "opencontractserver.discovery",
    "opencontractserver.benchmarks",
    "opencontractserver.research",
]

# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

# MIGRATIONS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
MIGRATION_MODULES = {"sites": "opencontractserver.contrib.sites.migrations"}

# USER LIMITS (FOR USERS WITH IS_USAGE_CAPPED=True)
# ------------------------------------------------------------------------------
USAGE_CAPPED_USER_DOC_CAP_COUNT = env.int(
    "USAGE_CAPPED_USER_CORPUS_CAP_COUNT", default=10
)
USAGE_CAPPED_USER_CAN_USE_ANALYZERS = env.bool(
    "USAGE_CAPPED_USER_CAN_USE_ANALYZERS", default=True
)
USAGE_CAPPED_USER_CAN_IMPORT_CORPUS = env.bool(
    "USAGE_CAPPED_USER_CAN_IMPORT_CORPUS", default=False
)
USAGE_CAPPED_USER_CAN_EXPORT_CORPUS = env.bool(
    "USAGE_CAPPED_USER_CAN_EXPORT_CORPUS", default=True
)

# UPLOAD CONTROLS
# ------------------------------------------------------------------------------
# DEPRECATED: Upload validation now uses dynamically-derived MIME types from the
# pipeline component registry (see opencontractserver.pipeline.registry.get_allowed_mime_types).
# This static list is retained for backward compatibility with external code or tests.
ALLOWED_DOCUMENT_MIMETYPES = [
    "application/pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    # "application/vnd.openxmlformats-officedocument.presentationml.presentation",
    # "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "text/plain",
    "application/txt",
]

# AUTHENTICATION
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "guardian.backends.ObjectPermissionBackend",
]

# AUTH0
USE_AUTH0 = env.bool("USE_AUTH0", False)
USE_API_KEY_AUTH = env.bool("ALLOW_API_KEYS", False)

if USE_AUTH0:

    AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID")
    AUTH0_API_AUDIENCE = env("AUTH0_API_AUDIENCE")
    AUTH0_DOMAIN = env("AUTH0_DOMAIN")
    AUTH0_M2M_MANAGEMENT_API_SECRET = env("AUTH0_M2M_MANAGEMENT_API_SECRET")
    AUTH0_M2M_MANAGEMENT_API_ID = env("AUTH0_M2M_MANAGEMENT_API_ID")
    AUTH0_M2M_MANAGEMENT_GRANT_TYPE = env("AUTH0_M2M_MANAGEMENT_GRANT_TYPE")

    # Namespace prefix for admin claims in Auth0 access tokens
    # Claims: {namespace}is_staff and {namespace}is_superuser
    AUTH0_ADMIN_CLAIM_NAMESPACE = env(
        "AUTH0_ADMIN_CLAIM_NAMESPACE",
        default="https://contracts.opensource.legal/",
    )

    # Defense-in-depth: comma-separated list of Auth0 ``sub`` values eligible
    # for ``is_superuser`` elevation via JWT claim sync. The verified token
    # must contain the namespaced superuser claim AND the user's ``sub`` must
    # appear in this list before Django flips ``is_superuser=True``. An empty
    # list (the default) blocks all JWT-driven superuser elevation, which
    # protects against misconfigured tenant Actions that source admin claims
    # from user-writable ``user_metadata``. Existing superusers whose subs are
    # not added here are demoted on next sync; populate this BEFORE deploy.
    AUTH0_SUPERUSER_SUB_ALLOWLIST = env.list(
        "AUTH0_SUPERUSER_SUB_ALLOWLIST",
        default=[],
    )

    # Admin claims sync cache TTL (seconds). Bounds the privilege-retention gap
    # between Auth0 demoting a user and Django reflecting the new flags. Lower
    # values give a tighter revocation SLA at the cost of slightly more frequent
    # claim-sync writes on the per-request auth path. The default tracks
    # ``ADMIN_CLAIMS_CACHE_TTL`` in ``opencontractserver/constants/auth.py``.
    from opencontractserver.constants.auth import (
        ADMIN_CLAIMS_CACHE_TTL as _DEFAULT_ADMIN_CLAIMS_CACHE_TTL,
    )

    AUTH0_ADMIN_CLAIMS_CACHE_TTL = env.int(
        "AUTH0_ADMIN_CLAIMS_CACHE_TTL",
        default=_DEFAULT_ADMIN_CLAIMS_CACHE_TTL,
    )

    # Auto-provision Django users on first Auth0 login. Default True preserves
    # historical behaviour; set ``AUTH0_CREATE_NEW_USERS=False`` to require
    # out-of-band provisioning (any unknown sub will fail authentication).
    # Consumed by ``config.graphql_auth0_auth.utils.get_auth0_user_from_token``
    # via the ``AUTH0_JWT`` settings dict.
    AUTH0_JWT = {
        "AUTH0_CREATE_NEW_USERS": env.bool("AUTH0_CREATE_NEW_USERS", default=True),
    }

    AUTHENTICATION_BACKENDS += [
        "config.graphql_auth0_auth.backends.Auth0RemoteUserJSONWebTokenBackend",
        "config.admin_auth.backends.Auth0AdminBackend",  # For Django admin login
    ]

else:
    AUTHENTICATION_BACKENDS += [
        "graphql_jwt.backends.JSONWebTokenBackend",
    ]

if USE_API_KEY_AUTH:
    API_TOKEN_HEADER_NAME = "AUTHORIZATION"
    API_TOKEN_PREFIX = "KEY"
    AUTHENTICATION_BACKENDS += ["config.graphql_api_token_auth.backends.ApiKeyBackend"]

# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model
AUTH_USER_MODEL = "users.User"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
LOGIN_REDIRECT_URL = "users:redirect"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-url
LOGIN_URL = "account_login"

# Guardian Settings
# ------------------------------------------------------------------------------
GUARDIAN_AUTO_PREFETCH = True
ANONYMOUS_USER_NAME = "Anonymous"

# PASSWORDS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = [
    # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
    },
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

# MIDDLEWARE
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.security.SecurityMiddleware",
    "config.middleware.SecurityHeadersMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.common.BrokenLinkEmailsMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

# STATIC
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#static-root
STATIC_ROOT = str(ROOT_DIR / "staticfiles")
# https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = "/static/"
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = [str(APPS_DIR / "static")]
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
STATICFILES_FINDERS = [
    "django.contrib.staticfiles.finders.FileSystemFinder",
    "django.contrib.staticfiles.finders.AppDirectoriesFinder",
]

if STORAGE_BACKEND == "LOCAL":
    # STORAGES (Django 5.x unified storage configuration)
    # https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES
    STORAGES = {
        "default": {
            "BACKEND": "django.core.files.storage.FileSystemStorage",
        },
        "staticfiles": {
            "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
        },
    }

    # MEDIA
    # ------------------------------------------------------------------------------
    # https://docs.djangoproject.com/en/dev/ref/settings/#media-root
    MEDIA_ROOT = str(APPS_DIR / "media")
    # https://docs.djangoproject.com/en/dev/ref/settings/#media-url
    MEDIA_URL = "/media/"
elif STORAGE_BACKEND == "AWS":
    # AWS S3 STORAGE CONFIGURATION
    # ------------------------------------------------------------------------------
    # https://django-storages.readthedocs.io/en/latest/#installation
    INSTALLED_APPS += ["storages"]  # noqa F405
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="dummy-key")
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", default="dummy-secret")
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", default="dummy-bucket")
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_QUERYSTRING_AUTH = True
    # Presigned-URL signature lifetime (seconds). Made explicit (rather than
    # relying on django-storages' implicit 3600 default) because the shared
    # file-URL cache TTL below MUST be derived from it.
    AWS_QUERYSTRING_EXPIRE = env.int("AWS_QUERYSTRING_EXPIRE", default=3600)
    # DO NOT change these unless you know what you're doing.
    # NOTE: this is the HTTP CacheControl max-age for the stored OBJECTS —
    # it has nothing to do with how long presigned URLs stay valid (that is
    # AWS_QUERYSTRING_EXPIRE above).
    _AWS_EXPIRY = 60 * 60 * 24 * 7
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_S3_OBJECT_PARAMETERS = {
        "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate"
    }
    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
    AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", default=None)

    # Connection pooling for better performance
    # Reuse connections instead of creating new ones for each request
    AWS_S3_CONNECTION_POOL_SIZE = env.int("AWS_S3_CONNECTION_POOL_SIZE", default=10)

    # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront
    AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", default=None)
    aws_s3_domain = (
        AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
    )

    # Copied values from botos3 for OpenEdgar Crawlers rather than rewriting the crawlers (for now). Lazy :-)
    S3_ACCESS_KEY = AWS_ACCESS_KEY_ID
    S3_SECRET_KEY = AWS_SECRET_ACCESS_KEY
    S3_BUCKET = AWS_STORAGE_BUCKET_NAME
    S3_PREFIX = env("S3_PREFIX", default="documents")
    S3_COMPRESSION_LEVEL = int(env("S3_COMPRESSION_LEVEL", default=6))

    # STORAGES (Django 5.x unified storage configuration)
    # Use pooled storage backends for better performance
    STORAGES = {
        "default": {
            "BACKEND": "opencontractserver.utils.enhanced_storages.PooledMediaRootS3Storage",
        },
        "staticfiles": {
            "BACKEND": "opencontractserver.utils.enhanced_storages.PooledStaticRootS3Storage",
        },
    }
    COLLECTFASTA_STRATEGY = "collectfasta.strategies.boto3.Boto3Strategy"
    STATIC_URL = f"https://{aws_s3_domain}/static/"

    MEDIA_URL = f"https://{aws_s3_domain}/media/"
    S3_DOCUMENT_PATH = env("S3_DOCUMENT_PATH", default="open_contracts")
    MEDIA_ROOT = str(APPS_DIR / "media")

elif STORAGE_BACKEND == "GCP":
    # GCP CLOUD STORAGE CONFIGURATION
    # ------------------------------------------------------------------------------
    # https://django-storages.readthedocs.io/en/latest/#installation
    INSTALLED_APPS += ["storages"]  # noqa F405

    # GCS Settings
    # https://django-storages.readthedocs.io/en/latest/backends/gcloud.html#settings
    GS_BUCKET_NAME = env("GS_BUCKET_NAME")

    # Optional: Your Google Cloud project ID
    GS_PROJECT_ID = env("GS_PROJECT_ID", default=None)

    # Authentication - Can use service account JSON path or default credentials
    # For production, use workload identity/service account attached to compute
    GS_CREDENTIALS = env("GS_CREDENTIALS", default=None)

    # ACL for new files - publicRead for static files that need public access
    # For media files, we'll override this in the storage class
    GS_DEFAULT_ACL = env("GS_DEFAULT_ACL", default=None)

    # Security: Don't serve public URLs for private files
    GS_QUERYSTRING_AUTH = env.bool("GS_QUERYSTRING_AUTH", default=True)

    GS_EXPIRATION = timedelta(seconds=env.int("GS_EXPIRATION_SECONDS", default=86400))

    # File handling
    GS_FILE_OVERWRITE = env.bool("GS_FILE_OVERWRITE", default=False)

    # Maximum memory size before rolling over to disk (0 = no rollover)
    GS_MAX_MEMORY_SIZE = env.int("GS_MAX_MEMORY_SIZE", default=0)

    # Chunk size for resumable uploads (must be multiple of 256K)
    GS_BLOB_CHUNK_SIZE = env.int("GS_BLOB_CHUNK_SIZE", default=1024 * 256 * 10)  # 2.5MB

    # Optional custom endpoint
    GS_CUSTOM_ENDPOINT = env("GS_CUSTOM_ENDPOINT", default=None)

    # Location (subdirectory) for files - will be set per storage class
    GS_LOCATION = env("GS_LOCATION", default="")

    # Object parameters for cache control
    _GS_EXPIRY = 60 * 60 * 24 * 7  # 7 days
    GS_OBJECT_PARAMETERS = {
        "cache_control": f"max-age={_GS_EXPIRY}, s-maxage={_GS_EXPIRY}, must-revalidate"
    }

    # GZIP settings
    GS_IS_GZIPPED = env.bool("GS_IS_GZIPPED", default=False)
    GZIP_CONTENT_TYPES = (
        "text/css",
        "text/javascript",
        "application/javascript",
        "application/x-javascript",
        "image/svg+xml",
    )

    # IAM Sign Blob API for signed URLs (required when not using service account key file)
    GS_IAM_SIGN_BLOB = env.bool("GS_IAM_SIGN_BLOB", default=False)

    # Optional: Override service account email for signing
    GS_SA_EMAIL = env("GS_SA_EMAIL", default=None)

    # Build domain for URLs
    if GS_CUSTOM_ENDPOINT:
        gcs_domain = GS_CUSTOM_ENDPOINT
    else:
        gcs_domain = f"storage.googleapis.com/{GS_BUCKET_NAME}"

    # STORAGES (Django 5.x unified storage configuration)
    STORAGES = {
        "default": {
            "BACKEND": "opencontractserver.utils.storages.MediaRootGoogleCloudStorage",
        },
        "staticfiles": {
            "BACKEND": "opencontractserver.utils.storages.StaticRootGoogleCloudStorage",
        },
    }
    COLLECTFASTA_STRATEGY = "collectfasta.strategies.gcloud.GoogleCloudStrategy"
    STATIC_URL = f"https://{gcs_domain}/static/"

    MEDIA_URL = f"https://{gcs_domain}/media/"
    GCS_DOCUMENT_PATH = env("GCS_DOCUMENT_PATH", default="open_contracts")
    MEDIA_ROOT = str(APPS_DIR / "media")

# Cross-request signed-URL cache
# ------------------------------------------------------------------------------
# S3/GCS media URLs are signed per object. On GCS with IAM signBlob (Workload
# Identity, no local signing key) every ``FieldFile.url`` is a network round
# trip, so a document-list edge selecting pdf/txt/pawls/icon URLs fans out to
# N×4 signing calls — the source of the multi-second corpus-folder query.
# ``optimized_file_resolvers`` caches the generated URL strings in the shared
# (Redis) cache keyed by blob name (a signed URL is not user-specific, so the
# entry is safely shared across users), signing each blob at most once per TTL
# window. The TTL is held well under the signed-URL lifetime so a cached URL is
# always served with ample validity remaining. 0 disables the shared cache
# (LOCAL storage URLs are relative + free; only the per-request memo applies).
from opencontractserver.utils.files import (  # noqa: E402  (pure helper, no app/model imports at module level)
    clamp_shared_url_cache_ttl as _clamp_shared_url_cache_ttl,
)

if STORAGE_BACKEND == "GCP":
    _signed_url_lifetime_seconds = int(GS_EXPIRATION.total_seconds())
elif STORAGE_BACKEND == "AWS":
    # The PRESIGN lifetime (AWS_QUERYSTRING_EXPIRE) — NOT ``_AWS_EXPIRY``,
    # which is the stored objects' HTTP CacheControl max-age (7 days) and
    # says nothing about signature validity. Deriving from the wrong value
    # let this cache serve dead (403) links for up to 5 hours.
    _signed_url_lifetime_seconds = AWS_QUERYSTRING_EXPIRE
else:
    _signed_url_lifetime_seconds = 0
# Clamped even when set explicitly via env: a TTL beyond half the signature
# lifetime can only ever serve expired links.
FILE_URL_SHARED_CACHE_TTL = _clamp_shared_url_cache_ttl(
    env.int(
        "FILE_URL_SHARED_CACHE_TTL",
        default=max(0, min(_signed_url_lifetime_seconds // 2, 6 * 60 * 60)),
    ),
    _signed_url_lifetime_seconds,
)

# Max concurrent signBlob round trips when ``FileUrlPrewarmMiddleware`` pre-signs
# a document-list page. Bounded to stay well under IAM signBlob rate limits
# while collapsing an N-deep serial chain into ~N/concurrency.
FILE_URL_SIGN_CONCURRENCY = env.int("FILE_URL_SIGN_CONCURRENCY", default=16)

# TEMPLATES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
TEMPLATES = [
    {
        # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
        "DIRS": [str(APPS_DIR / "templates")],
        "OPTIONS": {
            # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
            # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
            "loaders": [
                "django.template.loaders.filesystem.Loader",
                "django.template.loaders.app_directories.Loader",
            ],
            # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.i18n",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "django.template.context_processors.tz",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    }
]

# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

# FIXTURES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs
FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),)

# SECURITY
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly
SESSION_COOKIE_HTTPONLY = True
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly
CSRF_COOKIE_HTTPONLY = True
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-samesite
SESSION_COOKIE_SAMESITE = "Lax"
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-samesite
CSRF_COOKIE_SAMESITE = "Lax"
# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options
X_FRAME_OPTIONS = "DENY"

# Referrer-Policy header — controls how much referrer info is sent.
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

# Content-Security-Policy — defence-in-depth against XSS / injection.
# Uses 'self' as the baseline; blob: and data: are needed by PDF.js;
# 'unsafe-inline' is required for React's runtime-injected styles (Vite
# injects <style> tags in dev, and hashed CSS chunks still require inline
# style attributes).  'self' covers same-origin WebSocket connections
# (wss:// when served over HTTPS) so no extra wss: source is needed here.
#
# NOTE: Auth0 CSP extension is handled automatically below — when AUTH0_DOMAIN
# is set, connect-src and script-src are extended with the tenant domain.
SECURE_CSP_DIRECTIVES = {
    "default-src": ["'self'"],
    # blob: required for PDF.js web workers in CSP Level 2 browsers
    # that fall back from worker-src to script-src
    "script-src": ["'self'", "blob:"],
    "style-src": ["'self'", "'unsafe-inline'"],
    "img-src": ["'self'", "data:", "blob:"],
    "font-src": ["'self'", "data:"],
    "connect-src": ["'self'"],
    "worker-src": ["'self'", "blob:"],
    "object-src": ["'none'"],
    # frame-ancestors 'none' is the modern CSP2 mechanism for blocking framing.
    # X_FRAME_OPTIONS = "DENY" (above) is the legacy fallback for older browsers.
    # Both are set intentionally for maximum browser coverage.
    "frame-ancestors": ["'none'"],
    "base-uri": ["'self'"],
    "form-action": ["'self'"],
}

# When Auth0 is enabled, the browser must be able to load scripts from and
# connect to the Auth0 tenant domain for authentication flows.
#
# The admin login page loads the Auth0 SPA SDK from cdn.jsdelivr.net, so that
# origin must also be allowed in script-src.  NOTE: this allowlists the entire
# cdn.jsdelivr.net origin app-wide (CSP is not page-scoped).  The actual Auth0
# script tag in auth0_login.html is pinned with an SRI integrity hash, but SRI
# only protects that specific <script> element — other pages could still load
# arbitrary jsDelivr-hosted scripts if an XSS vector existed.  This is an
# accepted trade-off for CDN-hosted dependencies; keep it in mind when auditing
# script injection surface area.
if USE_AUTH0:
    from config.middleware import validate_csp_domain

    validate_csp_domain(AUTH0_DOMAIN)
    SECURE_CSP_DIRECTIVES["connect-src"].append(f"https://{AUTH0_DOMAIN}")
    SECURE_CSP_DIRECTIVES["script-src"].append(f"https://{AUTH0_DOMAIN}")
    SECURE_CSP_DIRECTIVES["script-src"].append("https://cdn.jsdelivr.net")
    # Auth0 SPA SDK uses a hidden iframe for silent authentication
    # (prompt=none, response_mode=web_message) which requires frame-src.
    SECURE_CSP_DIRECTIVES["frame-src"] = [f"https://{AUTH0_DOMAIN}"]

# Permissions-Policy — opt out of browser features not needed by the app.
SECURE_PERMISSIONS_POLICY: dict[str, list[str]] = {
    "camera": [],
    "microphone": [],
    "geolocation": [],
    "payment": [],
    "usb": [],
    "magnetometer": [],
    "gyroscope": [],
    "accelerometer": [],
}

# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env(
    "DJANGO_EMAIL_BACKEND",
    default="django.core.mail.backends.smtp.EmailBackend",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
EMAIL_TIMEOUT = 5

# ADMIN
# ------------------------------------------------------------------------------
# Django Admin URL.
ADMIN_URL = "admin/"
# https://docs.djangoproject.com/en/dev/ref/settings/#admins
ADMINS = [("""JSv4""", "support@opensource.legal")]
# https://docs.djangoproject.com/en/dev/ref/settings/#managers
MANAGERS = ADMINS

# LOGGING
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
# See https://docs.djangoproject.com/en/dev/topics/logging for
# more details on how to customize your logging configuration.
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {
        # Demote the routine "CSRF token missing." WARNING to INFO so
        # genuine CSRF anomalies (origin mismatch, bad referer) stand out.
        # See config/graphql/security.py::CsrfRejectLogFilter for the
        # full rationale (issue #1432).
        "csrf_reject_filter": {
            "()": "config.graphql.security.CsrfRejectLogFilter",
        },
    },
    "formatters": {
        "verbose": {
            "format": (
                "%(asctime)s %(levelname)s %(name)s [%(filename)s:%(lineno)d] "
                "%(message)s"
            ),
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django.security.csrf": {
            "handlers": ["console"],
            "filters": ["csrf_reject_filter"],
            "level": "INFO",
            "propagate": False,
        },
    },
}

# Celery
# ------------------------------------------------------------------------------
if USE_TZ:
    # http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-timezone
    CELERY_TIMEZONE = TIME_ZONE
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-broker_url
CELERY_BROKER_URL = REDIS_URL
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_backend
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-accept_content
CELERY_ACCEPT_CONTENT = ["json"]
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_serializer
CELERY_TASK_SERIALIZER = "json"
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_serializer
CELERY_RESULT_SERIALIZER = "json"
# Celery task time limits are intentionally unset — document processing tasks
# can run for extended periods depending on document size and parser backend.
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-time-limit
# CELERY_TASK_TIME_LIMIT = 5 * 3600
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-soft-time-limit
# CELERY_TASK_SOFT_TIME_LIMIT = 3600
# http://docs.celeryproject.org/en/latest/userguide/configuration.html#beat-scheduler
# Uses database scheduler - periodic tasks defined in CELERY_BEAT_SCHEDULE below
# are automatically synced into the DB on Beat startup.
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 14240000  # 14 GB (thousands of kilobytes)
CELERY_MAX_TASKS_PER_CHILD = 4
CELERY_PREFETCH_MULTIPLIER = 1
CELERY_RESULT_BACKEND_MAX_RETRIES = 10
# Resilience to worker death (Issue #1493).
# By default Celery acks messages on receive (at-most-once delivery): if a worker
# dies mid-task — OOM, host failure, deploy, eviction, SIGKILL — the broker has
# already removed the message and the task is silently lost. This is especially
# painful for long-running ingest/parse/embed work where DB persistence happens
# at the end of the task, leaving documents stuck with `backend_lock=True` and
# no parsed content.
#
# task_acks_late: only ack after the task returns successfully, so the broker
#   redelivers if the worker dies mid-task.
# task_reject_on_worker_lost: requeue even on hard kills (SIGKILL / host loss),
#   instead of treating the silent disappearance as success.
#
# Trade-off: at-least-once delivery means tasks may run twice. All new tasks
# MUST be idempotent — see docs/architecture/asynchronous-processing.md.
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-acks-late
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-reject-on-worker-lost
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True

# Chord error callbacks must attach to the chord BODY (not the header) so the
# document-ingest chain's link_error (mark_doc_failed_on_chain_error) fires when
# a chunk-parse task fails — marking the document FAILED instead of leaving it
# locked in PROCESSING.  Celery is deprecating this False default; pin it
# explicitly so the chunked-parse failure guarantee cannot regress silently on a
# Celery upgrade.
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-allow-error-cb-on-chord-header
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = False

# Redis broker visibility timeout (Issue #1493).
# OpenContracts uses Redis as the Celery broker. Unlike RabbitMQ, Redis tracks
# unacknowledged messages with a *visibility timeout*: once a worker pulls a
# message, the broker considers it eligible for redelivery to *another* worker
# after the timeout elapses, regardless of whether the original worker is still
# alive. The Celery default is 1 hour (3600s).
#
# With CELERY_TASK_ACKS_LATE = True, any task that runs longer than this
# timeout will be redelivered while still executing — a guaranteed double
# execution even without a worker crash. Document parsing/embedding tasks on
# very large documents can exceed this default, so we raise it to 12 hours
# (longer than any expected document processing job).
#
# Tasks taking longer than this should be split into smaller chunks rather
# than raising the timeout further; longer timeouts directly delay redelivery
# after a real worker death.
# https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html#visibility-timeout
#
# If adding new transport options in environment-specific settings (e.g.
# production SSL options), merge into this dict rather than reassigning it —
# a bare ``CELERY_BROKER_TRANSPORT_OPTIONS = {...}`` would drop the
# visibility timeout and silently regress at-least-once delivery.
CELERY_BROKER_TRANSPORT_OPTIONS = {
    "visibility_timeout": CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS,
}

# Celery task routing
# -----------------------------------------------------------------------
# All task queue routes are defined here in one place. Do NOT assign
# CELERY_TASK_ROUTES elsewhere — add new routes to this dict instead.
CELERY_TASK_ROUTES = {
    # Worker upload processing runs on a dedicated queue so it never starves
    # regular user operations (parsing, embedding, export, etc.)
    "opencontractserver.worker_uploads.tasks.*": {"queue": "worker_uploads"},
}

# Celery Beat schedule (settings-based)
# -----------------------------------------------------------------------
# Periodic drain of pending worker document uploads. Ensures uploads are
# processed even if the per-request nudge was missed during task-worker downtime.
CELERY_BEAT_SCHEDULE = {
    "worker-uploads-drain-pending": {
        "task": "opencontractserver.worker_uploads.tasks.process_pending_uploads",
        "schedule": 60.0,
        "options": {"queue": "worker_uploads"},
    },
    "worker-uploads-recover-stalled": {
        "task": "opencontractserver.worker_uploads.tasks.recover_stalled_uploads",
        "schedule": 300.0,  # every 5 minutes
        "options": {"queue": "worker_uploads"},
    },
    "memory-curate-idle-conversations": {
        "task": "opencontractserver.tasks.memory_tasks.check_conversations_for_curation",
        "schedule": MEMORY_CURATION_CHECK_INTERVAL_SECONDS,
        "options": {"queue": "celery"},
    },
    "chunked-uploads-purge-stale": {
        "task": "opencontractserver.document_imports.tasks.purge_stale_chunked_uploads",
        "schedule": 3600.0,  # hourly
        "options": {"queue": "celery"},
    },
    "deep-research-resume-stalled": {
        "task": "opencontractserver.tasks.research_tasks.reap_stalled_research",
        "schedule": 300.0,  # every 5 minutes
        "options": {"queue": "celery"},
    },
    # Safety net for documents stranded in PROCESSING when their ingest chain
    # halted without a terminal state (worker OOM/SIGKILL, lost broker
    # message). Marks them FAILED so they don't show "processing" forever.
    "documents-reconcile-stuck-processing": {
        "task": "opencontractserver.tasks.doc_tasks.reconcile_stuck_documents",
        "schedule": 300.0,  # every 5 minutes
        "options": {"queue": "celery"},
    },
    # Materialise install-wide headline counts so dashboards/landing tiles
    # don't run full-table COUNTs on every page load (issue #1908).
    "system-stats-refresh": {
        "task": "opencontractserver.tasks.stats_tasks.refresh_system_stats",
        "schedule": SYSTEM_STATS_REFRESH_INTERVAL_SECONDS,
        "options": {"queue": "celery"},
    },
}

# Worker Upload Processing
# ------------------------------------------------------------------------------
# Documents per batch when draining the staging table
WORKER_UPLOAD_BATCH_SIZE = int(env("WORKER_UPLOAD_BATCH_SIZE", default="50"))

# Maximum file size (in bytes) accepted by the worker upload endpoint.
# Default: 256 MB. Set to 0 to disable the limit.
MAX_WORKER_UPLOAD_SIZE_BYTES = int(
    env("MAX_WORKER_UPLOAD_SIZE_BYTES", default=str(256 * 1024 * 1024))
)

# Minutes before a PROCESSING upload is considered stalled and reset to PENDING.
WORKER_UPLOAD_STALE_MINUTES = int(env("WORKER_UPLOAD_STALE_MINUTES", default="15"))

# Minutes a Document may sit in processing_status=PROCESSING (with
# backend_lock=True) before the reconcile_stuck_documents sweep marks it
# FAILED. Default 30 min — comfortably beyond ingest_doc's max retry/backoff
# window (~15 min) so the sweep never races a legitimately-retrying document.
DOCUMENT_PROCESSING_STALE_MINUTES = int(
    env("DOCUMENT_PROCESSING_STALE_MINUTES", default="30")
)

# Max documents reclaimed per reconcile_stuck_documents sweep. After an
# extended outage the stuck backlog can be large; capping the per-run batch
# keeps a single sweep well under its beat interval so runs don't overlap.
# Any remainder is picked up by the next scheduled sweep.
DOCUMENT_RECONCILE_BATCH_CAP = int(env("DOCUMENT_RECONCILE_BATCH_CAP", default="200"))

# Maximum file size (in bytes) accepted by the multipart REST import
# endpoints under /api/imports/. Applied to both single-document and
# bulk-zip imports. Default: same ceiling as DATA_UPLOAD_MAX_MEMORY_SIZE
# (5 GB). Set to 0 to disable the per-endpoint check (Django's
# DATA_UPLOAD_MAX_MEMORY_SIZE still applies to non-file form data).
MAX_DOCUMENT_IMPORT_SIZE_BYTES = int(
    env(
        "MAX_DOCUMENT_IMPORT_SIZE_BYTES",
        default=str(MAX_FILE_UPLOAD_SIZE_BYTES),
    )
)

# Maximum uncompressed size (in bytes) for a single document source member that
# the V2 corpus-export importer reads into memory. Despite the "REINGEST" name
# (kept for backward compatibility with existing deployments' env config), this
# guards BOTH the reingest peek AND the baked-import fallback — every document
# source read in the V2 importer goes through it, so an over-size member cannot
# bypass the limit by falling through to the baked path. Over-size members are
# skipped (the document is not imported) rather than risking worker exhaustion.
#
# Sentinel: a NEGATIVE value disables the guard entirely (the read becomes
# unbounded). 0 is NOT a disable — it is a literal zero-byte limit that rejects
# every non-empty member, so an operator who zeroes the value to *harden* gets
# stricter behavior, not an accidental full-disable. Because the guard now also
# covers the baked-only path, 0 rejects even the 1-byte NUL placeholder the V2
# exporter writes for text/markdown/source-less documents — i.e. it blocks
# importing ANY document via this importer, not just reingest-mode documents.
_max_corpus_reingest_source_bytes = int(
    env(
        "MAX_CORPUS_REINGEST_SOURCE_BYTES",
        default=str(DEFAULT_MAX_CORPUS_REINGEST_SOURCE_BYTES),
    )
)
MAX_CORPUS_REINGEST_SOURCE_BYTES: int | None = (
    None if _max_corpus_reingest_source_bytes < 0 else _max_corpus_reingest_source_bytes
)

# Maximum size (in bytes) of the top-level ``data.json`` manifest inside a V2
# corpus-export ZIP. Read in full before any per-document guard runs (it is
# the very first member the importer opens), so it needs its own bound — see
# DEFAULT_MAX_CORPUS_MANIFEST_SIZE_BYTES for the threat model.
#
# Sentinel: same convention as MAX_CORPUS_REINGEST_SOURCE_BYTES — a NEGATIVE
# value disables the guard entirely (unbounded read); 0 is a literal
# zero-byte limit, not a disable.
_max_corpus_manifest_size_bytes = int(
    env(
        "MAX_CORPUS_MANIFEST_SIZE_BYTES",
        default=str(DEFAULT_MAX_CORPUS_MANIFEST_SIZE_BYTES),
    )
)
MAX_CORPUS_MANIFEST_SIZE_BYTES: int | None = (
    None if _max_corpus_manifest_size_bytes < 0 else _max_corpus_manifest_size_bytes
)

# Chunked (resumable) upload limits
# ------------------------------------------------------------------------------
# Back the /api/imports/chunked/* endpoints, which slice a large file into
# sub-ceiling parts to get past the 100 MB per-request body cap on upstream
# proxies (Cloudflare). The assembled-file total is still bounded by
# MAX_DOCUMENT_IMPORT_SIZE_BYTES above.
#
# Maximum size (bytes) of a single uploaded part. MUST stay below the smallest
# upstream proxy body limit (Cloudflare: 100 MB). Default: 90 MB.
CHUNKED_UPLOAD_PART_MAX_BYTES = int(
    env("CHUNKED_UPLOAD_PART_MAX_BYTES", default=str(90 * 1024 * 1024))
)
# Hard cap on the number of parts in one session (bounds metadata / abuse).
CHUNKED_UPLOAD_MAX_PARTS = int(env("CHUNKED_UPLOAD_MAX_PARTS", default="100000"))
# Hours of inactivity before an unfinished session and its stored parts are
# eligible for garbage collection by ``purge_stale_chunked_uploads``.
CHUNKED_UPLOAD_STALE_HOURS = int(env("CHUNKED_UPLOAD_STALE_HOURS", default="24"))
# Days a COMPLETED session row is retained as an audit trail before
# ``purge_stale_chunked_uploads`` removes it (its parts were already deleted on
# completion, so this only reclaims small metadata rows). Prevents the table
# from growing unboundedly. Set to 0 to keep COMPLETED rows forever.
CHUNKED_UPLOAD_COMPLETED_RETENTION_DAYS = int(
    env("CHUNKED_UPLOAD_COMPLETED_RETENTION_DAYS", default="30")
)
# Grace window (hours) before an ``ASSEMBLING`` session is treated as a crashed
# worker and made eligible for GC. Deliberately far larger than any real
# reassembly so the staleness GC can never delete parts out from under a live
# assembly. See ``document_imports.services.purge_stale_chunked_uploads``.
CHUNKED_UPLOAD_ASSEMBLING_GRACE_HOURS = int(
    env("CHUNKED_UPLOAD_ASSEMBLING_GRACE_HOURS", default="6")
)
# Block size (bytes) used when streaming stored parts into the reassembled temp
# file. Bounds peak assembly memory to O(block), independent of file size.
CHUNK_ASSEMBLY_BLOCK_SIZE = int(
    env("CHUNK_ASSEMBLY_BLOCK_SIZE", default=str(8 * 1024 * 1024))
)

# Maximum metadata JSON size (in bytes) accepted by the worker upload endpoint.
# Default: 500 MB. Set to 0 to disable the limit.
MAX_WORKER_METADATA_SIZE_BYTES = int(
    env("MAX_WORKER_METADATA_SIZE_BYTES", default=str(500 * 1024 * 1024))
)

# Zip Import Limits
# ------------------------------------------------------------------------------
# These limits are consumed by opencontractserver/constants/zip_import.py via
# `getattr(settings, ...)` and shield the importer from zip bombs, path
# traversal, and resource exhaustion.  Each value is overridable via the
# matching environment variable so operators can tune them per deployment.

# Maximum number of files allowed in a single import zip.
ZIP_MAX_FILE_COUNT = int(env("ZIP_MAX_FILE_COUNT", default="1000"))

# Maximum total uncompressed size (in bytes) of a single import zip.
# Default: 500 MB.
ZIP_MAX_TOTAL_SIZE_BYTES = int(
    env("ZIP_MAX_TOTAL_SIZE_BYTES", default=str(500 * 1024 * 1024))
)

# Maximum size (in bytes) of any single file inside an import zip.  Files
# exceeding this limit are skipped with an error message.  Default: 100 MB.
ZIP_MAX_SINGLE_FILE_SIZE_BYTES = int(
    env("ZIP_MAX_SINGLE_FILE_SIZE_BYTES", default=str(100 * 1024 * 1024))
)

# Maximum compression ratio (uncompressed/compressed) before flagging a zip
# entry as suspicious.  Files above this ratio trigger extra validation.
ZIP_MAX_COMPRESSION_RATIO = int(env("ZIP_MAX_COMPRESSION_RATIO", default="100"))

# Maximum folder depth (number of nested folders) inside an import zip.
ZIP_MAX_FOLDER_DEPTH = int(env("ZIP_MAX_FOLDER_DEPTH", default="20"))

# Maximum number of folders that can be created from a single import zip.
ZIP_MAX_FOLDER_COUNT = int(env("ZIP_MAX_FOLDER_COUNT", default="500"))

# Maximum length (characters) of a single path component (folder or file
# name) inside an import zip.
ZIP_MAX_PATH_COMPONENT_LENGTH = int(env("ZIP_MAX_PATH_COMPONENT_LENGTH", default="255"))

# Maximum total path length (characters) for any entry in an import zip.
ZIP_MAX_PATH_LENGTH = int(env("ZIP_MAX_PATH_LENGTH", default="1024"))

# Number of documents to process per batch when draining a zip import.
ZIP_DOCUMENT_BATCH_SIZE = int(env("ZIP_DOCUMENT_BATCH_SIZE", default="50"))

# Maximum size (in bytes) of a single annotation sidecar JSON inside an import
# zip. Sidecars are fully loaded into memory for JSON parsing, so this caps
# per-sidecar memory use. Default: 50 MB.
ZIP_MAX_SIDECAR_SIZE_BYTES = int(
    env("ZIP_MAX_SIDECAR_SIZE_BYTES", default=str(50 * 1024 * 1024))
)

# django-rest-framework
# -------------------------------------------------------------------------------
# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "config.rest_jwt_auth.GraphQLJWTAuthentication",  # JWT auth (same as GraphQL)
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ),
    "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/hour",  # Anonymous users (shouldn't hit authenticated endpoints)
        "user": "1000/hour",  # Authenticated users
        "annotation_images": "200/hour",  # Image retrieval endpoint, authenticated (higher bandwidth)
        "annotation_images_anon": "200/hour",  # Image retrieval endpoint, anonymous
        "document_imports": "120/hour",  # Multipart document import endpoints
        # Chunked-upload part PUTs: one large file fans out into many part
        # requests, so this scope is far looser than ``document_imports``
        # (which is sized for whole-file imports). ``start``/``complete`` stay
        # on the strict ``document_imports`` scope.
        "document_import_chunks": "5000/hour",
    },
}


# Base configuration
BASE_PATH = "./"
DATA_PATH = Path(BASE_PATH, "data")
MODEL_PATH = Path(BASE_PATH, "model")

# Graphene
# ------------------------------------------------------------------------------
# Start with the base middleware that we always want
GRAPHENE_MIDDLEWARE = [
    # Concurrently pre-signs a Document connection page's file URLs (no-op
    # unless FILE_URL_SHARED_CACHE_TTL > 0). Outermost so its ``next()`` returns
    # the fully-resolved connection.
    "config.graphql.file_url_prewarm.FileUrlPrewarmMiddleware",
    "config.graphql.permissioning.permission_annotator.middleware.PermissionAnnotatingMiddleware",
]

# JWT middleware is always needed — both Auth0 and password login
# return JWT tokens via the tokenAuth mutation, and subsequent
# GraphQL requests use Authorization: Bearer <token>.
GRAPHENE_MIDDLEWARE.append("graphql_jwt.middleware.JSONWebTokenMiddleware")

# Add API Key middleware if enabled
if USE_API_KEY_AUTH:
    GRAPHENE_MIDDLEWARE.append(
        "config.graphql_api_token_auth.middleware.ApiKeyTokenMiddleware"
    )

# Add Django Debug Middleware if enabled
if ALLOW_GRAPHQL_DEBUG:
    GRAPHENE_MIDDLEWARE.append("graphene_django.debug.DjangoDebugMiddleware")

# Configure Graphene with the constructed middleware list
GRAPHENE = {
    "SCHEMA": "config.graphql.schema.schema",
    "MIDDLEWARE": GRAPHENE_MIDDLEWARE,
    # Increased from 10 for better performance with larger datasets
    "RELAY_CONNECTION_MAX_LIMIT": 100,
}

GRAPHQL_JWT = {
    "JWT_AUTH_HEADER_PREFIX": "Bearer",
    "JWT_VERIFY_EXPIRATION": True,
    "JWT_LONG_RUNNING_REFRESH_TOKEN": True,
    "JWT_EXPIRATION_DELTA": timedelta(days=7),
    "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=14),
    "JWT_ALGORITHM": "HS256",
    # "JWT_ALLOW_ANY_HANDLER": "config.graphql.jwt_overrides.allow_any",
}

# Reserved top-level user slugs (extendable)
RESERVED_USER_SLUGS = {
    "corpuses",
    "corpus",
    "documents",
    "document",
    "settings",
    "login",
    "logout",
    "admin",
    "api",
    "graphql",
}

# Constants for Permissioning
DEFAULT_PERMISSIONS_GROUP = "Public Objects Access"

# Embeddings / Semantic Search
# NOTE(deferred): These could be consolidated into an EMBEDDER_KWARGS dict
# (similar to PARSER_KWARGS) once all embedder backends are pluggable.
# Microservice URLs - read from environment with defaults
EMBEDDINGS_MICROSERVICE_URL = env(
    "EMBEDDINGS_MICROSERVICE_URL", default="http://vector-embedder:8000"
)
VECTOR_EMBEDDER_API_KEY = env("VECTOR_EMBEDDER_API_KEY", default="")
# CLIP embedder configuration (768-dimensional vectors)
CLIP_EMBEDDER_URL = env("CLIP_EMBEDDER_URL", default="http://vector-embedder:8000")
CLIP_EMBEDDER_API_KEY = env("CLIP_EMBEDDER_API_KEY", default="")

# Qwen embedder configuration (1024-dimensional vectors)
QWEN_EMBEDDER_URL = env("QWEN_EMBEDDER_URL", default="http://qwen-embedder:8000")
QWEN_EMBEDDER_API_KEY = env("QWEN_EMBEDDER_API_KEY", default="")

# Legacy multimodal embedder configuration (deprecated - use CLIP or Qwen settings above)
# Kept for backwards compatibility - maps to CLIP embedder
MULTIMODAL_EMBEDDER_HOST = env(
    "MULTIMODAL_EMBEDDER_HOST", default="multimodal-embedder"
)
MULTIMODAL_EMBEDDER_PORT = env.int("MULTIMODAL_EMBEDDER_PORT", default=8000)
MULTIMODAL_EMBEDDER_URL = env(
    "MULTIMODAL_EMBEDDER_URL",
    default=f"http://{MULTIMODAL_EMBEDDER_HOST}:{MULTIMODAL_EMBEDDER_PORT}",
)
MULTIMODAL_EMBEDDER_API_KEY = env("MULTIMODAL_EMBEDDER_API_KEY", default="")
# Vector dimensionality - must match the embedding model used by the microservice
# CLIP ViT-L-14: 768, CLIP ViT-B-32: 512, etc.
MULTIMODAL_EMBEDDER_VECTOR_SIZE = env.int(
    "MULTIMODAL_EMBEDDER_VECTOR_SIZE", default=768
)
# Weights for combining text and image embeddings in multimodal annotations
# Images weighted higher by default since multimodal annotations are often predominantly visual
MULTIMODAL_EMBEDDING_WEIGHTS = {
    "text_weight": env.float("MULTIMODAL_TEXT_WEIGHT", default=0.3),
    "image_weight": env.float("MULTIMODAL_IMAGE_WEIGHT", default=0.7),
}
DOCLING_PARSER_SERVICE_URL = env(
    "DOCLING_PARSER_SERVICE_URL", default="http://docling-parser:8000/parse/"
)
DOCLING_PARSER_TIMEOUT = env.int(
    "DOCLING_PARSER_TIMEOUT", default=DOCLING_PARSER_REQUEST_TIMEOUT_SECONDS
)
use_cloud_run_iam_auth = True

# Gotenberg Settings - for the optional pre-parse file-to-PDF converter
# (GotenbergFileConverter). These seed the converter's component settings via
# ``migrate_pipeline_settings`` (PipelineSetting env_var metadata); the DB
# PipelineSettings singleton is the runtime source of truth.
GOTENBERG_SERVICE_URL = env(
    "GOTENBERG_SERVICE_URL", default=DEFAULT_GOTENBERG_SERVICE_URL
)
GOTENBERG_CONVERTER_TIMEOUT = env.int(
    "GOTENBERG_CONVERTER_TIMEOUT", default=GOTENBERG_CONVERTER_REQUEST_TIMEOUT_SECONDS
)

# LlamaParse Settings - for LlamaParse document parser
# Supports both LLAMAPARSE_API_KEY and LLAMA_CLOUD_API_KEY (LlamaIndex's default env var)
_llamaparse_key = env.str("LLAMAPARSE_API_KEY", default="")
LLAMAPARSE_API_KEY = _llamaparse_key or env.str("LLAMA_CLOUD_API_KEY", default="")
LLAMAPARSE_RESULT_TYPE = env.str("LLAMAPARSE_RESULT_TYPE", default="json")
LLAMAPARSE_EXTRACT_LAYOUT = env.bool("LLAMAPARSE_EXTRACT_LAYOUT", default=True)
LLAMAPARSE_NUM_WORKERS = env.int("LLAMAPARSE_NUM_WORKERS", default=4)
LLAMAPARSE_LANGUAGE = env.str("LLAMAPARSE_LANGUAGE", default="en")
LLAMAPARSE_VERBOSE = env.bool("LLAMAPARSE_VERBOSE", default=False)

# ------------------------------------------------------------------------------
# privacy-filter microservice (PII detection)
# ------------------------------------------------------------------------------
# Reach the dockerised service over the internal compose network. When empty
# (e.g., production deployments that opt out), the agent tool returns a
# deterministic error string instead of raising.
PRIVACY_FILTER_URL = env("PRIVACY_FILTER_URL", default="")
PRIVACY_FILTER_API_KEY = env("PRIVACY_FILTER_API_KEY", default="")
PRIVACY_FILTER_TIMEOUT_SECONDS = env.int("PRIVACY_FILTER_TIMEOUT_SECONDS", default=30)

# LLM SETTING
OPENAI_API_KEY = env.str("OPENAI_API_KEY", default="")
OPENAI_MODEL = env.str("OPENAI_MODEL", default="gpt-4o")
# ``DEFAULT_LLM`` is the install-wide fallback for pydantic-ai agents,
# consulted by ``opencontractserver.llms.llm_registry.resolve_model_spec``
# *before* the legacy ``OPENAI_MODEL``.  Use the pydantic-ai
# provider-prefixed form, e.g.
#   DEFAULT_LLM = "anthropic:claude-sonnet-4-6"
# Per-corpus (``Corpus.preferred_llm``) and per-agent
# (``AgentConfiguration.preferred_llm``) values still win over this.
DEFAULT_LLM = env.str("DEFAULT_LLM", default="")
EMBEDDINGS_MODEL = env.str("EMBEDDINGS_MODEL", default="gpt-4o")
HF_TOKEN = env.str("HF_TOKEN", default="")
HF_EMBEDDINGS_ENDPOINT = env.str("HF_EMBEDDINGS_ENDPOINT", default="")

# CORPUS AUTO-BRANDING
# ------------------------------------------------------------------------------
# Install-wide kill-switch for auto-generating a logo + Readme.CAML article when
# a corpus is created (see ``opencontractserver/corpuses/signals.py`` and
# ``opencontractserver/corpuses/services/branding.py``). Per-corpus opt-out lives
# on ``Corpus.auto_branding_enabled``; uploading an icon also opts the corpus out.
CORPUS_AUTO_BRANDING_ENABLED = env.bool("CORPUS_AUTO_BRANDING_ENABLED", default=True)
# Toggles AI logo generation (OpenAI Images). When False — or when
# ``OPENAI_API_KEY`` is unset — auto-branding falls back to a deterministic PIL
# monogram logo, so a logo is still produced.
CORPUS_LOGO_GENERATION_ENABLED = env.bool(
    "CORPUS_LOGO_GENERATION_ENABLED", default=True
)

# CORS
# ------------------------------------------------------------------------------
# django-cors-headers v4.x settings
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "https://localhost:3000",
    "http://localhost:8000",
    "http://127.0.0.1:3000",
    "https://127.0.0.1:3000",
    "http://localhost:5173",
    "https://localhost:5173",
    "http://127.0.0.1:5173",
    "https://127.0.0.1:5173",
    # A second `yarn start` (e.g. the main checkout while a worktree holds :5173)
    # lands on the next free Vite port; allow it so anon GraphQL works there too.
    "http://localhost:5174",
    "https://localhost:5174",
    "http://127.0.0.1:5174",
    "https://127.0.0.1:5174",
    "http://localhost:5175",
    "https://localhost:5175",
    "http://127.0.0.1:5175",
    "https://127.0.0.1:5175",
]

# Allow only HTTP methods here
CORS_ALLOW_METHODS = [
    "DELETE",
    "GET",
    "OPTIONS",
    "PATCH",
    "POST",
    "PUT",
]

# If you send custom headers from the frontend, list them here. Defaults already
# include 'authorization' and 'content-type', but we explicitly add CSRF aliases.
CORS_ALLOW_HEADERS = [
    "accept",
    "accept-encoding",
    "authorization",
    "content-type",
    "dnt",
    "origin",
    "user-agent",
    "x-csrftoken",
    "x-csrf-token",
    "x-requested-with",
]

CORS_EXPOSE_HEADERS = [
    "my-custom-header",
    # Let browser-based MCP clients read the auth challenge + transport session
    # id on Django-served responses (e.g. the .well-known discovery endpoints).
    "WWW-Authenticate",
    "Mcp-Session-Id",
]

# When allowing credentials, do not use allow-all origins. Keep explicit list above.
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOW_CREDENTIALS = True

# Django requires this for cross-site cookies/POSTs from your Vite dev server
CSRF_TRUSTED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000",
    "http://localhost:5173",
    "http://127.0.0.1:5173",
]

DEFAULT_IMAGE = """data:image/png;base64, """  # noqa

# Model paths
DOCLING_MODELS_PATH = env.str("DOCLING_MODELS_PATH", default="/models/docling")

# Parser selection via environment variable
# Options: "docling" (default), "llamaparse"
PDF_PARSER = env.str("PDF_PARSER", default="docling")

# Map parser names to their full paths
_PDF_PARSER_MAP = {
    "docling": "opencontractserver.pipeline.parsers.docling_parser_rest.DoclingParser",
    "llamaparse": "opencontractserver.pipeline.parsers.llamaparse_parser.LlamaParseParser",
}

# Get the selected PDF parser (with fallback to docling)
_SELECTED_PDF_PARSER = _PDF_PARSER_MAP.get(
    PDF_PARSER.lower(), _PDF_PARSER_MAP["docling"]
)

# Preferred parsers for each MIME type
PREFERRED_PARSERS = {
    "application/pdf": _SELECTED_PDF_PARSER,
    "text/plain": "opencontractserver.pipeline.parsers.oc_text_parser.TxtParser",
    "application/txt": "opencontractserver.pipeline.parsers.oc_text_parser.TxtParser",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": _SELECTED_PDF_PARSER,  # noqa
    "application/vnd.openxmlformats-officedocument.presentationml.presentation": _SELECTED_PDF_PARSER,  # noqa
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "opencontractserver.pipeline.parsers.docling_parser_rest.DoclingParser",  # noqa
}

# Preferred enrichers for each MIME type. Unlike PREFERRED_PARSERS (one parser
# per MIME type), this maps a MIME type to an ORDERED LIST of enricher class
# paths: the ingest-time enrichment chain run between parsing and persistence.
# Each enricher transforms the parsed OpenContractDocExport and passes it to
# the next. Empty by default — enrichment is strictly opt-in. Example:
#   PREFERRED_ENRICHERS = {
#       "application/pdf": [
#           "opencontractserver.pipeline.enrichers.pdf_outline_enricher.PdfOutlineEnricher",  # noqa
#       ],
#   }
PREFERRED_ENRICHERS: dict[str, list[str]] = {}

# Image extraction size limits
# These prevent storage abuse and memory issues during PDF image extraction
MAX_IMAGE_SIZE_BYTES = env.int(
    "MAX_IMAGE_SIZE_BYTES", default=10 * 1024 * 1024  # 10MB per individual image
)
MAX_TOTAL_IMAGES_SIZE_BYTES = env.int(
    "MAX_TOTAL_IMAGES_SIZE_BYTES", default=100 * 1024 * 1024  # 100MB total per document
)
# DPI for rasterising PDF pages when an embedded image stream cannot be decoded
# directly. Page-render RSS scales as ~DPI^2, so raising this trades worker
# memory for sharper crops. Default of 150 keeps a US-letter page render
# under ~10 MB.
IMAGE_EXTRACTION_DPI = env.int("IMAGE_EXTRACTION_DPI", default=150)
# Force a full ``gc.collect()`` after this many pages of image extraction.
# Bounds peak RSS by reclaiming Poppler/PIL buffers proactively. Set to 0
# to disable explicit collection (rely on CPython's threshold-based GC).
IMAGE_EXTRACTION_GC_INTERVAL_PAGES = env.int(
    "IMAGE_EXTRACTION_GC_INTERVAL_PAGES", default=1
)

# Thumbnail extraction tasks
THUMBNAIL_TASKS = {
    "application/pdf": "opencontractserver.tasks.doc_tasks.extract_pdf_thumbnail",
    "application/txt": "opencontractserver.tasks.doc_tasks.extract_txt_thumbnail",
    "text/plain": "opencontractserver.tasks.doc_tasks.extract_txt_thumbnail",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "opencontractserver.tasks.doc_tasks.extract_docx_thumbnail",  # noqa
    # Add other MIME types and their thumbnail tasks as needed
}

# Annotation JSON validation
# When True, Annotation.clean() validates the structure of annotation JSON
# on every save. Enabled by default — the validation is a lightweight
# dict-key check with negligible cost, and provides an important safety net
# during the v1/v2 format co-existence period.
# TODO: Remove after v1 sunset — once migration 0066 has run on all
# environments and lazy compaction has converted remaining v1 rows, the
# v1/v2 co-existence guard is no longer needed and this can default to
# DEBUG-only validation.
VALIDATE_ANNOTATION_JSON = env.bool("VALIDATE_ANNOTATION_JSON", default=True)

# Mapping of MIME types to annotation label types
ANNOTATION_LABELS = {
    "application/pdf": "TOKEN_LABEL",
    "application/txt": "SPAN_LABEL",
    "text/plain": "SPAN_LABEL",
    "text/markdown": "SPAN_LABEL",
    "text/x-python": "SPAN_LABEL",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "SPAN_LABEL",  # noqa
    # "text/html": "SPAN_LABEL",  # Removed as we don't support HTML
    # Add other MIME types as needed
}

# Map of MIME types to label types
MIMETYPE_TO_LABEL_TYPE = {
    "application/pdf": "SPAN_LABEL",
    "text/plain": "SPAN_LABEL",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "SPAN_LABEL",
    # "text/html": "SPAN_LABEL",  # Removed as we don't support HTML
}

# Map of MIME types to preferred embedders
PREFERRED_EMBEDDERS = {
    "application/pdf": "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder",  # noqa:
    "text/plain": "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder",  # noqa:
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder",  # noqa:
    # "text/html": "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder",  # Removed as we don't support HTML  # noqa:
}

# Default embedder to use if no preferred embedder is found
DEFAULT_EMBEDDER = "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder"

# Default embedding dimension to use if no dimension is specified
DEFAULT_EMBEDDING_DIMENSION = 768


# Default runner
TEST_RUNNER = "opencontractserver.tests.runner.TerminateConnectionsTestRunner"

PARSER_KWARGS = {
    "opencontractserver.pipeline.parsers.docling_parser.DoclingParser": {
        "force_ocr": False,
        "roll_up_groups": True,
        "llm_enhanced_hierarchy": False,
    },
    "opencontractserver.pipeline.parsers.llamaparse_parser.LlamaParseParser": {
        "api_key": LLAMAPARSE_API_KEY,
        "result_type": "json",
        "extract_layout": True,
        "num_workers": 4,
        "language": "en",
        "verbose": False,
    },
}

# Enabled pipeline components. An empty list means all registered components are enabled.
# To restrict to specific components, list their full class paths, e.g.:
#   ENABLED_COMPONENTS = ["opencontractserver.pipeline.parsers.docling_parser.DoclingParser"]
ENABLED_COMPONENTS: list[str] = []

# Optional pre-parse file converter (BaseFileConverter class path). When set,
# uploads whose extension is in the converter's enabled set are converted to
# PDF at the head of the ingest chain and then parsed by the normal PDF
# pipeline. Empty string (the default) disables the conversion step — enable
# it here or in the admin System Settings UI, e.g.:
#   DEFAULT_FILE_CONVERTER=opencontractserver.pipeline.file_converters.gotenberg_converter.GotenbergFileConverter  # noqa: E501
DEFAULT_FILE_CONVERTER = env.str("DEFAULT_FILE_CONVERTER", default="")

# Analyzers
# ------------------------------------------------------------------------------
ANALYZER_KWARGS = {
    "opencontractserver.tasks.doc_analysis_tasks.agentic_highlighter_claude": {
        "ANTHROPIC_API_KEY": env.str("ANTHROPIC_API_KEY", default=""),
    },
}

# Pipeline-specific settings that override global settings
# These are only set if you want to override the global settings for specific components
# Otherwise, components will fall back to the global settings (which read from env vars)
PIPELINE_SETTINGS: dict[str, dict[str, Any]] = {
    # Example: To override settings for a specific component:
    # "opencontractserver.pipeline.embedders.sent_transformer_microservice.MicroserviceEmbedder": {
    #     "embeddings_microservice_url": "https://custom-url-for-this-component",
    #     "vector_embedder_api_key": "custom-key",
    # },
    # Currently no overrides - all components use global settings which read from env vars
}

# Pipeline Settings Encryption Configuration
# ------------------------------------------------------------------------------
# These settings control how sensitive pipeline configuration (API keys, etc.)
# is encrypted at rest in the PipelineSettings model.

# Length of random salt prepended to encrypted secrets (in bytes)
# 16 bytes = 128 bits, recommended minimum for secure encryption
PIPELINE_SETTINGS_ENCRYPTION_SALT_LENGTH = 16

# PBKDF2 iterations for key derivation from SECRET_KEY
# OWASP 2023 recommends 480,000 iterations for PBKDF2-HMAC-SHA256
# Higher = more secure but slower; only impacts save/load of secrets
PIPELINE_SETTINGS_ENCRYPTION_ITERATIONS = env.int(
    "PIPELINE_SETTINGS_ENCRYPTION_ITERATIONS", default=480000
)

# Maximum size for secrets payload (in bytes)
# Prevents storage abuse; 10KB is generous for API keys/tokens
PIPELINE_SETTINGS_MAX_SECRET_SIZE_BYTES = 10240

# Cache TTL for PipelineSettings singleton (in seconds)
# Reduces database queries during document processing
# Cache is invalidated on any settings update
PIPELINE_SETTINGS_CACHE_TTL_SECONDS = env.int(
    "PIPELINE_SETTINGS_CACHE_TTL_SECONDS", default=300
)

LLMS_DEFAULT_AGENT_FRAMEWORK = "pydantic_ai"

# Deep Research Agent
# ------------------------------------------------------------------------------
# Budget knobs for ``opencontractserver.tasks.research_tasks.run_deep_research``.
# Per-job ``max_steps`` lives on the ResearchReport row; these are the system
# ceilings and defaults.
DEEP_RESEARCH_DEFAULT_MAX_STEPS = env.int("DEEP_RESEARCH_DEFAULT_MAX_STEPS", default=60)
DEEP_RESEARCH_MAX_TOKENS_DEFAULT = env.int(
    "DEEP_RESEARCH_MAX_TOKENS_DEFAULT", default=400_000
)
DEEP_RESEARCH_SOFT_TIME_LIMIT = env.int(
    "DEEP_RESEARCH_SOFT_TIME_LIMIT", default=60 * 30
)  # 30 min
DEEP_RESEARCH_HARD_TIME_LIMIT = env.int(
    "DEEP_RESEARCH_HARD_TIME_LIMIT", default=60 * 60
)  # 60 min
DEEP_RESEARCH_STUCK_THRESHOLD_SECONDS = env.int(
    "DEEP_RESEARCH_STUCK_THRESHOLD_SECONDS",
    default=DEEP_RESEARCH_SOFT_TIME_LIMIT * 2,
)
DEEP_RESEARCH_SIMILARITY_TOP_K = env.int("DEEP_RESEARCH_SIMILARITY_TOP_K", default=6)
# Soft-block: don't start a second QUEUED/RUNNING report for the same
# (user, corpus) within this many seconds.
DEEP_RESEARCH_CONCURRENCY_GUARD_SECONDS = env.int(
    "DEEP_RESEARCH_CONCURRENCY_GUARD_SECONDS", default=60 * 60
)

# Default Agent Instructions
# ------------------------------------------------------------------------------
DEFAULT_DOCUMENT_AGENT_INSTRUCTIONS = """━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️  ABSOLUTE REQUIREMENTS - NO EXCEPTIONS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. You have ZERO prior knowledge of this document's contents.
2. You MUST use tools to examine the document before answering ANY question.
3. NEVER say you don't know what document is being discussed.
4. NEVER refuse to answer because you 'lack context' - USE THE TOOLS to get context.
5. Every answer MUST be grounded in information retrieved via tools with specific citations.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 RECOMMENDED SEARCH STRATEGY:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

For most questions, follow this workflow:

STEP 1 - GET OVERVIEW:
  • Use `load_document_summary` to understand the document's structure and main topics
  • Use `get_document_text_length` to check the document size
  • This helps you plan your detailed search strategy

STEP 2 - BROAD SEARCH (Semantic Understanding):
  • Use `similarity_search` (vector search) to find semantically relevant sections
  • Great for: conceptual questions, themes, related ideas, paraphrased content
  • Returns: annotated passages with page numbers and similarity scores

STEP 3 - DETAILED EXAMINATION:
  • Use `load_document_text` to read large sections (5K-50K chars) of relevant areas
  • Identify the specific character ranges from Step 1-2, then load those sections
  • Read enough context to thoroughly understand the relevant passages

  🔴 MANDATORY CITATION STEP - DO NOT SKIP:
  After reading ANY bulk text with `load_document_text`, you MUST:
  1. Identify the 3-5 most relevant exact quotes/passages for your answer
  2. Extract the EXACT text of each key passage (5-50 words each)
  3. Call `search_exact_text` with these exact strings to create proper citations
  4. This converts raw text into citable sources with page numbers

  WHY THIS MATTERS: `load_document_text` returns raw text WITHOUT creating sources.
  Only `search_exact_text` creates proper citations. Without this step, your answer
  will have NO SOURCES even though you read the document!

STEP 4 - PRECISE LOCATION (Exact Matching):
  • Use `search_exact_text` to find specific terms, phrases, or quoted language
  • Great for: finding exact wording, specific terminology, quoted passages, defined terms
  • Returns: all occurrences with page numbers and bounding boxes (PDFs)
  • Use this to provide precise citations with exact page locations
  • CRITICAL: Always use this AFTER bulk text loading to create proper source citations

STEP 5 - CROSS-REFERENCE:
  • Use `get_document_notes` to check for existing analysis or annotations
  • Combine findings from multiple tools to ensure completeness

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔧 TOOL SELECTION GUIDE:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Use `similarity_search` when:
  → Question asks about concepts, themes, or ideas (not exact words)
  → You need to find related content even if worded differently
  → Looking for passages that discuss a topic

Use `search_exact_text` when:
  → User asks about specific terms, phrases, or exact wording
  → You need to verify if specific language appears in the document
  → Providing citations that require exact page locations
  → Finding defined terms or quoted material

Use `load_document_text` when:
  → You need to read substantial sections for full context
  → Initial searches identified relevant areas to examine in detail
  → Question requires understanding flow, structure, or relationships
  ⚠️  ALWAYS follow with `search_exact_text` on key passages to create citations!

Use `load_document_summary` when:
  → Starting your analysis (always good first step)
  → Need high-level overview of document structure
  → Understanding document organization before detailed search

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ RESPONSE REQUIREMENTS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

• Provide complete, accurate answers based on document contents
• Include specific citations (page numbers, quotes) from tool results
• 🔴 CRITICAL: If you used `load_document_text`, you MUST use `search_exact_text`
  on key passages to generate proper citations. Otherwise your answer will have NO SOURCES.
• If information isn't in the document, explicitly state it was not found
• Use multiple search strategies to ensure thoroughness
• Present findings clearly with proper attribution to sources"""

DEFAULT_CORPUS_AGENT_INSTRUCTIONS = """You are the voice of this corpus — the collected knowledge \
contained in these documents, and your purpose is to represent that knowledge faithfully to anyone who asks.

**YOUR IDENTITY:**
You speak as the corpus itself — when a user asks "what do you know about X?", you answer from \
the perspective of the knowledge contained in your documents. You are not an external analyst; \
you ARE this body of knowledge, speaking directly.

**EPISTEMIC RULES (NON-NEGOTIABLE):**
1. NEVER infer, extrapolate, speculate, or draw conclusions beyond what is explicitly stated in the documents.
2. Clearly distinguish between what the documents explicitly state and what might be implied or interpreted.
3. If the documents do not contain information on a topic, say so plainly — this is a valuable answer, not a failure.
4. When documents contain conflicting information, present all sides without resolving the conflict yourself.

**TOOL USAGE:**
- ALWAYS use tools to retrieve information before answering — never rely on assumptions about your contents.
- `list_documents()` — see all documents in the corpus
- `ask_document(document_id, question)` — query a specific document
- `similarity_search(query)` — semantic search across all documents

**STRATEGY:**
1. If the corpus has a description, use it as orienting context.
2. If the corpus description is empty, use `list_documents()` to understand your contents, then examine \
key documents as needed.
3. For broad questions, search across multiple documents to ensure completeness.
4. ALWAYS cite the specific document(s) your information comes from.

**RESPONDING WITH AUTHORITY AND HUMILITY:**
- Speak confidently about what IS in the documents — you know your own contents.
- Be transparent about gaps: "My documents do not address this topic" is a direct, honest answer.
- Never pad answers with general knowledge or outside information. Your knowledge boundary is the boundary \
of your documents."""

DEFAULT_LOCATION_TAGGER_INSTRUCTIONS = """You are the Location Tagger — an automated agent that finds geographic place \
names in a document and turns them into geocoded annotations so they can be plotted on a map.

**YOUR TASK:**
Read the document, identify every mention of a country, U.S. state, or city, and create an \
annotation for each one using the `add_annotations_from_exact_strings` tool. Use exactly these \
three label types:
- `OC_COUNTRY` — for countries (e.g. "France", "United States").
- `OC_STATE` — for U.S. states / first-level administrative divisions (e.g. "Texas", "California").
- `OC_CITY` — for cities and localities (e.g. "Austin", "Paris").

**HOW TO CALL THE TOOL:**
Pass a list of items. Each item is `{"label_text": <one of OC_COUNTRY/OC_STATE/OC_CITY>, \
"exact_string": <text exactly as it appears in the document>, "hints": {...}}`.
- `exact_string` MUST match the document text character-for-character (the tool finds and \
annotates every exact occurrence). Do not paraphrase or change casing.
- Group multiple places into a single tool call where possible.

**DISAMBIGUATION RULES (IMPORTANT):**
Many place names are ambiguous ("Paris" is in both France and Texas; "Springfield" is in many \
U.S. states). Always supply hints so the geocoder resolves the right place:
- When tagging a CITY, include the country in `hints` and — if the city is in the United States — \
the two-letter state code. Example: for "Paris" in a document about Texas, send \
`"hints": {"country": "US", "state": "TX"}`; for "Paris" in a French context send \
`"hints": {"country": "FR"}`.
- When tagging a U.S. STATE, you may include `"hints": {"country": "US"}`.
- Countries are self-disambiguating; hints are optional for `OC_COUNTRY`.
Infer the hints from the surrounding context of the document (nearby country/state mentions, the \
document's subject matter). When the context is genuinely unclear, omit the hint rather than \
guessing wildly — the geocoder falls back to the most prominent match.

**RULES:**
- Only tag real geographic places. Do not tag organizations, person names, or adjectives that \
merely resemble place names unless they clearly refer to the place.
- Do not invent text. Every `exact_string` must be copied verbatim from the document.
- If you find no recognizable places, do nothing and say so briefly."""

# Global cap on concurrent per-chunk LLM calls in the Tier-2b enrichment pass
# (across all documents in a run). None => use the code default
# (opencontractserver.enrichment.constants.LLM_MAX_CONCURRENCY, 8). Raise it for
# more provider throughput at the cost of higher rate-limit / cost exposure.
ENRICHMENT_LLM_MAX_CONCURRENCY = env.int("ENRICHMENT_LLM_MAX_CONCURRENCY", default=None)

# Cap on documents processed concurrently within a single enrichment run
# (the outer fan-out around the per-chunk LLM cap above). None => use the code
# default (opencontractserver.enrichment.constants.DOC_MAX_CONCURRENCY). Tune
# per-environment alongside ENRICHMENT_LLM_MAX_CONCURRENCY to balance run
# latency against DB/connection pressure.
ENRICHMENT_DOC_MAX_CONCURRENCY = env.int("ENRICHMENT_DOC_MAX_CONCURRENCY", default=None)

# Out-of-tree authority-pack directories. Each entry is a self-contained pack
# directory (pack.yaml + optional providers/ + mappings/specs/personas). The
# pipeline registry scans every pack here — in addition to the in-tree packs under
# opencontractserver/enrichment/data/authority_packs/ — for provider modules under
# <pack>/providers/, so an authority pack copied to this install brings its scraper
# with it WITHOUT dropping a .py into core. Comma-separated absolute paths in the
# AUTHORITY_PACK_PATHS env var; empty by default. (Provider discovery happens at
# registry build, so adding a path needs a worker/web restart — same as any new
# in-tree provider.)
AUTHORITY_PACK_PATHS = env.list("AUTHORITY_PACK_PATHS", default=[])

# Rate Limiting Configuration
# ------------------------------------------------------------------------------
# Import rate limiting settings
from config.settings.ratelimit import *  # noqa: F401, F403, E402

# Telemetry Configuration
# ------------------------------------------------------------------------------
# Please check out the telemtry package or its usage if you want more details.
# You absolutely can disable this. We are collecting frequence of high-level action
# per installation (identified by a UUID with no PII) to help improve the application.
TELEMETRY_ENABLED = env.bool("TELEMETRY_ENABLED", default=True)
# IF you wanted to use your own telemetry, use your API key and host here.
POSTHOG_API_KEY = env.str(
    "POSTHOG_API_KEY", default="phc_wsTXvOFv6QLDMOA3yLl16awF4DTgILi4MSVLwhwyDeJ"
)
POSTHOG_HOST = env.str("POSTHOG_HOST", default="https://us.i.posthog.com")
# Secret salt for IP hashing - prevents rainbow table attacks on hashed IPs
# Generate a unique salt for your deployment: python -c "import secrets; print(secrets.token_hex(32))"
TELEMETRY_IP_SALT = env.str(
    "TELEMETRY_IP_SALT", default="opencontracts-default-ip-salt-change-in-production"
)
MODE = "LOCAL"

# MCP Server Configuration
# ------------------------------------------------------------------------------
# See docs/mcp/mcp_interface_proposal.md for details
MCP_SERVER = {
    "enabled": env.bool("MCP_SERVER_ENABLED", default=False),
    "max_results_per_page": env.int("MCP_MAX_RESULTS_PER_PAGE", default=100),
    "rate_limit": {
        "requests": env.int("MCP_RATE_LIMIT_REQUESTS", default=100),
        "window": env.int("MCP_RATE_LIMIT_WINDOW", default=60),
    },
    "cache_ttl": env.int("MCP_CACHE_TTL", default=300),
}

# Origins permitted to call the MCP endpoints (/mcp*) from a browser. MCP
# requests are routed to the MCP ASGI app *before* Django, so
# django-cors-headers never runs on them; CORS is enforced inside the MCP app
# (opencontractserver/mcp/server.py), which also merges in CORS_ALLOWED_ORIGINS
# at request time. Hosted Claude/ChatGPT connectors call server-side and don't
# need this, but browser clients and the MCP Inspector do.
# The hosted connectors are always safe to default-allow. The MCP Inspector
# loopback origins (npx @modelcontextprotocol/inspector, port 6274) are only
# folded into the default under DEBUG so they never ship in a production
# default — any other process binding :6274 on a deployed host would otherwise
# inherit credentialed cross-origin access. Operators who need them in a
# non-DEBUG environment can still set MCP_CORS_ALLOWED_ORIGINS explicitly.
_MCP_CORS_DEFAULT_ORIGINS = [
    "https://claude.ai",
    "https://chatgpt.com",
    "https://chat.openai.com",
]
if DEBUG:
    _MCP_CORS_DEFAULT_ORIGINS += [
        "http://localhost:6274",
        "http://127.0.0.1:6274",
    ]
MCP_CORS_ALLOWED_ORIGINS = env.list(
    "MCP_CORS_ALLOWED_ORIGINS",
    default=_MCP_CORS_DEFAULT_ORIGINS,
)

# Optional trusted public base URL (scheme://host) for absolute URLs the MCP
# server emits — notably the RFC 9728 ``resource_metadata`` pointer in 401
# challenges. When empty the value is derived from the (sanitized) request
# Host; because MCP bypasses ALLOWED_HOSTS, pinning this in production is
# recommended (e.g. MCP_PUBLIC_BASE_URL=https://contracts.opensource.legal).
MCP_PUBLIC_BASE_URL = env.str("MCP_PUBLIC_BASE_URL", default="")
