"""GitHub Actions steps that deploy a built static site to W4P and activate it through Core.

The upload script mirrors ``src/domain/hosting/adapters/w4p_static.py``: the same asset hash, the same
upload session, batch upload and worker PUT. The activation step asks GitHub for an OIDC token for this
run and calls ``/api/hosting/w4p/activate``, which moves the route. Core stays the source of truth.
"""

from __future__ import annotations

import json

# Upload script, run with `node --input-type=module`. Reads its inputs from the environment and writes
# the version script name to $GITHUB_OUTPUT. It must not contain GitHub expression syntax.
_UPLOAD_TEMPLATE = r"""
import { createHash } from "node:crypto";
import { readFile, readdir, lstat, appendFile, realpath } from "node:fs/promises";
import { join, relative, posix, extname, sep, isAbsolute } from "node:path";

const API = process.env.CF_API_BASE || "https://api.cloudflare.com/client/v4";
const ACCOUNT = process.env.CF_ACCOUNT_ID;
const TOKEN = process.env.CF_API_TOKEN;
const NAMESPACE = process.env.W4P_NAMESPACE;
const LIVE = process.env.LIVE_SCRIPT;
const SHA = process.env.GIT_SHA || "";
const STATIC_DIR = process.env.STATIC_DIR;
const COMPAT = process.env.COMPAT_DATE;
const MAIN = "worker.js";
const MAX_BATCH = 40 * 1024 * 1024;
const RUNTIME_SOURCE = __P2HS_RUNTIME_JSON__;
const WORKER_ENTRY = __P2HS_ENTRY_JSON__;
const PLATFORM_ROUTES = __P2HS_ROUTES_JSON__;
__P2HS_BUNDLER__
// Generated apps use client-side routes (for example /request-quote). Direct
// navigations must receive index.html so the app router can render the route.
const ASSET_CONFIG = { html_handling: "auto-trailing-slash", not_found_handling: "single-page-application" };

function fail(message) {
  console.error(`w4p_deploy_failed: ${message}`);
  process.exit(1);
}

async function collect(base) {
  const files = [];
  async function walk(dir) {
    for (const entry of await readdir(dir, { withFileTypes: true })) {
      const full = join(dir, entry.name);
      const rel = relative(base, full).split("\\").join(posix.sep);
      if (rel.split(posix.sep).some((part) => part.startsWith("."))) continue;
      const info = await lstat(full);
      if (info.isSymbolicLink()) continue;
      if (info.isDirectory()) await walk(full);
      else if (info.isFile()) files.push({ path: "/" + rel, content: await readFile(full) });
    }
  }
  await walk(base);
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
  return files;
}

function assetHash(path, content) {
  const body = content.toString("base64");
  return createHash("sha256").update(body + extname(path)).digest("hex").slice(0, 32);
}

const MIME_TYPES = {
  ".html": "text/html; charset=utf-8",
  ".htm": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "application/javascript; charset=utf-8",
  ".mjs": "application/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".map": "application/json; charset=utf-8",
  ".svg": "image/svg+xml",
  ".txt": "text/plain; charset=utf-8",
  ".xml": "application/xml; charset=utf-8",
  ".wasm": "application/wasm",
  ".webmanifest": "application/manifest+json",
  ".ico": "image/x-icon",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".avif": "image/avif",
  ".woff": "font/woff",
  ".woff2": "font/woff2",
  ".ttf": "font/ttf",
  ".otf": "font/otf",
};

function assetContentType(path) {
  return MIME_TYPES[extname(path).toLowerCase()] || "application/octet-stream";
}

function scriptUrl(script, suffix = "") {
  return `${API}/accounts/${ACCOUNT}/workers/dispatch/namespaces/${NAMESPACE}/scripts/${script}${suffix}`;
}

async function call(url, init, what) {
  const res = await fetch(url, init);
  let body = {};
  try { body = await res.json(); } catch { body = {}; }
  if (!res.ok || body.success === false) {
    const message = body.errors && body.errors[0] ? body.errors[0].message : "";
    fail(`${what}: HTTP ${res.status} ${message}`);
  }
  return body.result || {};
}

function normalizeBuckets(buckets, byHash) {
  if (buckets.length && buckets.every((b) => Array.isArray(b))) {
    return buckets.filter((b) => b.length);
  }
  const batches = [];
  let batch = [];
  let size = 0;
  for (const hash of buckets.map(String)) {
    const n = byHash.get(hash).content.length;
    if (batch.length && size + n > MAX_BATCH) {
      batches.push(batch);
      batch = [];
      size = 0;
    }
    batch.push(hash);
    size += n;
  }
  if (batch.length) batches.push(batch);
  return batches;
}

async function main() {
  for (const [name, value] of Object.entries({ CF_ACCOUNT_ID: ACCOUNT, CF_API_TOKEN: TOKEN, W4P_NAMESPACE: NAMESPACE, LIVE_SCRIPT: LIVE, STATIC_DIR, COMPAT_DATE: COMPAT })) {
    if (!value) fail(`missing_${name}`);
  }
  const files = await collect(STATIC_DIR);
  if (!files.length) fail("static_dir_empty");
  if (!files.some((f) => f.path === "/index.html")) fail("static_dir_has_no_index_html");
  const applicationPrelude = await bundleApplicationRoutes(process.env.REPO_ROOT || process.cwd(), PLATFORM_ROUTES);
  const WORKER_SOURCE = RUNTIME_SOURCE + "\n" + applicationPrelude + "\n" + WORKER_ENTRY;

  const version = `${LIVE}-v${(SHA || "unknown").slice(0, 8)}`;
  const byHash = new Map();
  const manifest = {};
  for (const f of files) {
    const hash = assetHash(f.path, f.content);
    manifest[f.path] = { hash, size: f.content.length };
    byHash.set(hash, f);
  }

  const session = await call(scriptUrl(version, "/assets-upload-session"), {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ manifest }),
  }, "assets_upload_session");

  let completion = session.jwt;
  for (const batch of normalizeBuckets(session.buckets || [], byHash)) {
    const form = new FormData();
    for (const hash of batch) {
      const file = byHash.get(hash);
      form.append(hash, new Blob([file.content.toString("base64")], { type: assetContentType(file.path) }), hash);
    }
    const result = await call(`${API}/accounts/${ACCOUNT}/workers/assets/upload?base64=true`, {
      method: "POST",
      headers: { Authorization: `Bearer ${session.jwt}` },
      body: form,
    }, "assets_upload_batch");
    if (result.jwt) completion = result.jwt;
  }

  const metadata = {
    main_module: MAIN,
    compatibility_date: COMPAT,
    assets: { jwt: completion, config: ASSET_CONFIG },
    bindings: [{ name: "ASSETS", type: "assets" }],
  };
  const put = new FormData();
  put.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
  put.append(MAIN, new Blob([WORKER_SOURCE], { type: "application/javascript+module" }), MAIN);
  await call(scriptUrl(version), {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}` },
    body: put,
  }, "put_worker");

  console.log(`uploaded ${files.length} files as ${version}`);
  if (process.env.GITHUB_OUTPUT) {
    await appendFile(process.env.GITHUB_OUTPUT, `version_script=${version}\n`);
  }
}

main().catch((err) => fail(err && err.message ? err.message : String(err)));
"""


