"""Environment-aware configuration for P2HS backend."""
from __future__ import annotations

import os
from enum import Enum
from typing import Any
from urllib.parse import urlparse

from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Environment(str, Enum):
    DEV = "dev"
    PROD = "prod"


class AIProvider(str, Enum):
    OPENAI = "openai"
    CURSOR = "cursor"
    CLAUDE = "claude"
    CODEX = "codex"


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
    )

    environment: Environment = Environment.DEV

    # Server
    port: int = 8000
    # When False, uvicorn never uses --reload. Set false on shared/staging hosts if the worker respawns
    # in a loop. With --reload, only ``src/`` is watched (see ``main.run``), not the whole deploy CWD.
    uvicorn_reload: bool = True

    # Database
    database_url: str = "postgresql+asyncpg://p2hs:p2hs@localhost:5432/p2hs_dev"
    # Each process (API + arq worker) has its own pool. Raise on shared DB hosts; workers use short sessions for long jobs.
    database_pool_size: int = 10
    database_max_overflow: int = 10
    database_pool_timeout_seconds: int = 30
    # asyncpg connect() timeout (seconds). Avoids hung TCP when the DB host is unreachable.
    database_connect_timeout_seconds: int = 10

    # arq: concurrent jobs per worker process (codegen/git is heavy — keep low on same host as the API).
    arq_max_jobs: int = 3

    # Redis
    redis_url: str = "redis://localhost:6379/0"

    # Security
    # Shared Google web client. Only Core exchanges/refreshes platform tokens.
    google_platform_client_id: str = ""
    google_platform_client_secret: str = ""
    google_platform_redirect_uri: str = "https://devdash.p2hs.com/api/oauth/google/callback"
    google_platform_enabled_families: str = "identity"
    google_platform_session_ttl_seconds: int = 600
    # P2HS-owned HubSpot public app. Manual private-app/OAuth BYOK remains supported.
    hubspot_platform_client_id: str = ""
    hubspot_platform_client_secret: str = ""
    hubspot_platform_redirect_uri: str = "https://devdash.p2hs.com/api/oauth/hubspot/callback"
    hubspot_platform_enabled_families: str = "crm"
    hubspot_platform_session_ttl_seconds: int = 600
    # P2HS-owned Salesforce Connected App. Sandbox uses a separate deployment later.
    salesforce_platform_client_id: str = ""
    salesforce_platform_client_secret: str = ""
    salesforce_platform_redirect_uri: str = "https://devdash.p2hs.com/api/oauth/salesforce/callback"
    salesforce_platform_enabled_families: str = "crm"
    salesforce_platform_session_ttl_seconds: int = 600
    x_ads_platform_client_id: str = ""
    x_ads_platform_client_secret: str = ""
    x_ads_platform_redirect_uri: str = "https://devdash.p2hs.com/api/oauth/x_ads/callback"
    x_ads_platform_enabled_families: str = "ads"
    x_ads_platform_session_ttl_seconds: int = 600
    microsoft_ads_platform_client_id: str = ""
    microsoft_ads_platform_client_secret: str = ""
    microsoft_ads_platform_redirect_uri: str = "https://devdash.p2hs.com/api/oauth/microsoft_ads/callback"
    microsoft_ads_platform_enabled_families: str = "ads"
    microsoft_ads_platform_session_ttl_seconds: int = 600

    secret_key: str = "dev-secret-key-change-in-prod"
    jwt_secret: str = "dev-jwt-secret-change-in-prod"
    encryption_key: str = "dev-encryption-key-32-bytes!!"
    jwt_access_ttl_minutes: int = 15
    jwt_refresh_ttl_days: int = 30
    magic_link_ttl_minutes: int = 10
    # Fixed deep link used only by the first-party iOS/Android dashboard app.
    # It is intentionally configuration-owned: clients cannot supply redirect targets.
    mobile_magic_link_redirect_uri: str = "p2hs://auth/callback"
    turnstile_secret: str = ""
    turnstile_expected_hostnames: str = ""
    # Public widget for generated customer apps (hostname allowlist should include
    # *.p2hsapp.com and custom domains). Independent of the dashboard/login widget.
    generated_app_turnstile_site_key: str = ""
    generated_app_turnstile_secret: str = ""
    # Used only by local Turnstile provisioning tools; never by request handling.
    cloudflare_turnstile_token: str = ""

    # Feature flags
    debug: bool = False
    log_level: str = "INFO"
    auth_expose_magic_token_for_dev: bool = False  # Expose token_preview in magic/request response (tests/dev only)
    # Run integration diagnostics after API startup (logs warnings only). Set STARTUP_DIAGNOSTICS=false to disable.
    startup_diagnostics: bool = True
    # Dispatcher deployment is an explicit reconciliation operation. Keep worker
    # restarts side-effect free by default; the admin hosting route/reconciliation
    # job can deploy it when the dispatcher code or routes actually change.
    worker_dispatcher_deploy_on_startup: bool = False

    # GitHub App
    github_app_id: str = ""
    github_app_private_key: str = ""
    # Prefer a mounted PEM file on servers (.env newlines are easy to get wrong).
    github_app_private_key_file: str = Field(
        default="",
        validation_alias=AliasChoices("GITHUB_APP_PRIVATE_KEY_FILE", "GITHUB_APP_PRIVATE_KEY_PATH"),
    )
    github_installation_id: int = 0
    # Separate installation ID for template repos (may be in a different org/account).
    # Falls back to github_installation_id if not set.
    github_template_installation_id: int = 0
    # Comma-separated GitHub usernames to auto-invite as collaborators on new template repos.
    # Requires 'Administration: write' on the GitHub App. Example: "alice,bob,carol"
    github_template_repo_collaborators: str = ""
    github_org: str = ""
    github_webhook_secret: str = ""
    # GitHub **user** App (customer repos / external mirror) — separate from P2HS org app.
    github_user_app_id: str = ""
    github_user_app_private_key: str = ""
    github_user_app_private_key_file: str = Field(
        default="",
        validation_alias=AliasChoices(
            "GITHUB_USER_APP_PRIVATE_KEY_FILE",
            "GITHUB_USER_APP_PRIVATE_KEY_PATH",
        ),
    )
    github_user_app_webhook_secret: str = ""
    # GitHub App slug for https://github.com/apps/<slug>/installations/new
    # Post-install redirect in GitHub must point at the API only, e.g.
    # https://<api>/api/integrations/github-user/callback (not customer SPA origins).
    github_user_app_slug: str = ""
    # Fallback when Origin cannot be resolved (see resolve_github_user_mirror_return_url).
    # May be full URL to the callback route or origin-only; path also via *_REDIRECT_PATH.
    github_user_app_frontend_redirect_url: str = "http://localhost:5173/settings/github-user-callback"
    # Appended to request-resolved origin when building return URL (multi-tenant frontends).
    github_user_app_frontend_redirect_path: str = "/settings/github-user-callback"

    # GitLab OAuth app (customer-owned repos / external mirror), separate from P2HS GitHub org app.
    # Configure the GitLab app redirect URI to:
    # https://<api>/api/integrations/gitlab-user/callback
    gitlab_user_oauth_client_id: str = ""
    gitlab_user_oauth_client_secret: str = ""
    gitlab_user_oauth_base_url: str = "https://gitlab.com"
    gitlab_user_webhook_secret: str = ""
    gitlab_user_frontend_redirect_url: str = "http://localhost:5173/settings/gitlab-user-callback"
    gitlab_user_frontend_redirect_path: str = "/settings/gitlab-user-callback"

    # Bitbucket OAuth consumer (customer-owned repos / external mirror).
    # Configure the OAuth callback URL to:
    # https://<api>/api/integrations/bitbucket-user/callback
    bitbucket_user_oauth_client_id: str = ""
    bitbucket_user_oauth_client_secret: str = ""
    bitbucket_user_webhook_secret: str = ""
    bitbucket_user_frontend_redirect_url: str = "http://localhost:5173/settings/bitbucket-user-callback"
    bitbucket_user_frontend_redirect_path: str = "/settings/bitbucket-user-callback"

    # Azure DevOps OAuth/PAT (customer-owned repos / external mirror).
    # Configure the OAuth callback URL to:
    # https://<api>/api/integrations/azure-devops/callback
    azure_devops_oauth_client_id: str = ""
    azure_devops_oauth_client_secret: str = ""
    azure_devops_oauth_authorize_url: str = "https://app.vssps.visualstudio.com/oauth2/authorize"
    azure_devops_oauth_token_url: str = "https://app.vssps.visualstudio.com/oauth2/token"
    azure_devops_oauth_scope: str = "vso.code vso.profile"
    azure_devops_webhook_secret: str = ""
    azure_devops_frontend_redirect_url: str = "http://localhost:5173/settings/azure-devops-callback"
    azure_devops_frontend_redirect_path: str = "/settings/azure-devops-callback"

    # Shopify platform Partner App — registered once by the P2HS operator on partners.shopify.com.
    # Redirect URI to set in Partner Dashboard: https://<api>/api/integrations/shopify-app/callback
    # Users never see these; they only enter their shop domain and click Connect.
    shopify_platform_client_id: str = ""
    shopify_platform_client_secret: str = ""
    shopify_oauth_scopes: str = "read_products,write_products,read_orders,write_orders"
    # Public URL of the Shopify BFF Worker — used to register webhook subscriptions.
    # Example: https://p2hs-ai-assist-shopify-bff.example.workers.dev
    shopify_app_bff_url: str = ""

    # Stripe
    stripe_secret_key: str = ""
    stripe_webhook_secret: str = ""
    stripe_publishable_key: str = ""
    stripe_subscription_price_id: str = ""
    stripe_metered_price_id: str = ""
    # Only allow unsigned webhook payloads when explicitly enabled (local dev / testing)
    stripe_webhook_allow_unsigned: bool = False

    # Discord community (P2HS server join via dashboard OAuth BFF)
    discord_client_id: str = ""
    discord_client_secret: str = ""
    discord_bot_token: str = ""
    discord_guild_id: str = ""
    discord_oauth_redirect_uri: str = "https://devdash.p2hs.com/api/integrations/discord/callback"

    # Email (resend | mailgun | ses)
    email_provider: str = "resend"
    resend_api_key: str = ""
    mailgun_api_key: str = ""
    mailgun_domain: str = ""
    # SES: FROM_EMAIL must be a verified identity in the SES account/region.
    # Keys are optional when the process already has an IAM role / default AWS chain.
    ses_region: str = Field(
        default="us-east-1",
        validation_alias=AliasChoices("SES_REGION", "AWS_REGION"),
    )
    ses_access_key_id: str = Field(
        default="",
        validation_alias=AliasChoices("SES_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
    )
    ses_secret_access_key: str = Field(
        default="",
        validation_alias=AliasChoices("SES_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"),
    )
    ses_configuration_set: str = ""
    from_email: str = "noreply@p2hs.app"
    from_name: str = "P2HS"
    # Generated-app / P2HS Cloud transactional email. Separate credentials from EMAIL_PROVIDER
    # above (auth, billing, ops). Cloud Workers must be given the matching CLOUD_EMAIL_* secrets.
    # Until a project connects its own Resend/Mailgun/SES connector, Cloud uses this default.
    cloud_email_provider: str = "resend"
    cloud_email_from: str = "noreply@p2hs.app"
    cloud_email_from_name: str = "P2HS"
    # P2HS-owned SMS default. Provider credentials stay in Cloud secrets; the
    # provider is intentionally not part of the generated-app/public contract.
    cloud_sms_provider: str = "twilio"
    cloud_sms_from: str = ""
    cloud_email_reply_to: str = ""
    cloud_resend_api_key: str = ""
    cloud_mailgun_api_key: str = ""
    cloud_mailgun_domain: str = ""
    cloud_mailgun_region: str = "us"
    cloud_ses_region: str = "us-east-1"
    cloud_ses_access_key_id: str = ""
    cloud_ses_secret_access_key: str = ""
    cloud_ses_configuration_set: str = ""
    # Managed per-workspace email domains (src/domain/email_domains/) — a workspace's own
    # verified sending domain, hosted on this same AWS SES account/region as cloud_ses_*
    # above, but with a per-workspace identity + Configuration Set instead of the shared
    # platform default. Overage rate is DB/env-configurable by design (never a Stripe Price)
    # so it can be repriced without touching the Stripe catalog; a workspace-level override
    # lives on WorkspaceEmailQuota.overage_rate_per_1000_minor.
    email_overage_rate_per_1000_usd_cents: int = 30
    # Additional verified Resend sender addresses available in the platform admin composer.
    # Comma-separated; FROM_EMAIL is always included.
    admin_email_from_addresses: str = ""
    # Comma-separated inboxes notified for website contact and enterprise inquiry forms.
    ops_notify_emails: str = (
        "support@p2hs.com,mohsin@pie-solutions.com,muhammad.zakir@pie-solutions.com"
    )

    # App URL (for magic link, WebAuthn origin)
    app_url: str = "http://localhost:5173"
    api_url: str = "http://localhost:8000"
    # Home/marketing site URL (used in recovery emails, signup cancel URLs)
    home_url: str = "https://p2hs.com"
    agent_access_enabled: bool = False
    agent_proxy_secret: str = ""
    agent_mcp_url: str = "https://mcp.p2hs.com/mcp"
    # Public OpenAI MCP deployment. It uses the same Core OAuth/grant service,
    # but receives a distinct RFC 8707 audience for its isolated tool profile.
    agent_openai_mcp_url: str = "https://openai.p2hs.com/mcp"
    # A2A JSON-RPC endpoint (hosted on Dashboard, not this private API). A second
    # valid OAuth `resource` (RFC 8707) alongside agent_mcp_url — same grants,
    # same scopes, distinct audience-bound tokens per resource.
    agent_a2a_url: str = "https://devdash.p2hs.com/a2a"
    agent_access_ttl_seconds: int = Field(default=900, ge=60, le=3600)
    agent_refresh_ttl_days: int = Field(default=30, ge=1, le=90)
    # Whitelist of allowed client app URLs (comma-separated). Used to resolve dynamic base URL per request.
    # APP_URL origin is always implicitly allowed when this list is non-empty (see app_url._effective_client_whitelist).
    # Example: APP_URL_WHITELIST=https://app1.p2hs.com,https://app2.p2hs.com,http://localhost:5173
    app_url_whitelist: str = ""
    # When True, resolve client origin from X-Forwarded-Host + X-Forwarded-Proto if Origin is missing (BFF/proxy).
    trust_x_forwarded_host: bool = False
    # Shared secret sent by the WordPress gateway as X-P2HS-Gateway-Token.
    # Keep this server-side only; WordPress sites never receive it.
    wp_gateway_token: str = Field(
        default="",
        validation_alias=AliasChoices(
            "P2HS_WP_GATEWAY_TOKEN",
            "WP_GATEWAY_TOKEN",
            "P2HS_GATEWAY_TOKEN",
            "X_P2HS_GATEWAY_TOKEN",
            "X-P2HS-GATEWAY-TOKEN",
            "X-P2HS-Gateway-Token",
            "x-p2hs-gateway-token",
        ),
    )
    # Server-side kill switch for the WordPress companion installer (install + package download).
    # Off by default: the installer accepted a sha256-only integrity check and the package is
    # replaced by the reviewed-runtime distribution (contract 13). Turn on only for a
    # controlled test.
    wp_companion_install_enabled: bool = Field(
        default=False,
        validation_alias=AliasChoices(
            "P2HS_WP_COMPANION_INSTALL_ENABLED", "WP_COMPANION_INSTALL_ENABLED"
        ),
    )
    # Edge attestation (docs/COMPANION_RUNTIME_CONTRACT.md 2A.2). Machine routes must come
    # through a P2HS Edge Worker that signs each request (src/core/edge_attestation.py).
    # Modes: "off" = legacy static token only; "report" = verify and log, legacy token
    # still accepted (rollout); "enforce" = attestation required, fail closed (target).
    # Any unrecognised value is treated as "enforce".
    edge_attestation_mode: str = Field(
        default="report",
        validation_alias=AliasChoices("P2HS_EDGE_ATTESTATION_MODE", "EDGE_ATTESTATION_MODE"),
    )
    # JSON: {"<kid>": {"secret": "<>=32 chars>", "edge": "wp_gateway|shopify_edge"}}.
    # Two entries per edge during key rotation. Server-side only.
    edge_attestation_keys: str = Field(
        default="",
        validation_alias=AliasChoices("P2HS_EDGE_ATTESTATION_KEYS", "EDGE_ATTESTATION_KEYS"),
    )
    # Public URL WordPress sites call for plugin API traffic (gateway Worker).
    # Leave empty in local dev to fall back to api_url (direct Core plugin routes).
    wp_gateway_public_url: str = Field(
        default="",
        validation_alias=AliasChoices(
            "WP_GATEWAY_PUBLIC_URL",
            "P2HS_WP_GATEWAY_PUBLIC_URL",
            "P2HS_WP_GATEWAY_URL",
        ),
    )
    # Plan quotas: workspaces that skip max_projects / internal caps (comma-separated UUIDs).
    # Prefer DB column workspaces.skip_plan_quota_limits for long-lived internal/QA tenants.
    quota_exempt_workspace_ids: str = ""

    # Staff email domains: registrants from these domains are auto-assigned the 'staff' plan
    # with skip_plan_quota_limits=true on workspace creation (comma-separated, e.g. "company.com,company.io").
    staff_email_domains: str = ""

    # WebAuthn (relying party)
    webauthn_rp_id: str = "localhost"
    webauthn_rp_name: str = "P2HS"

    # AI provider: openai | cursor | claude | codex
    ai_provider: AIProvider = AIProvider.OPENAI
    openai_api_key: str = ""
    openai_model: str = "gpt-4o"
    # Enable after configuring the classifier key/model and shared Redis budget.
    # In enforce mode, classifier outages stop admission instead of starting unchecked builds.
    project_intake_enabled: bool = False
    project_intake_model: str = "gpt-4o-mini"
    project_intake_timeout_seconds: float = Field(default=8, gt=0, le=30)
    project_intake_max_chars: int = Field(default=20000, gt=0, le=100000)
    project_intake_calls_per_minute: int = Field(default=60, gt=0)
    # Provider capacity is a separate execution primitive from the 60/min cost/abuse
    # counter. Safety fraction and wait deadline are policy, not provider quotas.
    # Do not encode org TPM/RPM numbers here.
    provider_capacity_enabled: bool = True
    provider_capacity_safety_fraction: float = Field(default=0.90, gt=0, le=1)
    provider_capacity_wait_seconds: float = Field(default=60, gt=0, le=120)
    provider_capacity_unknown_max_in_flight: int = Field(default=2, gt=0, le=32)
    provider_capacity_stale_observation_seconds: float = Field(default=120, gt=0, le=600)
    # Discovery rollout is independent of intake inference configuration.
    project_discovery_public_enabled: bool = False
    # Workspace→project membership projection (Authorization v2). Off until the owner backfill
    # and cohort gate are complete; reconciliation tasks no-op while disabled.
    project_membership_projection_enabled: bool = False
    # New projects run the Authorization v2 provisioning workflow (backfill, drift, shadow).
    # Off by default: enable on the server once the golden projects pass.
    authz_v2_auto_provision_enabled: bool = False
    # Core-only role and suspension mutations (membership holds). Off until the verifier has
    # passed live session checks and the cohort gates are closed; while off the routes return 503.
    authz_v2_core_mutations_enabled: bool = False
    # Discovery turns return a large nested schema; keep intake's cheap classifier at 8s.
    project_discovery_timeout_seconds: float = Field(default=25, gt=0, le=60)
    project_discovery_compile_timeout_seconds: float = Field(default=30, gt=0, le=120)
    project_discovery_workspace_ids: str = ""
    anthropic_api_key: str = ""
    claude_model: str = "claude-sonnet-4-20250514"
    # Cursor background agent binary — often named "agent" (e.g. ~/.local/bin/agent)
    cursor_cli_path: str = "agent"
    cursor_api_key: str = ""  # CURSOR_API_KEY — required for headless/server auth
    cursor_model: str = ""    # Model override e.g. "composer-2", "sonnet-4". Empty = agent default.
    cursor_agent_script: str = ""  # Optional: path to scripts/p2hs-cursor-agent.sh
    # When True, pass --output-format stream-json --stream-partial-output (agent apply only) for live tool/thinking-style NDJSON lines.
    # Requires a recent Cursor agent binary; set False if the CLI rejects these flags or the agent crashes on startup.
    cursor_agent_stream_progress: bool = True
    # When False, scripts/p2hs-cursor-agent.sh runs the agent without GNU coreutils ``timeout`` (Python still enforces cursor_cli_timeout).
    cursor_agent_coreutils_timeout: bool = True
    cursor_cli_timeout: int = 900
    # OpenAI Codex CLI — used by the opt-in per-run Codex CLI codegen test.
    codex_cli_path: str = "codex"
    codex_model: str = ""
    codex_api_key: str = ""
    codex_cli_timeout: int = 1800
    # Internal pricing policy: runtime is converted to credits at settlement time.
    # This is deliberately not exposed to browser clients or plan catalog responses.
    codegen_credit_rate_per_minute: int = 5
    codegen_build_validation_enabled: bool = False  # Gate successful runs on adapter build pass
    # ARQ job wall-clock timeout for codegen (must exceed cursor_cli_timeout + clone/git margin).
    codegen_job_timeout_seconds: int = 1800
    # If a run is still ``running`` with no pushed checkpoint after this long, the worker may reclaim it (arq retry).
    codegen_reclaim_running_seconds: int = 600
    # Recovery + codegen sweeper: mark ``running`` runs older than this as failed (UI spinner).
    # Aligned with stale-deploy recovery horizon (~45m); raise in prod only if you expect very long codegen.
    codegen_stale_running_seconds: int = 2700
    # Canonical Codegen placement. Production defaults to an external GitHub-hosted
    # runner. ``server`` remains a legacy development-only compatibility value.
    codegen_execution_provider: str = "github"
    codegen_execution_target: str = "hosted"
    codegen_execution_runner_class: str = "linux_standard"
    codegen_execution_config_version: str = "1"
    codegen_execution_pricing_version: str = "github-linux-2026-01"
    codegen_execution_provider_rate_usd_per_minute: str = "0.008"
    codegen_execution_credits_per_minute: int = 1
    codegen_allow_local_dev: bool = False
    # Deprecated bridge. Do not use this to select arbitrary runners.
    codegen_execution_backend: str = "github_actions"
    codegen_actions_workflow_file: str = "p2hs-codegen.yml"
    codegen_actions_callback_token: str = ""
    codegen_actions_payload_secret: str = ""
    codegen_actions_timeout_minutes: int = 30
    # Agent wall-clock inside the 30-minute GitHub job, leaving time to commit/push/callback.
    codegen_actions_agent_budget_seconds: int = 1320
    # Extra GitHub Actions dispatches after a save-and-restart (same codegen run).
    codegen_actions_max_continuations: int = 4
    # Run-scoped work branch (p2hs/codegen/<run_id>): the runner works and checkpoints there and
    # `preview` is only advanced after validation. Off by default so behaviour is unchanged until
    # it is enabled deliberately (see docs/CODEGEN_WORK_BRANCH.md).
    codegen_work_branch_enabled: bool = False
    # Failed/blocked/promoted work branches are kept this long, then deleted by the cleanup cron.
    codegen_work_branch_retention_days: int = 14
    # Convert a migrated/replaced static HTML prototype into Nuxt pages deterministically at dispatch, so the
    # agent adds wiring instead of re-typing (or statically serving) the pages. See docs/STATIC_TO_NUXT.md.
    codegen_static_conversion_enabled: bool = False
    # How many times the coding agent is sent back to fix its own invalid capability manifest.
    codegen_manifest_correction_attempts: int = 2
    # Desktop execution hardening: these stay False by default until a deployment explicitly opts
    # into sending short-lived repo/provider credentials to a paired device. The safer long-term
    # path is Core-mediated source sync and push.
    desktop_git_credentials_enabled: bool = False
    desktop_provider_credentials_enabled: bool = False
    # ROOT_PROJECT_FOLDER or REPO_WORKSPACE: checkout + project_meta siblings per tenant
    repo_workspace: str = Field(
        default="/var/p2hs/repos",
        validation_alias=AliasChoices("ROOT_PROJECT_FOLDER", "REPO_WORKSPACE"),
        description=(
            "Root for tenant folders: checkout at <root>/<workspace_id>/<project_id>; "
            "P2HS-only meta at <root>/<workspace_id>/project_meta/<project_id>."
        ),
    )
    # Used for `git commit` on workers when materializing project workspaces (Cursor runs in that tree).
    git_committer_name: str = "P2HS"
    git_committer_email: str = "noreply@p2hs.app"

    # Prompt multimodal uploads (images, voice, files) — stored on disk, copied into repo per codegen run.
    prompt_media_upload_root: str = Field(
        default="/var/p2hs/prompt-media",
        validation_alias=AliasChoices(
            "PROMPT_MEDIA_UPLOAD_ROOT",
            "P2HS_PROMPT_MEDIA_UPLOAD_ROOT",
            "VOICE_IMAGE_UPLOAD_DIR",
            "P2HS_VOICE_IMAGE_UPLOAD_DIR",
        ),
        description=(
            "Root only — files are stored as "
            "<root>/<workspace_id>/<project_id>/[session_id]/filename "
            "(tenant=workspace, project=project, optional chat=prompt session)."
        ),
    )
    prompt_media_max_bytes: int = Field(
        default=25 * 1024 * 1024,
        validation_alias=AliasChoices("PROMPT_MEDIA_MAX_BYTES", "P2HS_PROMPT_MEDIA_MAX_BYTES"),
        description="Max size per uploaded file (bytes).",
    )

    @model_validator(mode="after")
    def _align_codegen_job_timeout(self) -> "Settings":
        """ARQ must not kill codegen before Cursor/subprocess timeout + git margin."""
        floor = int(self.cursor_cli_timeout) + 180
        if self.codegen_job_timeout_seconds < floor:
            object.__setattr__(self, "codegen_job_timeout_seconds", floor)
        return self

    @field_validator("repo_workspace", mode="after")
    @classmethod
    def _repo_workspace_abs(cls, v: str) -> str:
        """Resolve relative REPO_WORKSPACE against process cwd so worker paths are stable."""
        return os.path.abspath(os.path.expanduser(v.strip() or "/var/p2hs/repos"))

    @field_validator("prompt_media_upload_root", mode="after")
    @classmethod
    def _prompt_media_upload_root_abs(cls, v: str) -> str:
        return os.path.abspath(os.path.expanduser(v.strip() or "/var/p2hs/prompt-media"))

    @field_validator("mobile_magic_link_redirect_uri", mode="after")
    @classmethod
    def _mobile_magic_link_redirect_uri(cls, v: str) -> str:
        uri = v.strip()
        parsed = urlparse(uri)
        if parsed.scheme != "p2hs" or not parsed.netloc:
            raise ValueError("MOBILE_MAGIC_LINK_REDIRECT_URI must use the p2hs:// scheme.")
        return uri.rstrip("/")

    @field_validator("cloudflare_w4p_trusted_workers", mode="before")
    @classmethod
    def _coerce_cloudflare_w4p_trusted_workers(cls, v: Any) -> bool:
        """Env vars arrive as strings; accept common truthy/falsey spellings."""
        if v is None or v == "":
            return False
        if isinstance(v, bool):
            return v
        if isinstance(v, str):
            return v.strip().lower() in ("1", "true", "yes", "on")
        return bool(v)

    # P2HS Cloud control plane (gateway → internal control endpoints)
    cloud_control_api_url: str = ""   # e.g. https://stag-cloudgate.p2hs.com
    cloud_public_api_url: str = ""    # e.g. https://stag-cloud.p2hs.com; falls back to cloud_control_api_url
    cloud_internal_token: str = ""    # Must match INTERNAL_CONTROL_TOKEN in cloud workers
    # Signing secret for Cloud authorization_id + grant JWTs (falls back to JWT_SECRET if empty)
    cloud_grant_jwt_secret: str = ""
    # Shared secret for Core's narrow auth_projection.write capability (Cloud: CORE_AUTH_PROJECTION_SECRET).
    cloud_auth_projection_secret: str = ""
    cloud_grant_ttl_minutes: int = 15
    cloud_authorization_token_ttl_days: int = 7

    # Server-side storage ingest staging (R2 dashboard upload → worker extract → R2)
    storage_staging_upload_root: str = Field(
        default="/var/p2hs/storage-staging",
        validation_alias=AliasChoices(
            "STORAGE_STAGING_UPLOAD_ROOT",
            "P2HS_STORAGE_STAGING_UPLOAD_ROOT",
        ),
        description=(
            "Root for files uploaded via /storage/ingest-upload before worker pushes to R2. "
            "Layout: <root>/<workspace_id>/<project_id>/ingest/<job_id>/filename"
        ),
    )
    storage_staging_max_bytes: int = Field(
        default=20 * 1024 * 1024 * 1024,
        validation_alias=AliasChoices("STORAGE_STAGING_MAX_BYTES", "P2HS_STORAGE_STAGING_MAX_BYTES"),
        description="Max size per ingest upload (bytes). Default 20 GB.",
    )
    storage_ingest_chunk_bytes: int = Field(
        default=50 * 1024 * 1024,
        validation_alias=AliasChoices("STORAGE_INGEST_CHUNK_BYTES", "P2HS_STORAGE_INGEST_CHUNK_BYTES"),
        description=(
            "Max bytes per ingest-upload part request. Keep under Cloudflare's ~100 MB proxy limit. Default 50 MB."
        ),
    )
    storage_ingest_single_post_max_bytes: int = Field(
        default=90 * 1024 * 1024,
        validation_alias=AliasChoices(
            "STORAGE_INGEST_SINGLE_POST_MAX_BYTES",
            "P2HS_STORAGE_INGEST_SINGLE_POST_MAX_BYTES",
        ),
        description="Files at or below this size may use one-shot /ingest-upload (default 90 MB).",
    )
    storage_ingest_worker_grace_seconds: int = Field(
        default=30,
        validation_alias=AliasChoices(
            "STORAGE_INGEST_WORKER_GRACE_SECONDS",
            "P2HS_STORAGE_INGEST_WORKER_GRACE_SECONDS",
        ),
        description=(
            "Seconds to wait for arq before the API process runs ingest inline. "
            "Use when the worker is down or Redis is misconfigured."
        ),
    )
    storage_ingest_running_stale_seconds: int = Field(
        default=20 * 60,
        validation_alias=AliasChoices(
            "STORAGE_INGEST_RUNNING_STALE_SECONDS",
            "P2HS_STORAGE_INGEST_RUNNING_STALE_SECONDS",
        ),
        description=(
            "Reset RUNNING ingest jobs to pending when updated_at is older than this. "
            "Covers API/worker crashes after claim so the host that still has the file can retry."
        ),
    )

    @field_validator("storage_staging_upload_root", mode="after")
    @classmethod
    def _storage_staging_upload_root_abs(cls, v: str) -> str:
        return os.path.abspath(os.path.expanduser(v.strip() or "/var/p2hs/storage-staging"))

    # R2 import staging bucket (file-upload / archive imports)
    r2_imports_account_id: str = ""      # CF account ID owning the R2 bucket
    r2_imports_bucket: str = ""          # R2 bucket name for staging import archives
    r2_imports_access_key_id: str = ""   # R2 API token access key
    r2_imports_secret_access_key: str = ""

    # Agent-access binary asset uploads (photos/video an MCP agent attaches to
    # start_build). Presign-and-direct-upload against the same R2 import-staging
    # bucket above, under a distinct `agent-uploads/` key prefix -- see
    # src/domain/agent_access/asset_upload.py. Deliberately separate from
    # prompt_media_max_bytes (25 MiB default), which is sized for chat-pasted
    # images/voice clips, not full video files.
    agent_asset_upload_max_bytes: int = Field(
        default=150 * 1024 * 1024,
        gt=0,
        description="Max size of a single agent-uploaded asset (bytes). Default 150 MiB.",
    )
    agent_asset_upload_total_bytes: int = Field(
        default=300 * 1024 * 1024,
        gt=0,
        description="Max combined asset byte size referenced by one start_build call. Default 300 MiB.",
    )
    agent_asset_upload_presign_ttl_seconds: int = Field(
        default=900,
        gt=0,
        description="How long a request_asset_upload presigned URL stays valid. Default 15 min.",
    )
    agent_asset_upload_max_zip_entries: int = Field(
        default=150,
        gt=0,
        description=(
            "Max file entries confirm_asset_upload will extract from an uploaded zip "
            "(see src/integrations/archive_extract.py). Per-entry and total-uncompressed-"
            "bytes caps reuse agent_asset_upload_max_bytes/agent_asset_upload_total_bytes."
        ),
    )

    # Cloudflare (API + account)
    cloudflare_api_token: str = ""       # Account-level token: Workers, KV, Pages, W4P
    cloudflare_account_id: str = ""
    # Optional dedicated Cloudflare Registrar account. Registrar operations
    # fall back to the general account above when these are unset.
    cloudflare_registrar_account_id: str = ""
    cloudflare_registrar_api_token: str = ""
    cloudflare_zone_id: str = ""         # Zone ID for the hosting apex (e.g. p2hsapp.com)
    # Hosted-app traffic monitoring. The analytics token needs Account Analytics: Read;
    # it falls back to cloudflare_api_token when that token has the same permission.
    # Workspaces (comma-separated IDs) whose deploys use the Cloudflare account assigned to them in
    # Cloud infra, instead of the global account. Empty means every deploy uses the global account.
    hosting_deploy_account_workspace_ids: str = ""
    # Projects created at or after this time (ISO-8601) deploy to the platform account, using the
    # NEW_* credentials, and use NEW_HOSTING_PUBLIC_APEX. Empty means no project is moved this way.
    hosting_platform_projects_created_after: str = ""
    # When false, the W4P cutover applies to whichever account the project resolves to
    # (global or workspace-assigned), without forcing the NEW_* platform account.
    hosting_platform_account_enabled: bool = False
    # Platform-account variables for the migration (Phase 1). Declared so the live .env loads;
    # nothing reads them until the cutover. Keep this list in step with the .env.
    new_app_url: str = ""
    new_cloud_control_api_url: str = ""
    new_hosting_cname_target: str = ""
    new_hosting_provider: str = ""
    new_hosting_public_apex: str = ""
    new_cloudflare_account_id: str = ""
    new_cloudflare_api_token: str = ""
    new_r2_imports_account_id: str = ""
    new_r2_imports_access_key_id: str = ""
    new_r2_imports_secret_access_key: str = ""
    new_r2_imports_bucket: str = ""
    new_cloudflare_analytics_api_token: str = ""
    new_cloudflare_turnstile_site_key: str = ""
    new_cloudflare_turnstile_token: str = ""
    new_cloudflare_zone_api_token: str = ""
    new_cloudflare_zone_id: str = ""
    new_turnstile_expected_hostnames: str = ""
    new_turnstile_secret: str = ""
    new_cloudflare_dispatch_kv_namespace_id: str = ""
    new_cloudflare_w4p_namespace: str = ""
    new_cloudflare_w4p_namespace_production: str = ""
    new_cloudflare_w4p_namespace_preview: str = ""
    new_cloudflare_w4p_namespace_staging: str = ""
    new_cloudflare_worker_compatibility_date: str = ""
    cloudflare_analytics_api_token: str = ""
    hosting_usage_analytics_dataset: str = "p2hs_hosting_usage"
    # Zone-level token for Custom Hostnames (CF for SaaS) — requires Zone > SSL and Certificates > Edit.
    # Falls back to cloudflare_api_token if not set, but a dedicated zone-scoped token is recommended.
    cloudflare_zone_api_token: str = ""
    # Domain commerce is deliberately opt-in because Registrar API calls bill the
    # platform account and the current Cloudflare API is still beta.
    domain_registrar_transactions_enabled: bool = False
    domain_markup_percent: int = 20
    domain_markup_fixed_usd_cents: int = 0
    domain_renewal_lead_days: int = 37
    domain_renewal_failure_cutoff_days: int = 30
    domain_stripe_success_path: str = "/domains"
    domain_stripe_cancel_path: str = "/domains"

    # Workers for Platforms + dispatcher KV (see src/domain/hosting/adapters/cloudflare_w4p.py)
    # KV stores host:<hostname> -> user Worker script name for the dispatch Worker.
    cloudflare_dispatch_kv_namespace_id: str = ""
    # Dispatch namespace names (per environment). Case is preserved (e.g. P2HS). Empty default:
    # per-env var -> CLOUDFLARE_W4P_NAMESPACE -> code fallback.
    cloudflare_w4p_namespace: str = ""
    cloudflare_w4p_namespace_production: str = ""
    cloudflare_w4p_namespace_preview: str = ""
    cloudflare_w4p_namespace_staging: str = ""
    cloudflare_w4p_trusted_workers: bool = False
    # Empty -> adapter uses today's date (YYYY-MM-DD).
    cloudflare_worker_compatibility_date: str = ""
    # Dispatch namespace holding Edge Function scripts (must match P2HSCloud's
    # pc-functions wrangler.toml [[dispatch_namespaces]] "namespace").
    cloudflare_functions_namespace: str = "p2hs-cloud-functions"

    # Hosting execution plane (see src/domain/hosting/)
    # "cloudflare_pages" = CF Pages (GitHub-connected, auto-build) + W4P proxy routing
    # "stub" = no real provider calls (local dev / CI)
    hosting_provider: str = "cloudflare_pages"
    # Audience the W4P activation call must carry in its GitHub OIDC token. Empty = endpoint disabled.
    w4p_activation_audience: str = ""
    # Apex for generated platform URLs: https://proj-<ref>.<apex>
    hosting_public_apex: str = "sites.p2hs.com"
    # CNAME target customers point their domains to (e.g. custom.sites.p2hs.com).
    # Must be a hostname in your CF zone that has "Cloudflare for SaaS" (Custom Hostnames)
    # configured with the dispatcher Worker as the fallback origin.
    # Set HOSTING_CNAME_TARGET in your environment — this default is a placeholder.
    hosting_cname_target: str = "custom.sites.p2hs.com"
    # Branch used for preview deployments (all codegen changes target this branch).
    hosting_preview_branch: str = "preview"
    # After GitHub push + DB seed, create initial production deploy and enqueue run_project_deploy.
    hosting_bootstrap_on_project_setup: bool = True

    # Boilerplate snapshots: path to directory containing <type>@<version>.tar.gz archives.
    # Defaults to boilerplates/snapshots/ relative to the project root when empty.
    boilerplate_snapshot_dir: str = ""

    @property
    def is_dev(self) -> bool:
        return self.environment == Environment.DEV

    @property
    def quota_exempt_workspace_id_set(self) -> set[str]:
        return {x.strip().lower() for x in self.quota_exempt_workspace_ids.split(",") if x.strip()}

    @property
    def staff_email_domain_set(self) -> set[str]:
        return {x.strip().lower() for x in self.staff_email_domains.split(",") if x.strip()}

    @property
    def ops_notify_email_list(self) -> list[str]:
        seen: list[str] = []
        for raw in self.ops_notify_emails.split(","):
            email = raw.strip().lower()
            if email and email not in seen:
                seen.append(email)
        return seen


def get_settings() -> Settings:
    return Settings()
