services:

  backend:
    build:
      context: .
      dockerfile: JobTrackerApi/Dockerfile
    volumes:
      - jobtracker_data:/data
      # Kept outside restored application data so an older database/data backup cannot erase the
      # deletion ledger used to detect and re-delete resurrected accounts.
      - jobtracker_deletion_tombstones:/account-lifecycle/tombstones
    environment:
      - ASPNETCORE_URLS=http://+:8080
      - Data__Root=/data
      - Exports__DailyFolder=/data/exports
      - AccountLifecycle__DeletionEnabled=${ACCOUNT_DELETION_ENABLED:-false}
      - AccountLifecycle__TombstonesRoot=/account-lifecycle/tombstones
      - Database__Provider=${DATABASE_PROVIDER:-sqlite}
      - ConnectionStrings__JobTracker=${JOBTRACKER_CONNECTION_STRING}
      # If you enable HTTPS at a reverse proxy (recommended), handle redirects there.
      - HttpsRedirection__Enabled=false
      # Backend is internal-only here; nginx is the sole trusted ingress.
      - Proxy__TrustForwardedHeaders=true
      - Proxy__KnownNetworks__0=${WEB_PROXY_SUBNET:-172.31.250.0/29}
      # Authentication (recommended for any non-local deployment)
      - Auth__Require=true
      - Auth__JwtKey=${AUTH_JWT_KEY}
      - Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
      - Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
      - Auth__AllowRegistration=${AUTH_ALLOW_REGISTRATION:-false}
      - Auth__RequireEmailVerification=${AUTH_REQUIRE_EMAIL_VERIFICATION:-false}
      - Turnstile__SiteKey=${TURNSTILE_SITE_KEY}
      - Turnstile__SecretKey=${TURNSTILE_SECRET_KEY}
      - Stripe__SecretKey=${STRIPE_SECRET_KEY}
      - Stripe__PricePremium=${STRIPE_PRICE_PREMIUM}
      - Stripe__WebhookSecret=${STRIPE_WEBHOOK_SECRET}
      # Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
      - Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
      - Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
      - Auth__MicrosoftTenant=${AUTH_MICROSOFT_TENANT}
      - Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
      # Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
      - Microsoft__ClientId=${MICROSOFT_CLIENT_ID}
      - Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET}
      - Microsoft__TenantId=${MICROSOFT_TENANT_ID}
      - Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001}
      - Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001}
      # External processing requires this admin gate AND a per-user opt-in. Default is local-only.
      - Ai__ExternalProcessingEnabled=${EXTERNAL_AI_ENABLED:-false}
      - Ai__ExternalProvider=${AI_PROVIDER:-ollama}
      - Ai__RoutingMode=${AI_ROUTING_MODE:-local_first}
      # Shared secret for calls to ai-service. Must match AI_SERVICE_TOKEN below.
      # Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
      - "Ai__ServiceToken=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
      # Email (SMTP)
      # Build metadata should be resolved before deployment. Examples:
      #   APP_VERSION=1.0.0
      #   APP_COMMIT_SHA=abc1234
      #   APP_BUILD_STAMP=2026-03-22 14:00 UTC
      # Do not set literal placeholders like $(git rev-parse --short HEAD) in .env.
      - App__PublicBaseUrl=${APP_PUBLIC_BASE_URL}
      - App__Version=${APP_VERSION}
      - App__CommitSha=${APP_COMMIT_SHA}
      - App__BuildStamp=${APP_BUILD_STAMP}
      - Email__Enabled=${EMAIL_ENABLED}
      - Email__SmtpHost=${EMAIL_SMTP_HOST}
      - Email__SmtpPort=${EMAIL_SMTP_PORT}
      - Email__SmtpEnableSsl=${EMAIL_SMTP_ENABLE_SSL}
      - Email__SmtpTimeoutMs=${EMAIL_SMTP_TIMEOUT_MS}
      - Email__SmtpUser=${EMAIL_SMTP_USER}
      - Email__SmtpPassword=${EMAIL_SMTP_PASSWORD}
      - Email__From=${EMAIL_FROM}
      - Email__FromName=${EMAIL_FROM_NAME}
      - Email__FollowUpReminders__Enabled=${EMAIL_FOLLOWUPREMINDERS_ENABLED:-false}
      - Email__FollowUpReminders__UpcomingDays=${EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS:-2}
      # These formerly inert workers stay off until their owner-safe behavior and downstream
      # notification/privacy/entitlement prerequisites have been explicitly rolled out.
      - Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
      - Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_ENABLED:-false}
      - Workers__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false}
      - Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
      - Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false}
      - AiQueue__WorkerConcurrency=${AI_QUEUE_WORKER_CONCURRENCY:-1}
      - AiQueue__GlobalCapacity=${AI_QUEUE_GLOBAL_CAPACITY:-100}
      - AiQueue__PerUserCapacity=${AI_QUEUE_PER_USER_CAPACITY:-10}
      - AiQueue__DeadlineMinutes=${AI_QUEUE_DEADLINE_MINUTES:-15}
      - AiQueue__OperationTimeoutSeconds=${AI_QUEUE_OPERATION_TIMEOUT_SECONDS:-300}
    expose:
      - "8080"
    networks:
      default:
      shared_services:
      web_proxy:
        aliases:
          - backend-web
      # The only other member of ai_internal — the backend is the sole permitted caller of
      # ai-service.
      ai_internal:
    restart: unless-stopped
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    # Liveness only. /health does not touch the database on purpose: a health check that queried
    # MariaDB would restart a healthy backend whenever the database blipped.
    # start_period covers first-boot schema reconciliation, which can take a while on a new database.
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 90s

  frontend:
    build:
      context: ./job-tracker-ui
      # Next's build type-checker needs more than Docker's default 64MB /dev/shm; too little
      # causes a SIGSEGV during `npm run build`.
      shm_size: '1gb'
      args:
        - NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
        - NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
        - NEXT_PUBLIC_MICROSOFT_TENANT=${AUTH_MICROSOFT_TENANT}
        # Optional override; default in production is `/api`
        - NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
    expose:
      - "80"
    # Compatibility ingress for the current production reverse proxy, which
    # targets the host's port 3000 rather than Docker service discovery. Keep
    # this until the operator-owned proxy is migrated onto jobtracker_shared.
    ports:
      - "3000:80"
    environment:
      - APP_PUBLIC_BASE_URL=${APP_PUBLIC_BASE_URL}
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - shared_services
      - web_proxy
    restart: unless-stopped
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    # Cheap liveness: nginx answering on its own port. wget ships with the alpine base.
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:80/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  ai-service:
    build:
      context: ./tools/summarizer
      dockerfile: Dockerfile
    environment:
      # Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
      # (e.g. http://<host-ip>:11435). The in-compose ollama service below is
      # opt-in via the "bundled-ollama" profile, so it is NOT started by default
      # and no duplicate Ollama container is created.
      - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
      - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
      # External fallback provider for heavy /cv/* calls. Ollama remains primary by default.
      - AI_PROVIDER=${AI_PROVIDER:-ollama}
      - EXTERNAL_AI_ENABLED=${EXTERNAL_AI_ENABLED:-false}
      - AI_ROUTING_MODE=${AI_ROUTING_MODE:-local_first}
      - EXTERNAL_AI_ALLOWED_TASKS=${EXTERNAL_AI_ALLOWED_TASKS:-cv-normalize,cv-classify,cv-rewrite}
      - EXTERNAL_AI_MAX_PROMPT_CHARS=${EXTERNAL_AI_MAX_PROMPT_CHARS:-24000}
      - LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD=${LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD:-3}
      - LOCAL_AI_CIRCUIT_OPEN_SECONDS=${LOCAL_AI_CIRCUIT_OPEN_SECONDS:-30}
      - GEMINI_API_KEY=${GEMINI_API_KEY:-}
      - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
      - GROQ_API_KEY=${GROQ_API_KEY:-}
      - GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
      # Shared secret required on every endpoint except /health. Must match Ai__ServiceToken
      # on the backend. `:?` so a deploy that forgets it fails loudly instead of booting open.
      # Quoted: the `:?` message contains a colon-space, which YAML would otherwise read as a map.
      - "AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?AI_SERVICE_TOKEN must be set - generate one with python -c 'import secrets; print(secrets.token_hex(32))'}"
    # Deliberately NOT published to the host: this service has no user auth and can spend a
    # paid provider's API key (AI_PROVIDER=gemini/groq). The backend reaches it in-network at
    # http://ai-service:8001. To debug locally, use docker-compose.dev.yml rather than
    # re-adding a `ports:` here.
    expose:
      - "8001"
    # ai_internal ONLY. Not on `default` (which the frontend shares) and not on
    # `shared_services` (which is `external: true`, so any other compose stack on this host can
    # join it and would then be able to reach this service). ai_internal carries exactly two
    # members: this service and the backend. Nothing else can route to port 8001.
    #
    # The network is NOT marked `internal: true` — ai-service still needs egress to
    # generativelanguage.googleapis.com / api.groq.com when AI_PROVIDER is gemini or groq.
    networks:
      - ai_internal
    restart: unless-stopped
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5).read()"]
      interval: 30s
      timeout: 10s
      retries: 3

  # Opt-in only: start locally with
  # `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile bundled-ollama up`.
  # Left out of the default set so deploys reuse an existing/shared Ollama
  # (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
  ollama:
    profiles: ["bundled-ollama"]
    image: ollama/ollama:latest
    expose:
      - "11434"
    environment:
      - OLLAMA_HOST=0.0.0.0:11434
    volumes:
      - ollama_data:/root/.ollama
    # On ai_internal so the bundled Ollama stays reachable at http://ollama:11434 now that
    # ai-service has left the `default`/`shared_services` networks.
    #
    # NOTE: if you point OLLAMA_BASE_URL at an Ollama running in ANOTHER compose stack, address
    # it by host IP (e.g. http://<host-ip>:11435) — ai-service can no longer resolve container
    # names on `shared_services`, by design.
    networks:
      - ai_internal
    restart: unless-stopped
    logging:
      options:
        max-size: "10m"
        max-file: "3"
    gpus: all
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 20s
      timeout: 15s
      retries: 10
      start_period: 20s

volumes:
  jobtracker_data:
  jobtracker_deletion_tombstones:
  ollama_data:

networks:
  shared_services:
    external: true
    name: jobtracker_shared

  # Private backend <-> ai-service link. Deliberately NOT external: nothing outside this compose
  # project can join it, so ai-service is unreachable from the host, from the frontend, and from
  # any other stack sharing jobtracker_shared. Egress to cloud AI providers still works because
  # this is a normal bridge (not `internal: true`).
  ai_internal:
    driver: bridge

  # Only nginx and the backend join this network. The backend trusts forwarded headers solely
  # from this CIDR; set WEB_PROXY_SUBNET explicitly in production after checking for overlap.
  web_proxy:
    internal: true
    ipam:
      config:
        - subnet: ${WEB_PROXY_SUBNET:-172.31.250.0/29}