def _rendered_upload_script() -> str:
    from src.domain.hosting.runtime.worker_source import (
        bundler_embed_source,
        runtime_source,
        worker_entry,
    )
    from src.domain.hosting.routing_contract import compile_application_runtime_manifest

    runtime = runtime_source()
    entry = worker_entry()
    bundler = bundler_embed_source()
    for part in (runtime, entry, bundler):
        if "${{" in part:
            raise RuntimeError("worker source must not contain a GitHub expression")
    return (
        _UPLOAD_TEMPLATE
        .replace("__P2HS_BUNDLER__", bundler)
        .replace("__P2HS_RUNTIME_JSON__", json.dumps(runtime))
        .replace("__P2HS_ENTRY_JSON__", json.dumps(entry))
        .replace("__P2HS_ROUTES_JSON__", json.dumps(compile_application_runtime_manifest() + [
            {"id": "application.files.upload", "method": "POST", "public_path": "/api/files", "auth": "session", "upstream_path": "/v1/projects/:project_id/application-files"},
            {"id": "application.files.metadata", "method": "GET", "public_path": "/api/files/:file_id", "auth": "session", "upstream_path": "/v1/projects/:project_id/application-files/:file_id"},
            {"id": "application.files.delete", "method": "DELETE", "public_path": "/api/files/:file_id", "auth": "session", "upstream_path": "/v1/projects/:project_id/application-files/:file_id"},
            {"id": "application.files.download", "method": "GET", "public_path": "/api/files/:file_id/download", "auth": "session", "upstream_path": "/v1/projects/:project_id/application-files/:file_id/download"},
            {"id": "diagnostics.health", "method": "GET", "public_path": "/api/__p2hs/health"},
        ]))
    )


