from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

from django.conf import settings
from django.http import HttpRequest
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from wagtail.models import Site

from .capabilities import (
    CAPABILITY_STATUS_EXPERIMENTAL,
    CAPABILITY_STATUS_IMPLEMENTED,
    CAPABILITY_STATUS_PLANNED,
    serialize_capability_registry,
)
from .constants import (
    ANDROID_ICON_SIZE,
    ANDROID_LARGE_ICON_SIZE,
    APPLE_TOUCH_ICON_SIZE,
    APPLE_ICON_SIZE,
    ADAPTIVE_ICON_SIZE,
    ANDROID_PERMISSION_PLACEHOLDERS,
    ANDROID_ICON_SIZES,
    CAPABILITY_DASHBOARD_NOTES,
    CAMERA_CAPACITOR_PACKAGE,
    DEFAULT_CACHE_VERSION,
    DEFAULT_GENERATED_DIR,
    DEFAULT_SPLASH_SIZE,
    FILE_PICKER_CAPACITOR_PACKAGE,
    FAVICON_SIZE,
    GENERATED_BRANDING_ASSETS,
    IOS_PERMISSION_PLACEHOLDERS,
    MANIFEST_ICON_SPECS,
    OFFLINE_FALLBACK_ROUTE,
    PWA_ICON_SIZES,
    SERVICE_WORKER_EXCLUDED_PREFIXES,
    THIN_WRAPPER_WARNING,
)
from .models import MobileApp
from .utils import normalize_path


@dataclass
class CheckMessage:
    level: str
    code: str
    message: str


def get_generated_assets_dir(site_id: int) -> Path:
    return get_generated_output_dir(site_id) / "assets"


def get_generated_branding_manifest_path(site_id: int) -> Path:
    return get_generated_assets_dir(site_id) / "branding.json"


def build_capability_statuses(mobile_app: MobileApp) -> list[dict]:
    return serialize_capability_registry(mobile_app)


def get_enabled_capability_fields(mobile_app: MobileApp) -> list[str]:
    return [
        capability["key"]
        for capability in build_capability_statuses(mobile_app)
        if capability["enabled"]
    ]


def get_recommended_capacitor_packages(mobile_app: MobileApp) -> list[str]:
    packages: list[str] = []
    for capability in build_capability_statuses(mobile_app):
        if not capability["enabled"]:
            continue
        for package_name in capability["required_capacitor_packages"]:
            if package_name not in packages:
                packages.append(package_name)
    return packages


def build_permission_placeholders() -> dict:
    return {
        "android": [
            {
                "key": item["key"],
                "title": str(item["title"]),
                "placeholder": str(item["placeholder"]),
            }
            for item in ANDROID_PERMISSION_PLACEHOLDERS
        ],
        "ios": [
            {
                "key": item["key"],
                "title": str(item["title"]),
                "placeholder": str(item["placeholder"]),
            }
            for item in IOS_PERMISSION_PLACEHOLDERS
        ],
    }


def build_capability_metadata(mobile_app: MobileApp) -> dict:
    capability_statuses = build_capability_statuses(mobile_app)
    permission_placeholders = build_permission_placeholders()
    if mobile_app.ios_camera_usage_description:
        for item in permission_placeholders["ios"]:
            if item["key"] == "camera_usage_description":
                item["placeholder"] = mobile_app.ios_camera_usage_description
    return {
        "capabilities": capability_statuses,
        "enabled_capability_keys": [
            capability["key"]
            for capability in capability_statuses
            if capability["enabled"]
        ],
        "implemented_capability_keys": [
            capability["key"]
            for capability in capability_statuses
            if capability["implementation_status"] == CAPABILITY_STATUS_IMPLEMENTED
        ],
        "planned_capability_keys": [
            capability["key"]
            for capability in capability_statuses
            if capability["implementation_status"] == CAPABILITY_STATUS_PLANNED
        ],
        "experimental_capability_keys": [
            capability["key"]
            for capability in capability_statuses
            if capability["implementation_status"] == CAPABILITY_STATUS_EXPERIMENTAL
        ],
        "enabled_capabilities": [
            capability for capability in capability_statuses if capability["enabled"]
        ],
        "planned_capabilities": [
            capability
            for capability in capability_statuses
            if capability["implementation_status"] == CAPABILITY_STATUS_PLANNED
        ],
        "runtime_verification_required": [
            capability
            for capability in capability_statuses
            if capability["runtime_verification_required"]
        ],
        "registry": [
            {
                "key": capability["key"],
                "display_name": capability["display_name"],
                "description": capability["description"],
                "required_capacitor_packages": capability[
                    "required_capacitor_packages"
                ],
                "supported_platforms": capability["supported_platforms"],
                "implementation_status": capability["implementation_status"],
                "runtime_verification_required": capability[
                    "runtime_verification_required"
                ],
                "settings_field": capability["settings_field"],
            }
            for capability in capability_statuses
        ],
        "recommended_capacitor_packages": get_recommended_capacitor_packages(
            mobile_app
        ),
        "permission_placeholders": permission_placeholders,
        "dashboard_notes": [str(note) for note in CAPABILITY_DASHBOARD_NOTES],
        "deep_link_notes": [
            str(
                _(
                    "Future deep links should open the native app for matching website URLs when Android App Links and iOS Universal Links are configured."
                )
            ),
            str(
                _(
                    "Production rollout still requires platform-specific domain association files before deep links can be trusted."
                )
            ),
        ],
    }


def has_native_upload_features(mobile_app: MobileApp) -> bool:
    return bool(mobile_app.enable_camera_uploads or mobile_app.enable_file_picker)


def get_native_upload_dependencies(mobile_app: MobileApp) -> list[str]:
    dependencies: list[str] = []
    if mobile_app.enable_camera_uploads:
        dependencies.append(CAMERA_CAPACITOR_PACKAGE)
    if mobile_app.enable_file_picker:
        dependencies.append(FILE_PICKER_CAPACITOR_PACKAGE)
    return dependencies


def has_mobile_bridge_features(mobile_app: MobileApp) -> bool:
    return bool(
        mobile_app.enable_camera_uploads
        or mobile_app.enable_file_picker
        or mobile_app.enable_share_sheet
        or mobile_app.enable_qr_scanner
        or mobile_app.enable_secure_storage
    )


def get_mobile_bridge_dependencies(mobile_app: MobileApp) -> list[str]:
    dependencies = get_native_upload_dependencies(mobile_app)
    if mobile_app.enable_share_sheet and "@capacitor/share" not in dependencies:
        dependencies.append("@capacitor/share")
    if (
        mobile_app.enable_qr_scanner
        and "@capacitor-mlkit/barcode-scanning" not in dependencies
    ):
        dependencies.append("@capacitor-mlkit/barcode-scanning")
    if (
        mobile_app.enable_secure_storage
        and "capacitor-secure-storage-plugin" not in dependencies
    ):
        dependencies.append("capacitor-secure-storage-plugin")
    return dependencies