W4P_UPLOAD_SCRIPT = _rendered_upload_script()


def _indent(text: str, spaces: int) -> str:
    pad = " " * spaces
    return "\n".join((pad + line) if line.strip() else "" for line in text.splitlines())


def w4p_deploy_steps(
    *,
    project_ref: str,
    static_dir: str,
    w4p_namespace: str,
    preview_namespace: str | None = None,
    compatibility_date: str,
    core_api_url: str,
    activation_audience: str,
    cf_api_base: str = "https://api.cloudflare.com/client/v4",
) -> str:
    """Steps that run after the site is built. Returns YAML list items at six-space indent."""
    upload_script = _indent(W4P_UPLOAD_SCRIPT.strip("\n"), 10)
    return f"""      - name: Upload static assets to W4P
        id: upload
        env:
          CF_API_TOKEN: ${{{{ secrets.CLOUDFLARE_API_TOKEN }}}}
          CF_ACCOUNT_ID: ${{{{ secrets.CLOUDFLARE_ACCOUNT_ID }}}}
          CF_API_BASE: {cf_api_base}
          W4P_NAMESPACE: ${{{{ github.ref_name == 'preview' && '{preview_namespace or w4p_namespace}' || '{w4p_namespace}' }}}}
          COMPAT_DATE: {compatibility_date}
          STATIC_DIR: ${{{{ env.W4P_STATIC_DIR || '{static_dir}' }}}}
          GIT_SHA: ${{{{ github.sha }}}}
          LIVE_SCRIPT: p2hs-${{{{ github.ref_name == 'preview' && 'prev' || 'prod' }}}}-{project_ref}
        run: |
          node --input-type=module <<'P2HS_W4P_UPLOAD'
{upload_script}
          P2HS_W4P_UPLOAD
      - name: Activate W4P version through Core
        env:
          ACTIVATION_AUDIENCE: {activation_audience}
          CORE_API_URL: {core_api_url}
          PROJECT_REF: {project_ref}
          VERSION_SCRIPT: ${{{{ steps.upload.outputs.version_script }}}}
        run: |
          OIDC=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$ACTIVATION_AUDIENCE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).value))')
          BODY=$(node -e 'process.stdout.write(JSON.stringify({{project_ref: process.env.PROJECT_REF, version_script: process.env.VERSION_SCRIPT}}))')
          for attempt in $(seq 1 12); do
            RESPONSE=$(curl -sS -w '\\n%{{http_code}}' -X POST "$CORE_API_URL/api/hosting/w4p/activate" -H "Authorization: Bearer $OIDC" -H "Content-Type: application/json" -d "$BODY")
            STATUS="${{RESPONSE##*$'\\n'}}"
            RESPONSE_BODY="${{RESPONSE%$'\\n'*}}"
            if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
              printf '%s\\n' "$RESPONSE_BODY"
              exit 0
            fi
            printf 'W4P activation attempt %s failed with HTTP %s: %s\\n' "$attempt" "$STATUS" "$RESPONSE_BODY" >&2
            case "$STATUS" in
              404|409|429|500|502|503|504) ;;
              *) exit 1 ;;
            esac
            sleep 5
          done
          if [ "$GITHUB_REF_NAME" = "preview" ] && [ "$STATUS" = "404" ] && [[ "$RESPONSE_BODY" == *'deployment_not_found'* ]]; then
            echo 'No preview deployment is waiting for this push; leaving preview inactive.'
            exit 0
          fi
          exit 1
"""