def build_mobile_bridge_script(mobile_app: MobileApp) -> str:
    config = {
        "cameraEnabled": bool(mobile_app.enable_camera_uploads),
        "filePickerEnabled": bool(mobile_app.enable_file_picker),
        "shareSheetEnabled": bool(mobile_app.enable_share_sheet),
        "qrScannerEnabled": bool(mobile_app.enable_qr_scanner),
        "secureStorageEnabled": bool(mobile_app.enable_secure_storage),
    }
    return f"""(function () {{
  const config = {json.dumps(config)};
  const host = typeof window !== "undefined" ? window : globalThis;
  const memorySecureStorage = new Map();

  function readAsDataUrl(file) {{
    return new Promise((resolve, reject) => {{
      if (!file) {{
        resolve(null);
        return;
      }}
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(new Error("file_read_failed"));
      reader.readAsDataURL(file);
    }});
  }}

  function pickWithInput(options) {{
    return new Promise((resolve, reject) => {{
      if (typeof document === "undefined" || !document.body) {{
        reject(new Error("browser_input_unavailable"));
        return;
      }}
      const input = document.createElement("input");
      input.type = "file";
      input.style.position = "fixed";
      input.style.left = "-9999px";
      if (options.accept) input.accept = options.accept;
      if (options.capture) input.setAttribute("capture", options.capture);
      document.body.appendChild(input);

      let settled = false;

      function cleanup() {{
        window.removeEventListener("focus", handleWindowFocus);
        input.remove();
      }}

      function finishWithError(code) {{
        if (settled) {{
          return;
        }}
        settled = true;
        cleanup();
        reject(new Error(code));
      }}

      function handleWindowFocus() {{
        window.setTimeout(() => {{
          if (settled) {{
            return;
          }}
          const hasSelection = input.files && input.files.length > 0;
          if (!hasSelection) {{
            finishWithError("selection_cancelled");
          }}
        }}, 0);
      }}

      window.addEventListener("focus", handleWindowFocus, {{ once: true }});

      input.addEventListener("change", async () => {{
        try {{
          const file = input.files && input.files[0];
          if (!file) {{
            finishWithError("selection_cancelled");
            return;
          }}
          settled = true;
          cleanup();
          resolve({{
            source: "browser",
            name: file.name,
            type: file.type || "",
            size: file.size,
            file: file,
            dataUrl: await readAsDataUrl(file),
          }});
        }} catch (error) {{
          settled = true;
          cleanup();
          reject(error);
        }}
      }}, {{ once: true }});

      input.click();
    }});
  }}

  async function pickImage() {{
    if (!config.cameraEnabled) {{
      throw new Error("camera_uploads_disabled");
    }}

    const cameraPlugin = host.Capacitor?.Plugins?.Camera;
    if (cameraPlugin?.getPhoto) {{
      const photo = await cameraPlugin.getPhoto({{
        resultType: "dataUrl",
        source: "PROMPT",
      }});
      return {{
        source: "capacitor",
        type: photo.format ? `image/${{photo.format}}` : "image/*",
        webPath: photo.webPath || "",
        dataUrl: photo.dataUrl || null,
      }};
    }}

    return pickWithInput({{ accept: "image/*", capture: "environment" }});
  }}

  async function pickFile() {{
    if (!config.filePickerEnabled) {{
      throw new Error("file_picker_disabled");
    }}

    const filePickerPlugin = host.Capacitor?.Plugins?.FilePicker;
    if (filePickerPlugin?.pickFiles) {{
      const result = await filePickerPlugin.pickFiles({{ multiple: false }});
      const file = result.files && result.files[0];
      if (!file) {{
        throw new Error("selection_cancelled");
      }}
      return {{
        source: "capacitor",
        name: file.name || "",
        path: file.path || "",
        mimeType: file.mimeType || "",
        size: file.size || 0,
      }};
    }}

    return pickWithInput({{ accept: "*/*" }});
  }}

  function normalizeSharePayload(input) {{
    const payload = input && typeof input === "object" ? input : {{}};
    const title =
      payload.title ||
      (typeof document !== "undefined" ? document.title : "") ||
      "";
    const text = payload.text || "";
    const url =
      payload.url ||
      (typeof window !== "undefined" ? window.location.href : "") ||
      "";
    return {{ title, text, url }};
  }}

  async function share(payload) {{
    if (!config.shareSheetEnabled) {{
      throw new Error("share_sheet_disabled");
    }}

    const normalized = normalizeSharePayload(payload);
    const sharePlugin = host.Capacitor?.Plugins?.Share;
    if (sharePlugin?.share) {{
      await sharePlugin.share(normalized);
      return {{
        source: "capacitor",
        shared: true,
        payload: normalized,
      }};
    }}

    if (typeof navigator !== "undefined" && typeof navigator.share === "function") {{
      await navigator.share(normalized);
      return {{
        source: "browser",
        shared: true,
        payload: normalized,
      }};
    }}

    throw new Error("share_not_supported");
  }}

  async function shareCurrentPage(payload) {{
    const normalized = normalizeSharePayload(payload);
    return share(normalized);
  }}

  async function scanQrCode() {{
    if (!config.qrScannerEnabled) {{
      throw new Error("qr_scanner_disabled");
    }}

    const barcodePlugin =
      host.Capacitor?.Plugins?.BarcodeScanner ||
      host.Capacitor?.Plugins?.CapacitorBarcodeScanner;
    if (barcodePlugin?.scan) {{
      const result = await barcodePlugin.scan();
      const value =
        result?.displayValue ||
        result?.rawValue ||
        result?.content ||
        result?.code ||
        "";
      const format = result?.format || result?.barcodeFormat || "qr";
      if (!value) {{
        throw new Error("scan_cancelled");
      }}
      return {{
        source: "capacitor",
        value,
        format,
        cancelled: false,
      }};
    }}

    throw new Error("qr_scanner_not_supported");
  }}

  function getSecureStorageBackend() {{
    const secureStoragePlugin = host.Capacitor?.Plugins?.SecureStoragePlugin;
    if (secureStoragePlugin?.get && secureStoragePlugin?.set && secureStoragePlugin?.remove && secureStoragePlugin?.clear) {{
      return "native_secure_storage";
    }}
    if (typeof window !== "undefined") {{
      return "memory_ephemeral";
    }}
    return "unsupported";
  }}

  async function secureStorageSet(key, value) {{
    if (!config.secureStorageEnabled) {{
      throw new Error("secure_storage_disabled");
    }}
    if (!key) {{
      throw new Error("secure_storage_key_required");
    }}
    const backend = getSecureStorageBackend();
    const serialized = typeof value === "string" ? value : JSON.stringify(value);
    if (backend === "native_secure_storage") {{
      await host.Capacitor.Plugins.SecureStoragePlugin.set({{ key, value: serialized }});
      return {{ backend, stored: true }};
    }}
    if (backend === "memory_ephemeral") {{
      memorySecureStorage.set(key, serialized);
      return {{ backend, stored: true, warning: "browser_memory_only" }};
    }}
    throw new Error("secure_storage_not_supported");
  }}

  async function secureStorageGet(key) {{
    if (!config.secureStorageEnabled) {{
      throw new Error("secure_storage_disabled");
    }}
    if (!key) {{
      throw new Error("secure_storage_key_required");
    }}
    const backend = getSecureStorageBackend();
    if (backend === "native_secure_storage") {{
      const result = await host.Capacitor.Plugins.SecureStoragePlugin.get({{ key }});
      return {{ backend, value: result?.value ?? "" }};
    }}
    if (backend === "memory_ephemeral") {{
      if (!memorySecureStorage.has(key)) {{
        throw new Error("secure_storage_missing_key");
      }}
      return {{ backend, value: memorySecureStorage.get(key) }};
    }}
    throw new Error("secure_storage_not_supported");
  }}

  async function secureStorageRemove(key) {{
    if (!config.secureStorageEnabled) {{
      throw new Error("secure_storage_disabled");
    }}
    if (!key) {{
      throw new Error("secure_storage_key_required");
    }}
    const backend = getSecureStorageBackend();
    if (backend === "native_secure_storage") {{
      await host.Capacitor.Plugins.SecureStoragePlugin.remove({{ key }});
      return {{ backend, removed: true }};
    }}
    if (backend === "memory_ephemeral") {{
      memorySecureStorage.delete(key);
      return {{ backend, removed: true, warning: "browser_memory_only" }};
    }}
    throw new Error("secure_storage_not_supported");
  }}

  async function secureStorageClear() {{
    if (!config.secureStorageEnabled) {{
      throw new Error("secure_storage_disabled");
    }}
    const backend = getSecureStorageBackend();
    if (backend === "native_secure_storage") {{
      await host.Capacitor.Plugins.SecureStoragePlugin.clear();
      return {{ backend, cleared: true }};
    }}
    if (backend === "memory_ephemeral") {{
      memorySecureStorage.clear();
      return {{ backend, cleared: true, warning: "browser_memory_only" }};
    }}
    throw new Error("secure_storage_not_supported");
  }}

  host.MandelMobileApp = Object.assign(host.MandelMobileApp || {{}}, {{
    config,
    pickImage,
    pickFile,
    share,
    shareCurrentPage,
    scanQrCode,
    secureStorage: {{
      set: secureStorageSet,
      get: secureStorageGet,
      remove: secureStorageRemove,
      clear: secureStorageClear,
    }},
  }});
  if (typeof window !== "undefined") {{
    window.MandelMobileApp = host.MandelMobileApp;
  }}
}})();\n"""


def build_generated_shell_css(mobile_app: MobileApp) -> str:
    return f""":root {{
  color-scheme: light;
  --mobileapp-theme: {mobile_app.theme_color};
  --mobileapp-background: {mobile_app.background_color};
  --mobileapp-foreground: {mobile_app.foreground_color};
  --mobileapp-accent: {mobile_app.accent_color};
  --mobileapp-status-bar: {mobile_app.status_bar_color};
  --mobileapp-nav-bar: {mobile_app.navigation_bar_color};
  --mobileapp-border: color-mix(in srgb, var(--mobileapp-foreground) 12%, transparent);
  --mobileapp-shadow: 0 18px 48px rgba(0, 0, 0, 0.14);
}}

* {{
  box-sizing: border-box;
}}

html, body {{
  margin: 0;
  min-height: 100%;
  background: var(--mobileapp-background);
  color: var(--mobileapp-foreground);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}}

body {{
  min-height: 100vh;
  padding:
    max(20px, env(safe-area-inset-top))
    max(18px, env(safe-area-inset-right))
    max(24px, env(safe-area-inset-bottom))
    max(18px, env(safe-area-inset-left));
}}

.mobileapp-shell {{
  min-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  display: grid;
  gap: 20px;
  align-content: center;
  justify-items: center;
}}

.mobileapp-shell__card {{
  width: min(100%, 480px);
  padding: 28px 24px;
  border-radius: 24px;
  background: color-mix(in srgb, var(--mobileapp-background) 92%, white 8%);
  border: 1px solid var(--mobileapp-border);
  box-shadow: var(--mobileapp-shadow);
}}

.mobileapp-shell__brand {{
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 20px;
}}

.mobileapp-shell__icon {{
  width: 64px;
  height: 64px;
  border-radius: 18px;
  object-fit: cover;
  background: color-mix(in srgb, var(--mobileapp-theme) 10%, white 90%);
}}

.mobileapp-shell__eyebrow {{
  margin: 0 0 4px;
  font-size: 0.85rem;
  color: color-mix(in srgb, var(--mobileapp-foreground) 70%, transparent);
}}

.mobileapp-shell__title {{
  margin: 0;
  font-size: 1.5rem;
  line-height: 1.2;
}}

.mobileapp-shell__status {{
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--mobileapp-theme) 10%, white 90%);
  color: color-mix(in srgb, var(--mobileapp-foreground) 80%, transparent);
  font-size: 0.92rem;
}}

.mobileapp-shell__status-dot {{
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #16a34a;
  box-shadow: 0 0 0 6px color-mix(in srgb, #16a34a 14%, transparent);
}}

.mobileapp-shell.is-offline .mobileapp-shell__status-dot {{
  background: #dc2626;
  box-shadow: 0 0 0 6px color-mix(in srgb, #dc2626 14%, transparent);
}}

.mobileapp-shell__message,
.mobileapp-shell__meta {{
  margin: 16px 0 0;
  line-height: 1.55;
  color: color-mix(in srgb, var(--mobileapp-foreground) 82%, transparent);
}}

.mobileapp-shell__actions {{
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 22px;
}}

.mobileapp-shell__button {{
  appearance: none;
  border: 0;
  border-radius: 999px;
  padding: 12px 16px;
  font: inherit;
  cursor: pointer;
  text-decoration: none;
}}

.mobileapp-shell__button--primary {{
  background: var(--mobileapp-theme);
  color: #fff;
}}

.mobileapp-shell__button--secondary {{
  background: color-mix(in srgb, var(--mobileapp-theme) 9%, white 91%);
  color: var(--mobileapp-foreground);
}}

.mobileapp-shell__loading {{
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin-top: 18px;
  color: color-mix(in srgb, var(--mobileapp-foreground) 72%, transparent);
}}

.mobileapp-shell__spinner {{
  width: 18px;
  height: 18px;
  border-radius: 999px;
  border: 2px solid color-mix(in srgb, var(--mobileapp-theme) 22%, transparent);
  border-top-color: var(--mobileapp-theme);
  animation: mobileapp-spin 0.9s linear infinite;
}}

.mobileapp-shell.is-ready .mobileapp-shell__loading {{
  opacity: 0;
  transition: opacity 180ms ease;
}}

@keyframes mobileapp-spin {{
  to {{
    transform: rotate(360deg);
  }}
}}
"""


def build_generated_shell_js(site: Site, mobile_app: MobileApp) -> str:
    source_url = get_base_url(site)
    cache_version = f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}"
    config = {
        "appName": mobile_app.app_name,
        "sourceUrl": source_url,
        "offlineUrl": build_absolute_url(site, get_offline_url(site, mobile_app)),
        "appVersion": mobile_app.app_version,
        "buildNumber": mobile_app.build_number,
        "siteHostname": site.hostname,
        "manifestPath": "./manifest.webmanifest",
        "serviceWorkerPath": "./service-worker.js",
        "cacheVersion": cache_version,
    }
    return f"""(function () {{
  const config = {json.dumps(config)};
  const sourceOrigin = new URL(config.sourceUrl).origin;

  function onReady(callback) {{
    if (document.readyState === "loading") {{
      document.addEventListener("DOMContentLoaded", callback, {{ once: true }});
      return;
    }}
    callback();
  }}

  onReady(function () {{
    const shell = document.querySelector("[data-mobileapp-shell]");
    if (!shell) {{
      return;
    }}
    const status = shell.querySelector("[data-mobileapp-status]");
    const message = shell.querySelector("[data-mobileapp-message]");
    const meta = shell.querySelector("[data-mobileapp-meta]");
    const reloadButton = shell.querySelector("[data-mobileapp-reload]");
    const diagnosticsButton = shell.querySelector("[data-mobileapp-diagnostics]");

    function isExternalUrl(value) {{
      try {{
        const target = new URL(value, sourceOrigin);
        return target.origin !== sourceOrigin;
      }} catch (error) {{
        return false;
      }}
    }}

    function describeDownload(url) {{
      return {{
        url,
        external: isExternalUrl(url),
        handledBy: "webview_or_browser",
        note: "Native download manager integration is not implemented yet. Test real file downloads on target platforms before release.",
      }};
    }}

    function openExternal(url) {{
      if (!url) {{
        throw new Error("external_url_required");
      }}
      const normalized = new URL(url, sourceOrigin);
      if (!["http:", "https:", "mailto:", "tel:"].includes(normalized.protocol)) {{
        throw new Error("unsupported_external_scheme");
      }}
      if (typeof window !== "undefined" && window.open) {{
        window.open(normalized.toString(), "_blank", "noopener,noreferrer");
        return normalized.toString();
      }}
      return normalized.toString();
    }}

    function buildDiagnostics() {{
      return {{
        appName: config.appName,
        appVersion: config.appVersion,
        buildNumber: config.buildNumber,
        sourceUrl: config.sourceUrl,
        sourceOrigin,
        online: typeof navigator === "undefined" ? true : navigator.onLine !== false,
        userAgent: typeof navigator === "undefined" ? "" : navigator.userAgent,
        viewport: {{
          width: typeof window === "undefined" ? 0 : window.innerWidth,
          height: typeof window === "undefined" ? 0 : window.innerHeight,
        }},
        manifestPath: config.manifestPath,
        serviceWorkerPath: config.serviceWorkerPath,
        cacheVersion: config.cacheVersion,
        serviceWorkerSupported: typeof navigator !== "undefined" && "serviceWorker" in navigator,
        standaloneMode:
          (typeof window !== "undefined" &&
            window.matchMedia &&
            window.matchMedia("(display-mode: standalone)").matches) ||
          (typeof navigator !== "undefined" && !!navigator.standalone),
        serviceWorkerController:
          typeof navigator !== "undefined" && !!navigator.serviceWorker?.controller,
        capacitorAvailable: !!(window.Capacitor && window.Capacitor.Plugins),
        capabilities: {{
          camera: !!window.MandelMobileApp?.config?.cameraEnabled,
          filePicker: !!window.MandelMobileApp?.config?.filePickerEnabled,
          shareSheet: !!window.MandelMobileApp?.config?.shareSheetEnabled,
          qrScanner: !!window.MandelMobileApp?.config?.qrScannerEnabled,
          secureStorage: !!window.MandelMobileApp?.config?.secureStorageEnabled,
        }},
        shareSupported:
          !!window.Capacitor?.Plugins?.Share?.share ||
          (typeof navigator !== "undefined" && typeof navigator.share === "function"),
        qrScannerSupported:
          !!window.Capacitor?.Plugins?.BarcodeScanner?.scan ||
          !!window.Capacitor?.Plugins?.CapacitorBarcodeScanner?.scan,
        secureStorageSupported:
          !!window.Capacitor?.Plugins?.SecureStoragePlugin?.get &&
          !!window.Capacitor?.Plugins?.SecureStoragePlugin?.set,
        secureStorageBackend: window.MandelMobileApp?.secureStorage
          ? window.MandelMobileApp?.config?.secureStorageEnabled
            ? (window.Capacitor?.Plugins?.SecureStoragePlugin?.get &&
              window.Capacitor?.Plugins?.SecureStoragePlugin?.set
                ? "native_secure_storage"
                : "memory_ephemeral")
            : "disabled"
          : "unavailable",
        secureStorageOperations: window.MandelMobileApp?.secureStorage
          ? ["set", "get", "remove", "clear"]
          : [],
      }};
    }}

    function registerServiceWorker() {{
      if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {{
        return;
      }}
      window.addEventListener(
        "load",
        function () {{
          navigator.serviceWorker
            .register(config.serviceWorkerPath, {{ scope: "./" }})
            .catch(function (error) {{
              console.warn("MandelMobileApp service worker registration failed", error);
            }});
        }},
        {{ once: true }}
      );
    }}

    function updateNetworkState() {{
      const offline = typeof navigator !== "undefined" && navigator.onLine === false;
      shell.classList.toggle("is-offline", offline);
      shell.classList.toggle("is-ready", !offline);
      if (status) {{
        status.textContent = offline ? "Offline mode" : "Ready to connect";
      }}
      if (message) {{
        message.textContent = offline
          ? "This local shell is available, but live website content still needs a network connection."
          : "The generated app shell is ready. Live website content loads from the configured server URL at runtime.";
      }}
      if (meta) {{
        meta.textContent = offline
          ? "Offline fallback route: " + config.offlineUrl
          : "Source website: " + config.sourceUrl;
      }}
    }}

    window.addEventListener("online", updateNetworkState);
    window.addEventListener("offline", updateNetworkState);
    updateNetworkState();

    window.setTimeout(function () {{
      shell.classList.add("is-ready");
    }}, 240);

    if (reloadButton) {{
      reloadButton.addEventListener("click", function () {{
        window.location.reload();
      }});
    }}

    if (diagnosticsButton) {{
      diagnosticsButton.addEventListener("click", function () {{
        const payload = buildDiagnostics();
        console.info("MandelMobileApp diagnostics", payload);
      }});
    }}

    window.MandelMobileAppDiagnostics = {{
      getSnapshot: buildDiagnostics,
      describeDownload,
      openExternal,
      isExternalUrl,
    }};

    registerServiceWorker();
  }});
}})();\n"""


def build_generated_shell_html(site: Site, mobile_app: MobileApp) -> str:
    icon_markup = (
        '<img class="mobileapp-shell__icon" src="./assets/app-icon.png" alt="">'
        if mobile_app.icon
        else ""
    )
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="{mobile_app.theme_color}">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <link rel="manifest" href="./manifest.webmanifest">
  <link rel="apple-touch-icon" sizes="180x180" href="./assets/favicon-180.png">
  <link rel="icon" type="image/png" sizes="192x192" href="./assets/icon-192.png">
  <title>{mobile_app.app_name}</title>
  <link rel="stylesheet" href="./assets/app-shell.css">
</head>
<body>
  <main class="mobileapp-shell" data-mobileapp-shell>
    <section class="mobileapp-shell__card" aria-live="polite">
      <div class="mobileapp-shell__brand">
        {icon_markup}
        <div>
          <p class="mobileapp-shell__eyebrow">Generated mobile app shell</p>
          <h1 class="mobileapp-shell__title">{mobile_app.app_name}</h1>
        </div>
      </div>
      <div class="mobileapp-shell__status">
        <span class="mobileapp-shell__status-dot" aria-hidden="true"></span>
        <span data-mobileapp-status>Preparing app shell</span>
      </div>
      <p class="mobileapp-shell__message" data-mobileapp-message>
        Preparing the local app shell for first paint. Live website content loads from the configured server URL at runtime.
      </p>
      <p class="mobileapp-shell__meta" data-mobileapp-meta>
        Source website: {get_base_url(site)}
      </p>
      <div class="mobileapp-shell__actions">
        <a class="mobileapp-shell__button mobileapp-shell__button--primary" href="{get_base_url(site)}">Open website</a>
        <button class="mobileapp-shell__button mobileapp-shell__button--secondary" type="button" data-mobileapp-reload>Reload shell</button>
        <button class="mobileapp-shell__button mobileapp-shell__button--secondary" type="button" data-mobileapp-diagnostics>Runtime diagnostics</button>
      </div>
      <div class="mobileapp-shell__loading" aria-hidden="true">
        <span class="mobileapp-shell__spinner"></span>
        <span>Loading app shell</span>
      </div>
    </section>
  </main>
  <script src="./assets/app-shell.js"></script>
</body>
</html>
"""


def build_generated_pwa_manifest(site: Site, mobile_app: MobileApp) -> dict:
    icon_entries = []
    if mobile_app.icon:
        icon_entries = [
            {
                "src": "./assets/favicon-32.png",
                "sizes": "32x32",
                "type": "image/png",
                "purpose": "any",
            },
            {
                "src": "./assets/favicon-180.png",
                "sizes": "180x180",
                "type": "image/png",
                "purpose": "any",
            },
            {
                "src": "./assets/icon-192.png",
                "sizes": "192x192",
                "type": "image/png",
                "purpose": "any",
            },
            {
                "src": "./assets/icon-192.png",
                "sizes": "192x192",
                "type": "image/png",
                "purpose": "maskable",
            },
            {
                "src": "./assets/icon-512.png",
                "sizes": "512x512",
                "type": "image/png",
                "purpose": "any",
            },
            {
                "src": "./assets/icon-512.png",
                "sizes": "512x512",
                "type": "image/png",
                "purpose": "maskable",
            },
        ]
    return {
        "id": "./",
        "name": mobile_app.app_name,
        "short_name": mobile_app.short_name,
        "description": mobile_app.description,
        "start_url": "./index.html",
        "scope": "./",
        "display": mobile_app.display_mode,
        "orientation": mobile_app.orientation,
        "theme_color": mobile_app.theme_color,
        "background_color": mobile_app.background_color,
        "lang": settings.LANGUAGE_CODE,
        "categories": ["business", "productivity"],
        "prefer_related_applications": False,
        "icons": icon_entries,
    }


def build_generated_offline_html(site: Site, mobile_app: MobileApp) -> str:
    icon_markup = (
        '<img class="mobileapp-shell__icon" src="./assets/app-icon.png" alt="">'
        if mobile_app.icon
        else ""
    )
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="{mobile_app.theme_color}">
  <title>{mobile_app.app_name} offline</title>
  <link rel="stylesheet" href="./assets/app-shell.css">
</head>
<body>
  <main class="mobileapp-shell is-offline" data-mobileapp-shell>
    <section class="mobileapp-shell__card" aria-live="polite">
      <div class="mobileapp-shell__brand">
        {icon_markup}
        <div>
          <p class="mobileapp-shell__eyebrow">Offline fallback</p>
          <h1 class="mobileapp-shell__title">{mobile_app.app_name}</h1>
        </div>
      </div>
      <div class="mobileapp-shell__status">
        <span class="mobileapp-shell__status-dot" aria-hidden="true"></span>
        <span data-mobileapp-status>Offline mode</span>
      </div>
      <p class="mobileapp-shell__message" data-mobileapp-message>
        The generated app shell is available offline. Live website content will load again when a network connection returns.
      </p>
      <p class="mobileapp-shell__meta" data-mobileapp-meta>
        Source website: {get_base_url(site)}
      </p>
      <div class="mobileapp-shell__actions">
        <button class="mobileapp-shell__button mobileapp-shell__button--secondary" type="button" data-mobileapp-reload>Retry connection</button>
      </div>
    </section>
  </main>
  <script src="./assets/app-shell.js"></script>
</body>
</html>
"""


def build_generated_service_worker(site: Site, mobile_app: MobileApp) -> str:
    cache_name = f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}"
    shell_assets = [
        "./",
        "./index.html",
        "./offline.html",
        "./manifest.webmanifest",
        "./assets/app-shell.css",
        "./assets/app-shell.js",
        "./assets/app-icon.png",
        "./assets/favicon-180.png",
        "./assets/favicon-32.png",
        "./assets/icon-192.png",
        "./assets/icon-512.png",
        "./assets/branding.json",
    ]
    if has_mobile_bridge_features(mobile_app):
        shell_assets.append("./assets/mobile-bridge.js")
    return f"""const CACHE_NAME = {json.dumps(cache_name)};
const OFFLINE_URL = "./offline.html";
const PRECACHE_URLS = {json.dumps(shell_assets, indent=2)};
const EXCLUDED_PREFIXES = ["/admin/", "/django-admin/", "/api/", "/documents/"];

self.addEventListener("install", (event) => {{
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
  self.skipWaiting();
}});

self.addEventListener("activate", (event) => {{
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.map((key) => {{
          if (key !== CACHE_NAME) {{
            return caches.delete(key);
          }}
          return Promise.resolve();
        }})
      )
    )
  );
  self.clients.claim();
}});

function isExcluded(url) {{
  return EXCLUDED_PREFIXES.some((prefix) => url.pathname.startsWith(prefix));
}}

self.addEventListener("fetch", (event) => {{
  const request = event.request;
  if (request.method !== "GET") {{
    return;
  }}
  const url = new URL(request.url);
  if (url.origin !== self.location.origin || isExcluded(url)) {{
    return;
  }}

  if (request.mode === "navigate") {{
    event.respondWith(
      fetch(request).catch(async () => {{
        return caches.match("./offline.html") || caches.match(OFFLINE_URL);
      }})
    );
    return;
  }}

  const cacheableDestinations = ["script", "style", "image", "manifest", "font"];
  if (!cacheableDestinations.includes(request.destination)) {{
    return;
  }}

  event.respondWith(
    caches.match(request).then((cached) => {{
      if (cached) {{
        return cached;
      }}
      return fetch(request).then((response) => {{
        if (response && response.status === 200) {{
          caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
        }}
        return response;
      }});
    }})
  );
}});\n"""


def get_site_for_request(request: HttpRequest) -> Site:
    site = Site.find_for_request(request)
    if site:
        return site
    return Site.objects.get(is_default_site=True)


def get_settings_for_site(site: Site) -> MobileApp:
    return MobileApp.for_site(site)


def get_base_url(site: Site) -> str:
    root_url = site.root_url.rstrip("/")
    if root_url:
        return root_url
    scheme = "https" if site.port == 443 else "http"
    host = site.hostname
    if site.port not in [80, 443]:
        host = f"{host}:{site.port}"
    return f"{scheme}://{host}"


def build_absolute_url(site: Site, value: str) -> str:
    base_url = f"{get_base_url(site)}/"
    normalized = normalize_path(value)
    return urljoin(base_url, normalized.lstrip("/"))


def build_rendition_url(image, filter_spec: str) -> str:
    rendition = image.get_rendition(filter_spec)
    return rendition.url


def open_wagtail_image(image) -> Image.Image:
    image.file.open("rb")
    try:
        with Image.open(image.file) as pil_image:
            return pil_image.convert("RGBA")
    finally:
        image.file.close()


def build_solid_color_image(color: str, size: int) -> Image.Image:
    return Image.new("RGBA", (size, size), color)


def fit_image_to_square(
    source: Image.Image, size: int, *, transparent: bool
) -> Image.Image:
    canvas_color = (0, 0, 0, 0) if transparent else None
    if transparent:
        canvas = Image.new("RGBA", (size, size), canvas_color)
        fitted = ImageOps.contain(source, (size, size))
        offset = ((size - fitted.width) // 2, (size - fitted.height) // 2)
        canvas.paste(fitted, offset, fitted)
        return canvas
    return ImageOps.fit(source, (size, size), method=Image.Resampling.LANCZOS)


def paste_centered(
    base: Image.Image, overlay: Image.Image, scale: float = 0.6
) -> Image.Image:
    result = base.copy()
    target_size = int(min(base.size) * scale)
    resized = ImageOps.contain(overlay, (target_size, target_size))
    offset = ((base.width - resized.width) // 2, (base.height - resized.height) // 2)
    result.paste(resized, offset, resized)
    return result


def save_png(image: Image.Image, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    image.save(path, format="PNG")


def build_branding_asset_statuses(site_id: int) -> list[dict]:
    assets_dir = get_generated_assets_dir(site_id)
    return [
        {
            "filename": filename,
            "path": str(assets_dir / filename),
            "exists": (assets_dir / filename).exists(),
        }
        for filename in GENERATED_BRANDING_ASSETS
    ]


def get_existing_generated_assets(site_id: int) -> list[str]:
    return [
        entry["filename"]
        for entry in build_branding_asset_statuses(site_id)
        if entry["exists"]
    ]


def generate_branding_assets(
    site: Site, mobile_app: MobileApp, assets_dir: Path
) -> dict:
    assets_dir.mkdir(parents=True, exist_ok=True)
    generated_files: list[str] = []
    branding_manifest = {
        "appName": mobile_app.app_name,
        "shortName": mobile_app.short_name,
        "themeColor": mobile_app.theme_color,
        "backgroundColor": mobile_app.background_color,
        "foregroundColor": mobile_app.foreground_color,
        "accentColor": mobile_app.accent_color,
        "statusBarColor": mobile_app.status_bar_color,
        "navigationBarColor": mobile_app.navigation_bar_color,
        "icons": [],
        "adaptiveIcons": {
            "foreground": "adaptive-icon-foreground.png",
            "background": "adaptive-icon-background.png",
            "composited": "adaptive-icon.png",
        },
        "splash": {
            "filename": "splash.png",
            "recommendedDimensions": f"{DEFAULT_SPLASH_SIZE}x{DEFAULT_SPLASH_SIZE}",
            "usesUploadedSplashImage": bool(mobile_app.splash_image),
        },
        "generatedAt": datetime.now(timezone.utc).isoformat(),
        "generator": "ocyan.plugin.mobileapp",
    }
    if not mobile_app.icon:
        (assets_dir / "branding.json").write_text(
            json.dumps(branding_manifest, indent=2) + "\n",
            encoding="utf-8",
        )
        generated_files.append("branding.json")
        return {
            "generated_files": generated_files,
            "adaptive_icon_generated": False,
            "required_icon_sizes": [],
            "branding_manifest": branding_manifest,
        }

    icon_image = open_wagtail_image(mobile_app.icon)
    splash_source = (
        open_wagtail_image(mobile_app.splash_image)
        if mobile_app.splash_image
        else icon_image.copy()
    )

    # Core exported icon files
    icon_exports = {
        "app-icon.png": ADAPTIVE_ICON_SIZE,
        "favicon-32.png": FAVICON_SIZE,
        "favicon-180.png": APPLE_ICON_SIZE,
        "icon-192.png": 192,
        "icon-512.png": 512,
    }
    for filename, size in icon_exports.items():
        save_png(
            fit_image_to_square(icon_image, size, transparent=True),
            assets_dir / filename,
        )
        generated_files.append(filename)

    for size in ANDROID_ICON_SIZES:
        filename = f"icon-{size}.png"
        save_png(
            fit_image_to_square(icon_image, size, transparent=True),
            assets_dir / filename,
        )
        if filename not in generated_files:
            generated_files.append(filename)

    adaptive_background = build_solid_color_image(
        mobile_app.background_color, ADAPTIVE_ICON_SIZE
    )
    adaptive_foreground = fit_image_to_square(
        icon_image, ADAPTIVE_ICON_SIZE, transparent=True
    )
    adaptive_icon = paste_centered(adaptive_background, adaptive_foreground, scale=0.72)
    save_png(adaptive_background, assets_dir / "adaptive-icon-background.png")
    save_png(adaptive_foreground, assets_dir / "adaptive-icon-foreground.png")
    save_png(adaptive_icon, assets_dir / "adaptive-icon.png")
    generated_files.extend(
        [
            "adaptive-icon-background.png",
            "adaptive-icon-foreground.png",
            "adaptive-icon.png",
        ]
    )

    splash_background = build_solid_color_image(
        mobile_app.background_color, DEFAULT_SPLASH_SIZE
    )
    splash_foreground = fit_image_to_square(
        splash_source, DEFAULT_SPLASH_SIZE, transparent=True
    )
    splash = paste_centered(splash_background, splash_foreground, scale=0.5)
    save_png(splash, assets_dir / "splash.png")
    generated_files.append("splash.png")

    branding_manifest["icons"] = [
        {"filename": f"icon-{size}.png", "size": f"{size}x{size}"}
        for size in ANDROID_ICON_SIZES
    ] + [
        {"filename": "favicon-32.png", "size": "32x32"},
        {"filename": "favicon-180.png", "size": "180x180"},
        {"filename": "adaptive-icon.png", "size": "512x512"},
    ]
    (assets_dir / "branding.json").write_text(
        json.dumps(branding_manifest, indent=2) + "\n",
        encoding="utf-8",
    )
    generated_files.append("branding.json")

    return {
        "generated_files": generated_files,
        "adaptive_icon_generated": True,
        "required_icon_sizes": ANDROID_ICON_SIZES
        + PWA_ICON_SIZES
        + [APPLE_ICON_SIZE, FAVICON_SIZE],
        "branding_manifest": branding_manifest,
    }


def build_manifest_payload(
    site: Site, mobile_app: MobileApp, language_code: str | None
):
    icons = []
    if mobile_app.icon:
        seen = set()
        for spec in MANIFEST_ICON_SPECS:
            key = (spec["size"], spec["purpose"])
            if key in seen:
                continue
            seen.add(key)
            icons.append(
                {
                    "src": build_rendition_url(
                        mobile_app.icon,
                        f"fill-{spec['size']}|format-png",
                    ),
                    "sizes": spec["size"],
                    "type": "image/png",
                    "purpose": spec["purpose"],
                }
            )

    manifest = {
        "name": mobile_app.app_name,
        "short_name": mobile_app.short_name,
        "description": mobile_app.description,
        "theme_color": mobile_app.theme_color,
        "background_color": mobile_app.background_color,
        "display": mobile_app.display_mode,
        "orientation": mobile_app.orientation,
        "start_url": normalize_path(mobile_app.start_url),
        "scope": normalize_path(mobile_app.scope),
        "id": normalize_path(mobile_app.start_url),
        "lang": language_code or settings.LANGUAGE_CODE,
        "prefer_related_applications": False,
        "icons": icons,
    }

    screenshots = []
    if mobile_app.tablet_screenshot:
        screenshots.append(
            {
                "src": mobile_app.tablet_screenshot.file.url,
                "sizes": mobile_app.tablet_screenshot_size,
                "form_factor": "wide",
                "label": mobile_app.description or mobile_app.app_name,
            }
        )
    if mobile_app.mobile_screenshot:
        screenshots.append(
            {
                "src": mobile_app.mobile_screenshot.file.url,
                "sizes": mobile_app.mobile_screenshot_size,
                "form_factor": "narrow",
                "label": mobile_app.description or mobile_app.app_name,
            }
        )
    if screenshots:
        manifest["screenshots"] = screenshots
    return manifest


def get_offline_url(site: Site, mobile_app: MobileApp) -> str:
    if mobile_app.offline_url:
        return build_absolute_url(site, mobile_app.offline_url)
    return build_absolute_url(site, reverse("mobileapp_offline"))


def build_head_context(request: HttpRequest):
    site = get_site_for_request(request)
    mobile_app = get_settings_for_site(site)
    return {
        "enabled": mobile_app.enable_pwa,
        "site": site,
        "settings_obj": mobile_app,
        "manifest_url": reverse("manifest_json"),
        "service_worker_url": reverse("mobileapp_serviceworker"),
        "theme_color": mobile_app.theme_color,
        "apple_touch_icon_url": (
            build_rendition_url(
                mobile_app.icon, f"fill-{APPLE_TOUCH_ICON_SIZE}|format-png"
            )
            if mobile_app.icon
            else ""
        ),
        "android_icon_url": (
            build_rendition_url(mobile_app.icon, f"fill-{ANDROID_ICON_SIZE}|format-png")
            if mobile_app.icon
            else ""
        ),
    }


def build_bridge_context(request: HttpRequest) -> dict:
    site = get_site_for_request(request)
    mobile_app = get_settings_for_site(site)
    return {
        "enabled": has_mobile_bridge_features(mobile_app),
        "site": site,
        "settings_obj": mobile_app,
        "bridge_js": build_mobile_bridge_script(mobile_app),
        "bridge_dependencies": get_mobile_bridge_dependencies(mobile_app),
    }


def get_generated_output_dir(site_id: int) -> Path:
    base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
    return base_dir / DEFAULT_GENERATED_DIR / f"site_{site_id}"


def generate_capacitor_project(
    site: Site, mobile_app: MobileApp, *, include_platform_dirs: bool = True
) -> Path:
    output_dir = get_generated_output_dir(site.pk)
    assets_dir = get_generated_assets_dir(site.pk)
    assets_dir.mkdir(parents=True, exist_ok=True)

    capability_metadata = build_capability_metadata(mobile_app)
    native_upload_dependencies = get_native_upload_dependencies(mobile_app)
    bridge_dependencies = get_mobile_bridge_dependencies(mobile_app)
    branding_assets = generate_branding_assets(site, mobile_app, assets_dir)
    package_json = {
        "name": f"mobile-app-site-{site.pk}",
        "private": True,
        "scripts": {
            "sync": "npx cap sync",
            "doctor:android": "npx cap doctor android",
            "android": "npx cap open android",
            "ios": "npx cap open ios",
        },
        "dependencies": {
            "@capacitor/android": "^7.0.0",
            "@capacitor/cli": "^7.0.0",
            "@capacitor/core": "^7.0.0",
            "@capacitor/ios": "^7.0.0",
        },
        "devDependencies": {
            "typescript": "^5.6.3",
        },
        "mobileApp": {
            "version": mobile_app.app_version,
            "buildNumber": mobile_app.build_number,
            "capabilityRegistry": capability_metadata["registry"],
            "enabledCapabilities": capability_metadata["enabled_capability_keys"],
            "implementedCapabilities": capability_metadata[
                "implemented_capability_keys"
            ],
            "plannedCapabilities": capability_metadata["planned_capability_keys"],
            "experimentalCapabilities": capability_metadata[
                "experimental_capability_keys"
            ],
            "recommendedCapacitorPackages": capability_metadata[
                "recommended_capacitor_packages"
            ],
            "nativeUploadDependencies": native_upload_dependencies,
            "bridgeDependencies": bridge_dependencies,
            "bridgeScript": (
                "assets/mobile-bridge.js"
                if has_mobile_bridge_features(mobile_app)
                else ""
            ),
            "secureStorage": {
                "enabled": bool(mobile_app.enable_secure_storage),
                "plugin": "capacitor-secure-storage-plugin",
                "browserFallback": "memory_ephemeral",
                "supportedOperations": ["set", "get", "remove", "clear"],
                "runtimeVerificationRequired": True,
            },
            "brandingManifest": "assets/branding.json",
            "generatedAssets": branding_assets["generated_files"],
            "appShell": {
                "html": "dist/index.html",
                "css": "dist/assets/app-shell.css",
                "js": "dist/assets/app-shell.js",
                "safeAreaSupport": True,
                "networkStateHandling": True,
                "statusBarColor": mobile_app.status_bar_color,
                "splashBackgroundColor": mobile_app.background_color,
            },
            "pwa": {
                "manifest": "dist/manifest.webmanifest",
                "serviceWorker": "dist/service-worker.js",
                "offlineHtml": "dist/offline.html",
                "cacheVersion": f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}",
                "standaloneReady": mobile_app.display_mode == "standalone",
            },
            "runtimePolicies": {
                "externalLinks": {
                    "sameOriginBehavior": "stay_in_webview",
                    "crossOriginBehavior": "open_in_system_browser",
                    "supportedSchemes": ["http", "https", "mailto", "tel"],
                    "blockedSchemes": ["javascript", "data", "file"],
                },
                "downloads": {
                    "behavior": "webview_or_browser_default",
                    "nativeDownloadManager": False,
                    "note": "Native download handling still needs platform-specific verification.",
                },
                "deepLinks": {
                    "enabled": bool(mobile_app.enable_deep_links),
                    "associatedDomains": [f"applinks:{site.hostname}"],
                    "androidAssetLinksPath": "/.well-known/assetlinks.json",
                    "appleAssociationPath": "/.well-known/apple-app-site-association",
                },
            },
            "notes": capability_metadata["deep_link_notes"],
        },
    }
    dependency_versions = {
        CAMERA_CAPACITOR_PACKAGE: "^7.0.0",
        FILE_PICKER_CAPACITOR_PACKAGE: "^7.0.0",
        "@capacitor/share": "^7.0.0",
        "@capacitor-mlkit/barcode-scanning": "^7.0.0",
        "capacitor-secure-storage-plugin": "0.12.0",
    }
    for dependency in bridge_dependencies:
        package_json["dependencies"][dependency] = dependency_versions[dependency]
    capacitor_config = f"""import type {{ CapacitorConfig }} from '@capacitor/cli';\n\nconst config: CapacitorConfig = {{\n  appId: '{mobile_app.app_id or f'com.mandelblog.site{site.pk}'}',\n  appName: {json.dumps(mobile_app.app_name)},\n  webDir: 'dist',\n  server: {{\n    url: {json.dumps(get_base_url(site))},\n    cleartext: false,\n  }},\n  plugins: {{\n    SplashScreen: {{\n      backgroundColor: {json.dumps(mobile_app.background_color)},\n    }},\n    StatusBar: {{\n      backgroundColor: {json.dumps(mobile_app.status_bar_color)},\n    }},\n  }},\n}};\n\nexport default config;\n"""
    generated_readme = f"""# Generated mobile app shell for site {site.pk}

- Site: {site.hostname}
- App name: {mobile_app.app_name}
- App ID: {mobile_app.app_id or f'com.mandelblog.site{site.pk}'}
- Source website: {get_base_url(site)}

## Implemented by the generator

- `package.json` with Capacitor dependency metadata
- `capacitor.config.ts` with app id, app name, `webDir`, and server URL
- branding assets under `assets/`
- a deterministic local app shell: `dist/index.html`, `assets/app-shell.css`, and `assets/app-shell.js`
- a generated PWA layer: `dist/manifest.webmanifest`, `dist/service-worker.js`, and `dist/offline.html`
- `assets/mobile-bridge.js` when camera uploads, file picker, share sheet, or QR scanner support are enabled
- `assets/mobile-bridge.js` when camera uploads, file picker, share sheet, QR scanner, or secure storage support are enabled
- capability planning and permission notes in `assets/app-config.json`
- runtime policy metadata for external links, downloads, and deep-link readiness

## Requires local tooling

- Node.js and npm
- Android: JDK 17+, Android SDK, accepted SDK licenses
- iOS: full Xcode and CocoaPods

## What is not verified until local tooling is present

- `npx cap doctor android` with a working Android SDK
- `./gradlew assembleDebug`
- `npx cap add ios`
- `npx cap sync ios`
- `xcodebuild` simulator builds

## Enabled capability planning

{chr(10).join(f"- {capability['display_name']} ({capability['key']}): {'enabled' if capability['enabled'] else 'disabled'} / {capability['implementation_status']}" for capability in capability_metadata['capabilities'])}

## Capability model

- `implemented` means the generator emits concrete metadata, shell behavior, or bridge code for that capability.
- `planned` means the capability is modeled and documented, but native behavior is still deferred.
- `experimental` means partial policy/runtime support exists, but device verification is still required before treating it as production-native.
- `runtime_verification_required` means local metadata generation is not enough; Android and/or iOS tooling still need to prove the behavior on device or simulator.

## Recommended Capacitor packages

{chr(10).join(f"- {package_name}" for package_name in capability_metadata['recommended_capacitor_packages']) if capability_metadata['recommended_capacitor_packages'] else "- No additional packages recommended yet."}

## Generated branding assets

{chr(10).join(f"- {filename}" for filename in branding_assets['generated_files']) if branding_assets['generated_files'] else "- No branding assets were generated because the app icon is missing."}

Recommended source dimensions:

- App icon: at least 1024x1024, square, with transparent padding where possible
- Splash image: at least 2048x2048, centered subject/logo, with safe padding
- Generated splash output: {DEFAULT_SPLASH_SIZE}x{DEFAULT_SPLASH_SIZE}

## Native UX shell

- `dist/index.html` provides a branded first-paint shell for the generated package.
- `assets/app-shell.css` adds safe-area padding for notches and home-indicator devices.
- `assets/app-shell.js` updates online/offline state without requesting native permissions.
- The local shell is deterministic and tooling-independent, but the actual Capacitor runtime still depends on the configured remote server URL.
- The generated PWA layer caches only shell/static assets by default and avoids dynamic business data.

The shell includes an "Open website" action, a reload action, and a visible offline-state message when the browser reports that it is offline.

## Native interaction bridge

{("- Include `assets/mobile-bridge.js` in the generated app shell." if has_mobile_bridge_features(mobile_app) else "- Native interaction bridge is not generated because camera, file picker, share sheet, QR scanner, and secure storage are all disabled.")}

Example usage:

```html
<script src="./assets/mobile-bridge.js"></script>
<button onclick="window.MandelMobileApp.pickImage()">Upload photo</button>
<button onclick="window.MandelMobileApp.pickFile()">Upload document</button>
<button onclick="window.MandelMobileApp.shareCurrentPage()">Share this page</button>
<button onclick="window.MandelMobileApp.scanQrCode()">Scan QR code</button>
<button onclick="window.MandelMobileApp.secureStorage.set('session-token', 'demo')">Store token</button>
```

- In Capacitor, the bridge uses native plugins when available.
- In a normal browser or PWA, uploads fall back to `<input type="file">` and sharing falls back to `navigator.share()` when supported.
- Camera or file access is requested only after user action.
- Sharing returns a clear `share_not_supported` error when neither the native plugin nor `navigator.share()` is available.
- QR scanning returns a clear `qr_scanner_not_supported` error when the barcode scanner plugin is unavailable.
- QR scanning still needs camera permission copy and on-device verification before release.
- Secure storage uses native secure storage when available and only falls back to in-memory browser storage.
- The browser fallback is **not** durable secure storage and must not be treated as a safe place for real secrets.

## Secure storage

- Generated dependency: `capacitor-secure-storage-plugin@0.12.0`
- Generated API: `window.MandelMobileApp.secureStorage.set/get/remove/clear`
- Native runtime should use platform secure storage.
- Browser fallback is memory-only and exists for local verification, not for production secret persistence.

## Android permission placeholders

{chr(10).join(f"- {item['title']}: {item['placeholder']}" for item in capability_metadata['permission_placeholders']['android'])}

## iOS permission placeholders

{chr(10).join(f"- {item['title']}: {item['placeholder']}" for item in capability_metadata['permission_placeholders']['ios'])}

## Deep link planning

{chr(10).join(f"- {note}" for note in capability_metadata['deep_link_notes'])}

Example target:

- https://{site.hostname}/... should open the installed app when domain association files are in place.

Generated deep-link metadata:

- Associated domains: `applinks:{site.hostname}`
- Android asset links path: `/.well-known/assetlinks.json`
- Apple association path: `/.well-known/apple-app-site-association`

## External links and downloads

- Same-origin URLs are expected to stay inside the WebView.
- Cross-origin URLs should open in the system browser.
- `mailto:` and `tel:` are treated as external intents.
- Native download-manager integration is **not** implemented yet; test real downloads on device before release.

## Generated PWA layer

- Manifest path: `./manifest.webmanifest`
- Service worker path: `./service-worker.js`
- Offline fallback path: `./offline.html`
- Cache version: `mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}`
- The generated service worker caches only shell/static assets and does not aggressively cache dynamic user or business pages.

## Runtime diagnostics

- The generated shell exposes `window.MandelMobileAppDiagnostics.getSnapshot()`
- Diagnostic helpers also expose `openExternal(url)` and `describeDownload(url)`
- Diagnostics are tooling-independent and safe for local verification only

## Next steps

1. npm install
2. npm run sync
3. Add only the native Capacitor plugins that match the enabled capabilities above.
4. Review `assets/branding.json` and the generated icon/splash assets.
5. Review `assets/app-shell.css` and `assets/app-shell.js` if you need host-specific shell styling.
6. Add Android and iOS permission copy before requesting sensitive permissions.
7. npm run android
8. npm run ios

{THIN_WRAPPER_WARNING}
"""
    env_json = {
        "site_id": site.pk,
        "site_hostname": site.hostname,
        "source_url": get_base_url(site),
        "app_version": mobile_app.app_version,
        "build_number": mobile_app.build_number,
        "manifest_url": build_absolute_url(site, reverse("manifest_json")),
        "service_worker_url": build_absolute_url(
            site, reverse("mobileapp_serviceworker")
        ),
        "capability_planning": capability_metadata,
        "native_upload_dependencies": native_upload_dependencies,
        "bridge_dependencies": bridge_dependencies,
        "bridge_script": (
            "assets/mobile-bridge.js" if has_mobile_bridge_features(mobile_app) else ""
        ),
        "branding_assets": branding_assets,
        "native_ux": {
            "shell_html": "dist/index.html",
            "shell_css": "dist/assets/app-shell.css",
            "shell_js": "dist/assets/app-shell.js",
            "safe_area_support": True,
            "network_state_handling": True,
            "first_paint_loader": True,
            "status_bar_color": mobile_app.status_bar_color,
            "splash_background_color": mobile_app.background_color,
            "offline_fallback_route": get_offline_url(site, mobile_app),
            "runtime_diagnostics": {
                "global": "window.MandelMobileAppDiagnostics",
                "supports_external_link_policy": True,
                "supports_download_descriptions": True,
                "supports_share_sheet": bool(mobile_app.enable_share_sheet),
                "supports_qr_scanner": bool(mobile_app.enable_qr_scanner),
            },
            "runtime_note": (
                "The generated shell is verified locally. Actual Capacitor runtime still depends on the configured remote server URL and platform tooling."
            ),
        },
        "pwa": {
            "manifest_path": "./manifest.webmanifest",
            "service_worker_path": "./service-worker.js",
            "offline_html_path": "./offline.html",
            "cache_version": f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}",
            "standalone_ready": mobile_app.display_mode == "standalone",
            "service_worker_registration": True,
        },
        "runtime_policies": {
            "external_links": {
                "same_origin": "stay_in_webview",
                "cross_origin": "open_in_system_browser",
                "allowed_schemes": ["http", "https", "mailto", "tel"],
                "blocked_schemes": ["javascript", "data", "file"],
            },
            "downloads": {
                "mode": "webview_or_browser_default",
                "native_manager": False,
            },
            "secure_storage": {
                "backend": (
                    "native_secure_storage_or_memory_ephemeral_fallback"
                    if mobile_app.enable_secure_storage
                    else "disabled"
                ),
                "browser_fallback": "memory_ephemeral_only",
                "native_plugin": "capacitor-secure-storage-plugin",
                "supported_operations": ["set", "get", "remove", "clear"],
            },
            "deep_links": {
                "enabled": bool(mobile_app.enable_deep_links),
                "associated_domains": [f"applinks:{site.hostname}"],
                "android_assetlinks_path": "/.well-known/assetlinks.json",
                "apple_association_path": "/.well-known/apple-app-site-association",
            },
        },
    }

    (output_dir / "package.json").write_text(
        json.dumps(package_json, indent=2) + "\n", encoding="utf-8"
    )
    (output_dir / "capacitor.config.ts").write_text(capacitor_config, encoding="utf-8")
    (output_dir / "README.md").write_text(generated_readme, encoding="utf-8")
    (output_dir / "ANDROID.md").write_text(
        (
            "# Android verification\n\n"
            "Implemented by the generator:\n\n"
            "- `package.json` with Capacitor Android dependencies\n"
            "- `capacitor.config.ts` with app id, app name, `webDir`, and server URL\n"
            "- branding assets under `assets/`\n"
            "- local shell assets in `dist/index.html`, `assets/app-shell.css`, and `assets/app-shell.js`\n"
            "- `assets/mobile-bridge.js` when camera uploads, file picker, share sheet, or QR scanner support are enabled\n\n"
            "Local tooling required before a real build:\n\n"
            "- JDK 17 or newer available to `java` / `javac`\n"
            "- Android SDK command-line tools or Android Studio\n"
            "- `ANDROID_HOME` / `ANDROID_SDK_ROOT` configured\n"
            "- accepted Android SDK licenses\n\n"
            "Verification flow:\n\n"
            "1. `npm install`\n"
            "2. `npx cap add android`\n"
            "3. `npx cap sync android`\n"
            "4. `npx cap doctor android`\n"
            "5. `cd android && ./gradlew assembleDebug`\n\n"
            "Expected structural results before the final Gradle build:\n\n"
            "- `android/` exists\n"
            "- `android/app/build.gradle` contains the generated application id\n"
            "- `android/app/src/main/assets/capacitor.config.json` exists\n\n"
            "If `./gradlew assembleDebug` fails before app compilation starts, treat it as local tooling until proven otherwise.\n"
        ),
        encoding="utf-8",
    )
    (output_dir / "IOS.md").write_text(
        (
            "# iOS verification\n\n"
            "Implemented by the generator:\n\n"
            "- `package.json` with Capacitor iOS dependencies\n"
            "- `capacitor.config.ts` with app id, app name, `webDir`, and server URL\n"
            "- branding assets under `assets/`\n"
            "- local shell assets in `dist/index.html`, `assets/app-shell.css`, and `assets/app-shell.js`\n"
            "- camera/file-picker/share-sheet/QR-scanner planning in generated README and `assets/app-config.json`\n\n"
            "Local tooling required before a real build:\n\n"
            "- full Xcode, not only Command Line Tools\n"
            "- CocoaPods available as `pod`\n"
            "- iOS Simulator SDK installed through Xcode\n\n"
            "Verification flow:\n\n"
            "1. `npm install`\n"
            "2. `npx cap add ios`\n"
            "3. `npx cap sync ios`\n"
            "4. `npx cap doctor ios`\n"
            "5. `cd ios/App && xcodebuild -workspace App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator build`\n\n"
            "Expected structural results before the final Xcode build:\n\n"
            "- `ios/App/App.xcodeproj` exists\n"
            "- `ios/App/App.xcworkspace` exists\n"
            "- `ios/App/App/capacitor.config.json` exists\n\n"
            "If `npx cap add ios` fails with a CocoaPods or Xcode error, treat it as local tooling until proven otherwise.\n"
        ),
        encoding="utf-8",
    )
    dist_dir = output_dir / "dist"
    dist_dir.mkdir(exist_ok=True)
    dist_assets_dir = dist_dir / "assets"
    dist_assets_dir.mkdir(exist_ok=True)
    (dist_dir / "index.html").write_text(
        build_generated_shell_html(site, mobile_app),
        encoding="utf-8",
    )
    (dist_dir / "offline.html").write_text(
        build_generated_offline_html(site, mobile_app),
        encoding="utf-8",
    )
    (dist_dir / "manifest.webmanifest").write_text(
        json.dumps(build_generated_pwa_manifest(site, mobile_app), indent=2) + "\n",
        encoding="utf-8",
    )
    (dist_dir / "service-worker.js").write_text(
        build_generated_service_worker(site, mobile_app),
        encoding="utf-8",
    )
    (assets_dir / "app-config.json").write_text(
        json.dumps(env_json, indent=2) + "\n", encoding="utf-8"
    )
    (assets_dir / "app-shell.css").write_text(
        build_generated_shell_css(mobile_app), encoding="utf-8"
    )
    (assets_dir / "app-shell.js").write_text(
        build_generated_shell_js(site, mobile_app), encoding="utf-8"
    )
    shutil.copyfile(assets_dir / "app-shell.css", dist_assets_dir / "app-shell.css")
    shutil.copyfile(assets_dir / "app-shell.js", dist_assets_dir / "app-shell.js")
    for runtime_asset in [
        "app-icon.png",
        "favicon-32.png",
        "favicon-180.png",
        "icon-192.png",
        "icon-512.png",
        "branding.json",
    ]:
        source = assets_dir / runtime_asset
        if source.exists():
            shutil.copyfile(source, dist_assets_dir / runtime_asset)
    if has_mobile_bridge_features(mobile_app):
        (assets_dir / "mobile-bridge.js").write_text(
            build_mobile_bridge_script(mobile_app),
            encoding="utf-8",
        )
        shutil.copyfile(
            assets_dir / "mobile-bridge.js", dist_assets_dir / "mobile-bridge.js"
        )
    return output_dir


def run_mobileapp_checks(site: Site, mobile_app: MobileApp) -> list[CheckMessage]:
    messages: list[CheckMessage] = []
    base_url = get_base_url(site)
    generated_assets = build_branding_asset_statuses(site.pk)
    generated_asset_names = {
        entry["filename"] for entry in generated_assets if entry["exists"]
    }
    if not base_url.startswith("https://"):
        messages.append(
            CheckMessage(
                "warning",
                "https",
                _(
                    "Site URL is not HTTPS; PWA and native app distribution expect HTTPS."
                ),
            )
        )
    else:
        messages.append(CheckMessage("ok", "https", _("HTTPS site URL detected.")))

    required_values = [
        ("name", mobile_app.app_name, _("App name is configured.")),
        ("app_id", mobile_app.app_id, _("App ID / bundle identifier is configured.")),
        ("app_version", mobile_app.app_version, _("App version is configured.")),
        ("icon", mobile_app.icon, _("App icon is configured.")),
    ]
    for code, value, success_message in required_values:
        if value:
            messages.append(CheckMessage("ok", code, success_message))
        else:
            messages.append(
                CheckMessage(
                    "warning",
                    code,
                    _("Missing %(label)s.") % {"label": code.replace("_", " ")},
                )
            )

    if mobile_app.enable_pwa:
        messages.append(CheckMessage("ok", "pwa_enabled", _("PWA support is enabled.")))
    else:
        messages.append(
            CheckMessage("warning", "pwa_enabled", _("PWA support is disabled."))
        )

    if mobile_app.enable_android or mobile_app.enable_ios:
        messages.append(CheckMessage("warning", "store_value", THIN_WRAPPER_WARNING))
    enabled_capabilities = [
        capability
        for capability in build_capability_statuses(mobile_app)
        if capability["enabled"]
    ]
    if enabled_capabilities:
        messages.append(
            CheckMessage(
                "ok",
                "native_capabilities",
                _(
                    "Native capability planning is enabled for %(count)s capability flags."
                )
                % {"count": len(enabled_capabilities)},
            )
        )
    else:
        messages.append(
            CheckMessage(
                "warning",
                "native_capabilities",
                _(
                    "No native capability flags are enabled yet; the generated apps still risk looking like thin website wrappers."
                ),
            )
        )
    if any(
        getattr(mobile_app, field_name, False)
        for field_name in [
            "enable_push_notifications",
            "enable_camera_uploads",
            "enable_geolocation",
            "enable_qr_scanner",
            "enable_biometrics",
        ]
    ):
        messages.append(
            CheckMessage(
                "warning",
                "permission_copy",
                _(
                    "Sensitive native capabilities are enabled. Add Android and iOS permission copy before release."
                ),
            )
        )
    if mobile_app.enable_deep_links:
        messages.append(
            CheckMessage(
                "warning",
                "deep_links",
                _(
                    "Deep link planning is enabled. Android App Links and iOS Universal Links still need domain association files."
                ),
            )
        )
    if mobile_app.enable_camera_uploads and not mobile_app.ios_camera_usage_description:
        messages.append(
            CheckMessage(
                "warning",
                "ios_camera_permission",
                _(
                    "Camera uploads are enabled, but the iOS camera usage description is still empty."
                ),
            )
        )
    if mobile_app.enable_qr_scanner and not mobile_app.ios_camera_usage_description:
        messages.append(
            CheckMessage(
                "warning",
                "ios_qr_camera_permission",
                _(
                    "QR scanner is enabled, but the iOS camera usage description is still empty."
                ),
            )
        )
    if has_mobile_bridge_features(mobile_app):
        messages.append(
            CheckMessage(
                "warning",
                "bridge_integration",
                _(
                    "Native bridge features are enabled. Include {% mobile_app_bridge %} in the public template before rollout."
                ),
            )
        )
        if mobile_app.enable_file_picker:
            messages.append(
                CheckMessage(
                    "warning",
                    "file_picker_docs",
                    _(
                        "File picker support is enabled. Make sure the generated mobile bridge script is included in the app shell and public template docs."
                    ),
                )
            )
        if mobile_app.enable_share_sheet:
            messages.append(
                CheckMessage(
                    "warning",
                    "share_sheet_runtime",
                    _(
                        "Share sheet support is generated, but it still needs Android/iOS device verification because runtime behavior depends on Capacitor Share or navigator.share."
                    ),
                )
            )
        if mobile_app.enable_qr_scanner:
            messages.append(
                CheckMessage(
                    "warning",
                    "qr_scanner_runtime",
                    _(
                        "QR scanner support is generated, but it still needs Android/iOS device verification because runtime behavior depends on the native barcode scanner plugin."
                    ),
                )
            )
        if mobile_app.enable_secure_storage:
            messages.append(
                CheckMessage(
                    "warning",
                    "secure_storage_runtime",
                    _(
                        "Secure storage support is generated, but it still needs Android/iOS device verification. Browser fallback is memory-only and must not be used as durable secret storage."
                    ),
                )
            )
        else:
            messages.append(
                CheckMessage(
                    "ok",
                    "file_picker_docs",
                    _(
                        "Generated upload bridge documentation is ready once the bridge tag is included."
                    ),
                )
            )

    offline_target = mobile_app.offline_url or OFFLINE_FALLBACK_ROUTE
    if mobile_app.icon:
        messages.append(
            CheckMessage(
                "ok",
                "branding_icon",
                _("Branding source icon is available for generated assets."),
            )
        )
        if mobile_app.icon.width < 1024 or mobile_app.icon.height < 1024:
            messages.append(
                CheckMessage(
                    "warning",
                    "branding_icon_resolution",
                    _(
                        "The source icon is smaller than the recommended 1024x1024 size."
                    ),
                )
            )
    else:
        messages.append(
            CheckMessage(
                "warning",
                "branding_icon",
                _(
                    "No app icon is uploaded, so generated branding assets are incomplete."
                ),
            )
        )
    if mobile_app.splash_image:
        messages.append(
            CheckMessage(
                "ok",
                "branding_splash",
                _("Splash image is configured for generated splash assets."),
            )
        )
    else:
        messages.append(
            CheckMessage(
                "warning",
                "branding_splash",
                _(
                    "Splash image is missing. A fallback splash can be generated, but a dedicated splash asset is recommended."
                ),
            )
        )
    required_generated = {
        "branding.json",
        "adaptive-icon.png",
        "adaptive-icon-foreground.png",
        "adaptive-icon-background.png",
        "splash.png",
        "favicon-32.png",
        "favicon-180.png",
        "icon-192.png",
        "icon-512.png",
    }
    missing_generated = sorted(required_generated - generated_asset_names)
    if missing_generated:
        messages.append(
            CheckMessage(
                "warning",
                "generated_assets",
                _("Generated branding assets are missing: %(assets)s.")
                % {"assets": ", ".join(missing_generated)},
            )
        )
    else:
        messages.append(
            CheckMessage(
                "ok",
                "generated_assets",
                _("Generated branding assets are present."),
            )
        )
    if "branding.json" in generated_asset_names:
        messages.append(
            CheckMessage(
                "ok",
                "branding_manifest",
                _("Branding manifest has been generated."),
            )
        )
    else:
        messages.append(
            CheckMessage(
                "warning",
                "branding_manifest",
                _(
                    "Branding manifest is missing. Run mobileapp_generate to refresh assets."
                ),
            )
        )
    messages.append(
        CheckMessage(
            "ok",
            "offline",
            _("Offline fallback is configured for %(url)s.") % {"url": offline_target},
        )
    )
    messages.append(
        CheckMessage(
            "ok",
            "cache_exclusions",
            _(
                "Admin and API routes are excluded from the default service worker cache."
            ),
        )
    )
    messages.append(
        CheckMessage(
            "ok",
            "external_link_policy",
            _(
                "Generated runtime policy treats cross-origin links as external browser targets."
            ),
        )
    )
    messages.append(
        CheckMessage(
            "warning",
            "download_handling",
            _(
                "Native download-manager integration is not implemented yet; test file downloads on target devices before release."
            ),
        )
    )
    messages.append(
        CheckMessage(
            "ok",
            "runtime_diagnostics",
            _(
                "Generated shell diagnostics are available through window.MandelMobileAppDiagnostics."
            ),
        )
    )
    messages.append(
        CheckMessage(
            "ok",
            "generated_pwa_manifest",
            _(
                "Generated package includes manifest.webmanifest metadata for installability review."
            ),
        )
    )
    messages.append(
        CheckMessage(
            "ok",
            "generated_service_worker",
            _(
                "Generated package includes a defensive service worker for shell/offline assets only."
            ),
        )
    )
    return messages


def get_status_summary(site: Site, mobile_app: MobileApp) -> dict:
    capability_metadata = build_capability_metadata(mobile_app)
    native_upload_dependencies = get_native_upload_dependencies(mobile_app)
    branding_assets = build_branding_asset_statuses(site.pk)
    existing_assets = [
        asset["filename"] for asset in branding_assets if asset["exists"]
    ]
    return {
        "site": site,
        "settings_obj": mobile_app,
        "generated_path": str(get_generated_output_dir(site.pk)),
        "manifest_path": reverse("manifest_json"),
        "service_worker_path": reverse("mobileapp_serviceworker"),
        "offline_path": mobile_app.offline_url or reverse("mobileapp_offline"),
        "checks": run_mobileapp_checks(site, mobile_app),
        "store_warning": THIN_WRAPPER_WARNING,
        "app_version": mobile_app.app_version,
        "build_number": mobile_app.build_number,
        "android_icon_size": ANDROID_ICON_SIZE,
        "android_large_icon_size": ANDROID_LARGE_ICON_SIZE,
        "apple_touch_icon_size": APPLE_TOUCH_ICON_SIZE,
        "cache_version": DEFAULT_CACHE_VERSION,
        "generated_pwa_cache_version": f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}",
        "excluded_prefixes": SERVICE_WORKER_EXCLUDED_PREFIXES,
        "capabilities": capability_metadata["capabilities"],
        "enabled_capabilities": capability_metadata["enabled_capabilities"],
        "planned_capabilities": capability_metadata["planned_capabilities"],
        "runtime_verification_capabilities": capability_metadata[
            "runtime_verification_required"
        ],
        "capability_dashboard_notes": capability_metadata["dashboard_notes"],
        "recommended_capacitor_packages": capability_metadata[
            "recommended_capacitor_packages"
        ],
        "permission_placeholders": capability_metadata["permission_placeholders"],
        "deep_link_notes": capability_metadata["deep_link_notes"],
        "runtime_policies": {
            "external_links": {
                "same_origin": "stay_in_webview",
                "cross_origin": "open_in_system_browser",
                "allowed_schemes": ["http", "https", "mailto", "tel"],
                "blocked_schemes": ["javascript", "data", "file"],
            },
            "downloads": {
                "mode": "webview_or_browser_default",
                "native_manager": False,
            },
            "diagnostics_global": "window.MandelMobileAppDiagnostics",
        },
        "generated_pwa": {
            "manifest_path": "dist/manifest.webmanifest",
            "service_worker_path": "dist/service-worker.js",
            "offline_html_path": "dist/offline.html",
            "cache_version": f"mobileapp-generated-{DEFAULT_CACHE_VERSION}-site-{site.pk}-build-{mobile_app.build_number}",
            "service_worker_registration": True,
            "standalone_ready": mobile_app.display_mode == "standalone",
        },
        "bridge_tag": "{% mobile_app_bridge %}",
        "bridge_script_required": has_mobile_bridge_features(mobile_app),
        "native_upload_dependencies": native_upload_dependencies,
        "bridge_dependencies": get_mobile_bridge_dependencies(mobile_app),
        "secure_storage_backend": (
            "native_secure_storage_or_memory_ephemeral_fallback"
            if mobile_app.enable_secure_storage
            else "disabled"
        ),
        "branding_assets": branding_assets,
        "existing_branding_assets": existing_assets,
        "branding_icon_present": bool(mobile_app.icon),
        "branding_splash_present": bool(mobile_app.splash_image),
        "adaptive_icon_generated": "adaptive-icon.png" in existing_assets,
        "branding_manifest_present": "branding.json" in existing_assets,
    }
