/**
 * Firebase backend deployment:
 *  - Deploy Cloud Functions
 *  - Deploy Firestore security rules
 *  - Deploy Storage rules
 *
 * Requires: firebase-tools installed globally, user logged in.
 * Prerequisites (client must enable manually): Firebase Auth (Email/Password),
 * Secret Manager API, Firebase Storage. See PREREQUISITES.md.
 */

const { exec } = require('node:child_process');
const { promisify } = require('node:util');
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');

const execAsync = promisify(exec);

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Poll until all required GCP APIs are confirmed active.
 * APIs enabled via `gcloud services enable` can take 1–3 min to propagate,
 * especially in organization projects. This prevents deploy failures caused
 * by APIs that are "enabling" but not yet ready.
 *
 * Throws a descriptive error if any API is still not active after the timeout.
 *
 * @param {string} projectId
 * @param {string[]} apis - list of API service names to wait for
 * @param {object} [opts]
 * @param {number} [opts.timeoutMs] - max total wait time across all APIs (default 10 min)
 * @param {Function} [opts.onProgress] - called on each poll with (key, data)
 */
async function waitForApis(projectId, apis, { timeoutMs = 10 * 60 * 1000, onProgress } = {}) {
  const deadline = Date.now() + timeoutMs;
  const startTime = Date.now();
  for (const api of apis) {
    while (true) {
      const result = await run(
        `gcloud services list --enabled --filter="name:${api}" --project=${projectId} --format='value(name)'`
      );
      if (result.ok && result.stdout.includes(api)) break;
      if (Date.now() >= deadline) {
        throw new Error(
          `A API ${api} não ficou pronta em ${Math.round(timeoutMs / 60000)} minutos.\n` +
          `Isso é incomum — tente rodar o deploy manualmente em alguns minutos:\n` +
          `firebase deploy --project ${projectId} --only functions,firestore:rules,storage`
        );
      }
      const elapsed = Math.round((Date.now() - startTime) / 1000);
      if (onProgress) onProgress('wait-apis', { api, elapsed });
      await sleep(5000);
    }
  }
}

/**
 * Poll until a GCP service account exists (is visible via `gcloud iam`).
 * On brand-new projects, service accounts created by enabling APIs may take
 * a minute to propagate before IAM policy bindings can be applied to them.
 * Non-fatal: returns false if not found within the timeout.
 *
 * @param {string} serviceAccountEmail
 * @param {string} projectId
 * @param {number} timeoutMs - max wait time (default 2 min)
 */
async function waitForServiceAccount(serviceAccountEmail, projectId, timeoutMs = 2 * 60 * 1000) {
  const deadline = Date.now() + timeoutMs;
  while (true) {
    const result = await run(
      `gcloud iam service-accounts describe ${serviceAccountEmail} --project=${projectId}`
    );
    if (result.ok) return true;
    if (Date.now() >= deadline) return false;
    await sleep(5000);
  }
}

/**
 * Poll until the Eventarc Service Agent has its required IAM role on the project.
 * Returns as soon as the binding is confirmed — no unnecessary waiting on re-deploys
 * where the role was already granted in a previous run.
 *
 * On first deploy: waits up to timeoutMs (typically 60s) for propagation.
 * On re-deploy: returns in one poll cycle (~1s) because the binding already exists.
 *
 * @param {string} projectId
 * @param {string} projectNumber
 * @param {number} timeoutMs
 */
/**
 * Poll until the Eventarc Service Agent has its required IAM role on the project.
 * Returns true when confirmed, false if the binding never appeared within timeoutMs.
 * A false return means the IAM grant is being blocked (org policy or VPC SC).
 *
 * On re-deploy: returns true in one poll cycle (~1s) because the binding already exists.
 * On first deploy: polls up to timeoutMs for the binding to propagate.
 */
async function waitForEventarcIam(projectId, projectNumber, timeoutMs = 60 * 1000) {
  const eventarcSa = `service-${projectNumber}@gcp-sa-eventarc.iam.gserviceaccount.com`;
  const deadline = Date.now() + timeoutMs;
  while (true) {
    const result = await run(
      `gcloud projects get-iam-policy ${projectId} ` +
      `--flatten="bindings[].members" ` +
      `--filter="bindings.role=roles/eventarc.serviceAgent AND bindings.members=serviceAccount:${eventarcSa}" ` +
      `--format="value(bindings.members)" --limit=1`
    );
    if (result.ok && result.stdout.trim().length > 0) return true; // binding confirmed
    if (Date.now() >= deadline) return false; // timed out — grant likely blocked
    await sleep(5000);
  }
}

/**
 * Verify that the App Engine default SA has roles/datastore.user on the project.
 * Cloud Functions v1 (auth triggers) run as this SA and need Firestore write access.
 * Returns true if the binding exists, false otherwise.
 */
async function verifyAppEngineSaGrant(projectId) {
  const appEngineSa = `${projectId}@appspot.gserviceaccount.com`;
  const result = await run(
    `gcloud projects get-iam-policy ${projectId} ` +
    `--flatten="bindings[].members" ` +
    `--filter="bindings.role=roles/datastore.user AND bindings.members=serviceAccount:${appEngineSa}" ` +
    `--format="value(bindings.members)" --limit=1`
  );
  return result.ok && result.stdout.trim().length > 0;
}

/**
 * Verify that the compute SA has read access to a GCF source bucket.
 * Returns true if the SA appears in the bucket's IAM policy, false otherwise.
 * A false return indicates the bucket-level grant was blocked (org policy or VPC SC).
 */
async function verifyStorageGrant(projectNumber, region) {
  const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
  const bucket = `gs://gcf-sources-${projectNumber}-${region}`;
  // gcloud storage buckets get-iam-policy does not support --flatten/--filter/--limit.
  // Parse JSON output instead and search for the member manually.
  const result = await run(`gcloud storage buckets get-iam-policy ${bucket} --format=json`);
  if (!result.ok) return false;
  try {
    const policy = JSON.parse(result.stdout);
    for (const binding of (policy.bindings || [])) {
      if (binding.members && binding.members.includes(`serviceAccount:${computeSa}`)) return true;
    }
    return false;
  } catch (_) {
    return false;
  }
}

async function run(cmd, cwd) {
  try {
    const { stdout, stderr } = await execAsync(cmd, {
      cwd,
      maxBuffer: 50 * 1024 * 1024,
    });
    return { ok: true, stdout, stderr };
  } catch (err) {
    return {
      ok: false,
      error: err.message,
      stdout: err.stdout || '',
      stderr: err.stderr || '',
    };
  }
}

/**
 * Apply multiple IAM role bindings to a GCP project in a single atomic operation.
 * Uses get-iam-policy + set-iam-policy to avoid the race condition that occurs
 * when many parallel add-iam-policy-binding calls all read and write the same policy.
 *
 * @param {string} projectId - GCP project ID
 * @param {Array<{role: string, member: string}>} bindings - list of {role, member} to add
 */
async function applyProjectIamBindings(projectId, bindings) {
  const policyResult = await run(`gcloud projects get-iam-policy ${projectId} --format=json`);
  if (!policyResult.ok) return;
  let policy;
  try { policy = JSON.parse(policyResult.stdout); } catch (_) { return; }

  for (const { role, member } of bindings) {
    let binding = (policy.bindings || []).find(b => b.role === role);
    if (!binding) {
      binding = { role, members: [] };
      policy.bindings.push(binding);
    }
    if (!binding.members.includes(member)) {
      binding.members.push(member);
    }
  }

  const tmpFile = path.join(os.tmpdir(), `iam-policy-${projectId}.json`);
  await fs.writeJson(tmpFile, policy);
  await run(`gcloud projects set-iam-policy ${projectId} ${tmpFile} --quiet`);
  await fs.remove(tmpFile);
}

/**
 * Copy the service account key to the project directory if it's not already there.
 */
async function ensureServiceAccountKey(projectDir, serviceAccountPath) {
  if (!serviceAccountPath) return { ok: false, error: 'No service account path provided.' };

  const destPath = path.join(projectDir, 'firebase_key.json');
  const srcPath = path.resolve(serviceAccountPath);

  if (!(await fs.pathExists(srcPath))) {
    return { ok: false, error: `Service account file not found: ${srcPath}` };
  }

  if (srcPath !== destPath) {
    await fs.copy(srcPath, destPath);
  }

  return { ok: true };
}

/**
 * Install dependencies in the functions/ folder.
 */
async function installFunctionsDeps(projectDir) {
  const functionsDir = path.join(projectDir, 'functions');
  if (!(await fs.pathExists(functionsDir))) {
    return { ok: true, skipped: true };
  }
  return run('npm install', functionsDir);
}

/**
 * Write .firebaserc so firebase-tools knows which project to target.
 * Without this file, `firebase deploy` asks interactively or fails.
 */
async function writeFirebaseRc(projectDir, firebaseProjectId) {
  const rc = {
    projects: {
      default: firebaseProjectId,
    },
  };
  await fs.outputFile(
    path.join(projectDir, '.firebaserc'),
    JSON.stringify(rc, null, 2) + '\n',
    'utf8'
  );
}

/**
 * Secrets that Cloud Functions define via defineSecret().
 * These must exist in Secret Manager before `firebase deploy` runs,
 * otherwise firebase-tools prompts interactively and hangs in non-TTY environments.
 */
const REQUIRED_SECRETS = ['META_ACCESS_TOKEN', 'META_DATASET_ID', 'REVENUECAT_WEBHOOK_KEY', 'LLM_API_KEY'];

/**
 * Create each required secret if it doesn't exist yet.
 * Uses the real value when provided, otherwise falls back to 'placeholder'.
 * Uses gcloud to avoid firebase-tools interactive prompts.
 * Non-fatal: if a secret can't be created (e.g. API not enabled yet) we continue anyway.
 *
 * @param {string} firebaseProjectId
 * @param {Record<string,string>} [secretValues] - map of secret name → real value (optional)
 */
async function ensureSecretsExist(firebaseProjectId, secretValues = {}) {
  for (const secret of REQUIRED_SECRETS) {
    const check = await run(`gcloud secrets describe ${secret} --project=${firebaseProjectId}`);
    if (!check.ok) {
      const value = secretValues[secret]?.trim() || 'placeholder';
      await run(
        `printf '${value.replace(/'/g, "'\\''")}' | gcloud secrets create ${secret} --data-file=- --replication-policy=automatic --project=${firebaseProjectId}`
      );
    } else if (secretValues[secret]?.trim()) {
      // Secret exists but user provided a real value — update it.
      await run(
        `printf '${secretValues[secret].trim().replace(/'/g, "'\\''")}' | gcloud secrets versions add ${secret} --data-file=- --project=${firebaseProjectId}`
      );
    }
  }
}

/**
 * Get the numeric project number for a GCP project ID.
 */
async function getProjectNumber(firebaseProjectId) {
  const result = await run(
    `gcloud projects describe ${firebaseProjectId} --format='value(projectNumber)'`
  );
  if (!result.ok) return null;
  return result.stdout.trim();
}

/**
 * Pre-create Cloud Functions source buckets and grant compute SA access at bucket level.
 *
 * WHY: Firebase creates gcf-sources-* and gcf-v2-sources-* buckets during the first deploy.
 * Cloud Build immediately tries to read source code from those buckets. Even though we grant
 * roles/storage.objectAdmin at the project level, GCP IAM inheritance to newly-created
 * resources in org projects can take 60-120s to propagate. Cloud Build fails in that window.
 *
 * By pre-creating the buckets ourselves and granting bucket-level permissions directly,
 * the permissions are in place before Cloud Build ever touches the bucket.
 *
 * Non-fatal: if the bucket already exists (repeated deploy) we skip creation and just
 * re-apply the permission grant (idempotent).
 */
async function ensureGcfSourceBuckets(firebaseProjectId, projectNumber, functionsRegion = 'us-central1') {
  const regions = [...new Set(['us-central1', functionsRegion])];
  const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
  const cloudBuildSa = `${projectNumber}@cloudbuild.gserviceaccount.com`;

  for (const region of regions) {
    for (const bucketPrefix of ['gcf-sources', 'gcf-v2-sources']) {
      const bucket = `gs://${bucketPrefix}-${projectNumber}-${region}`;
      // Create the bucket if it doesn't exist yet.
      // Non-fatal: "409 already exists" is fine — we just proceed to the IAM grant below.
      await run(
        `gcloud storage buckets create ${bucket} --location=${region} --project=${firebaseProjectId} --uniform-bucket-level-access`
      );
      // Grant objectAdmin at bucket level — covers the window where project-level IAM
      // hasn't yet propagated to this specific bucket in org projects.
      for (const sa of [computeSa, cloudBuildSa]) {
        await run(
          `gcloud storage buckets add-iam-policy-binding ${bucket} --member=serviceAccount:${sa} --role=roles/storage.objectAdmin --project=${firebaseProjectId}`
        );
      }
    }
  }
}

/**
 * Grant all permissions required for Cloud Functions to build and deploy.
 * Safe to call multiple times — all operations are idempotent.
 * Non-fatal per operation — some resources may not exist yet in all regions.
 *
 * Service accounts that need access:
 *  - compute SA  (PROJECT_NUMBER-compute@developer.gserviceaccount.com)
 *    → Storage Object Viewer on gcf-sources buckets (v1 functions)
 *    → roles/cloudbuild.builds.builder at project level (new GCP projects post Apr 2024
 *      use compute SA as the default Cloud Build SA instead of the legacy cloudBuild SA)
 *    → roles/artifactregistry.writer on gcf-artifacts (v2 functions)
 *  - Cloud Build SA (PROJECT_NUMBER@cloudbuild.gserviceaccount.com)
 *    → roles/cloudbuild.builds.builder at project level (legacy GCP projects)
 *    → roles/artifactregistry.writer on gcf-artifacts (legacy GCP projects)
 */
async function grantBuildPermissions(firebaseProjectId, projectNumber, functionsRegion = 'us-central1') {
  const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
  const cloudBuildSa = `${projectNumber}@cloudbuild.gserviceaccount.com`;
  const gcfSa = `service-${projectNumber}@gcf-admin-robot.iam.gserviceaccount.com`;
  const cloudBuildAgentSa = `service-${projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com`;
  // us-central1 is always needed (auth trigger v1 always deploys there).
  // Add the chosen functions region if different.
  const regions = [...new Set(['us-central1', functionsRegion])];

  // Storage Object Viewer on GCF source buckets:
  //  - gcf-sources-*         → Gen 1 functions (us-central1 auth trigger)
  //  - gcf-v2-sources-*      → Gen 2 functions (Cloud Build fetches source from here)
  // Both naming patterns need the compute SA to have read access.
  for (const region of regions) {
    for (const bucketPrefix of ['gcf-sources', 'gcf-v2-sources']) {
      const bucket = `gs://${bucketPrefix}-${projectNumber}-${region}`;
      await run(
        `gcloud storage buckets add-iam-policy-binding ${bucket} --member=serviceAccount:${computeSa} --role=roles/storage.objectViewer --project=${firebaseProjectId}`
      );
    }
  }

  // Cloud Build builder role — grant to BOTH SAs.
  // New GCP projects (post Apr 2024) use the compute SA as the default Cloud Build SA
  // for v2 Cloud Functions builds. Older projects use the legacy cloudBuild SA.
  // Granting to both ensures compatibility across all project ages.
  for (const sa of [computeSa, cloudBuildSa]) {
    await run(
      `gcloud projects add-iam-policy-binding ${firebaseProjectId} --member=serviceAccount:${sa} --role=roles/cloudbuild.builds.builder`
    );
    await run(
      `gcloud projects add-iam-policy-binding ${firebaseProjectId} --member=serviceAccount:${sa} --role=roles/editor`
    );
  }

  // Cloud Functions Service Agent role — required for Cloud Build to build Cloud Functions.
  // This is the most common cause of "missing permission on the build service account".
  // See: https://cloud.google.com/functions/docs/troubleshooting#build-service-account
  for (const sa of [computeSa, cloudBuildSa]) {
    await run(
      `gcloud projects add-iam-policy-binding ${firebaseProjectId} --member=serviceAccount:${sa} --role=roles/cloudfunctions.serviceAgent`
    );
    await run(
      `gcloud projects add-iam-policy-binding ${firebaseProjectId} --member=serviceAccount:${sa} --role=roles/logging.logWriter`
    );
  }

  // Artifact Registry writer — grant to BOTH SAs for the same reason.
  // The gcf-artifacts repo is created during the first deploy.
  for (const region of regions) {
    for (const sa of [computeSa, cloudBuildSa]) {
      await run(
        `gcloud artifacts repositories add-iam-policy-binding gcf-artifacts --location=${region} --member=serviceAccount:${sa} --role=roles/artifactregistry.writer --project=${firebaseProjectId}`
      );
    }
  }

  // Eventarc Service Agent — required for 2nd gen functions event triggers.
  const eventarcSa = `service-${projectNumber}@gcp-sa-eventarc.iam.gserviceaccount.com`;
  await run(
    `gcloud projects add-iam-policy-binding ${firebaseProjectId} --member=serviceAccount:${eventarcSa} --role=roles/eventarc.serviceAgent`
  );

  // Pub/Sub SA — needed for Eventarc Firestore triggers (v2 functions).
  // Pub/Sub delivers Firestore events to Cloud Run via HTTP push, and must be able to
  // create OIDC tokens for the compute SA to authenticate those push requests.
  const pubsubSa = `service-${projectNumber}@gcp-sa-pubsub.iam.gserviceaccount.com`;
  // Wait for the SA to exist before granting — on brand-new projects the SA may not have
  // propagated yet even after identity create. If the grant runs before the SA exists
  // it fails silently, leaving Eventarc without the token-creator permission, which
  // causes Cloud Run to be created as an HTTPS function (no Eventarc trigger attached).
  await waitForServiceAccount(pubsubSa, firebaseProjectId);
  await run(
    `gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${pubsubSa} --role=roles/iam.serviceAccountTokenCreator --project=${firebaseProjectId}`
  );

  // Service-account-level bindings: allow service agents to impersonate the compute SA.
  // Required for Cloud Functions v2 builds — the GCF and Cloud Build agents must be able
  // to create tokens for the compute SA (which runs Cloud Run services).
  await Promise.all([
    run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${gcfSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
    run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${cloudBuildAgentSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
    run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${cloudBuildSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
  ]);
}

/**
 * Deploy Cloud Functions + Firestore rules + Storage rules.
 *
 * On a brand-new GCP project the first deploy may fail because:
 *  - gcf-sources-PROJECT_NUMBER-REGION (Gen 1) and gcf-v2-sources-* (Gen 2) are created
 *    during the deploy but the compute SA lacks Storage Object Viewer on them.
 *    (Project-level roles — Cloud Build builder, Eventarc service agent — are pre-granted
 *    by runDeploy before this function is called.)
 *
 * Strategy: attempt deploy → if it fails with bucket permission errors, grant bucket
 * roles (buckets now exist after the failed attempt), wait 4 min, retry.
 */
async function deployFirebase(projectDir, firebaseProjectId, { onProgress, functionsRegion = 'us-central1' } = {}) {
  const cmd = `firebase deploy --only functions,firestore:rules,firestore:indexes,storage --project=${firebaseProjectId} --force`;

  // Normalise: firebase-tools sometimes exits 0 but prints errors in stdout.
  // Treat those as failures so the retry logic can act on them.
  function normalise(r) {
    if (!r.ok) return r;
    const outLow = (r.stdout + r.stderr).toLowerCase();
    const hasError =
      outLow.includes('changing from an https function to a background triggered') ||
      outLow.includes('changing from a background triggered function to an https') ||
      outLow.includes('missing permission on the build service account') ||
      outLow.includes('build failed with status: failure') ||
      outLow.includes('functions deploy had errors');
    if (!hasError) return r;
    return { ok: false, error: 'Deploy errors in output (exit code 0)', stderr: r.stderr, stdout: r.stdout };
  }

  function hasTriggerConflict(r) {
    const t = (r.error + r.stderr + r.stdout).toLowerCase();
    return (
      t.includes('changing from an https function to a background triggered') ||
      t.includes('changing from a background triggered function to an https')
    );
  }

  function hasPermissionError(r) {
    const t = (r.error + r.stderr + r.stdout).toLowerCase();
    return (
      t.includes('storage object viewer') ||
      t.includes('eventarc service agent') ||
      t.includes('first time using 2nd gen') ||
      t.includes('retry the deployment in a few minutes') ||
      t.includes('missing permission on the build service account') ||
      t.includes('build service account') ||
      t.includes('gcf-sources') ||
      t.includes('gcf-v2-sources') ||
      t.includes('permission_denied') ||
      t.includes('caller does not have permission') ||
      t.includes('serviceaccounts.actas') ||
      t.includes('iam.serviceaccounts') ||
      t.includes('cloud run admin') ||
      t.includes('roles/run') ||
      t.includes('artifact registry') ||
      t.includes('"code":403') || t.includes('"code": 403') ||
      t.includes('failed to build') ||
      t.includes('build failed') ||
      t.includes('api is not enabled') ||
      t.includes('not enabled on project') ||
      t.includes('has not been used in project') ||
      t.includes('api_not_activated') ||
      t.includes('enable it by visiting') ||
      t.includes('cloudfunctions.googleapis.com') ||
      t.includes('cloudbuild.googleapis.com') ||
      t.includes('run.googleapis.com') ||
      // GCP org-policy violations block IAM grants and Storage access in org projects.
      // These errors appear when the org has constraints like iam.allowedPolicyMemberDomains.
      t.includes('policy_violated') ||
      t.includes('violates the constraint') ||
      t.includes('constraints/iam.allowed') ||
      t.includes('orgpolicy') ||
      t.includes('org policy') ||
      t.includes('organization policy') ||
      t.includes('denied by organization policy')
    );
  }

  async function deleteBrokenFunctions(r) {
    const text = r.error + r.stderr + r.stdout;
    // Delete the explicitly mentioned broken function(s) from the error output.
    const pattern = /(?:Error:\s*)?\[([^\]]+)\(([^)]+)\)\][^\n]*changing from/gi;
    let m;
    while ((m = pattern.exec(text)) !== null) {
      const funcName = m[1].trim();
      const region = m[2].trim();
      const fbDelete = await run(
        `firebase functions:delete ${funcName} --region=${region} --project=${firebaseProjectId} --force`,
        projectDir
      );
      if (!fbDelete.ok) {
        // Fallback: delete the underlying Cloud Run service directly.
        // Cloud Run service IDs are the Firebase function name in lowercase.
        const serviceId = funcName.toLowerCase();
        await run(
          `gcloud run services delete ${serviceId} --region=${region} --project=${firebaseProjectId} --quiet`
        );
      }
    }

    // Full sweep: find ALL Firebase Cloud Run services that have no Eventarc trigger.
    //
    // When Eventarc IAM isn't ready on the first deploy, EVERY Eventarc-triggered function
    // gets created as a plain HTTPS Cloud Run service (no trigger attached). firebase deploy
    // reports only ONE conflict per run and aborts — so without this sweep each retry can
    // only fix one function, needing N retries for N broken functions and exhausting
    // MAX_ATTEMPTS before all functions are fixed.
    //
    // Strategy: list all Cloud Run services deployed by Firebase in each region, list all
    // Eventarc triggers in that region, then delete any Firebase service that has NO trigger
    // pointing to it. HTTP / callable functions that are correctly deployed as HTTPS will
    // also be deleted here, but firebase deploy will recreate them correctly on the next
    // attempt without conflict (they don't have a trigger type mismatch).
    const sweepRegions = [...new Set(['us-central1', functionsRegion])];
    for (const region of sweepRegions) {
      // Find which Cloud Run services already have Eventarc triggers.
      const triggersResult = await run(
        `gcloud eventarc triggers list --location=${region} --project=${firebaseProjectId} --format=json`
      );
      const servicesWithTriggers = new Set();
      if (triggersResult.ok && triggersResult.stdout.trim().startsWith('[')) {
        try {
          for (const t of JSON.parse(triggersResult.stdout)) {
            const svc = t.destination?.cloudRun?.service;
            if (svc) servicesWithTriggers.add(svc);
          }
        } catch (_) {}
      }

      // List only Cloud Run services deployed by firebase-tools (label set by Firebase CLI).
      const listResult = await run(
        `gcloud run services list --region=${region} --project=${firebaseProjectId} --format='value(metadata.name)' --filter="labels.'firebase-functions-codebase'=default"`
      );
      if (!listResult.ok || !listResult.stdout.trim()) continue;

      for (const service of listResult.stdout.trim().split('\n').filter(Boolean)) {
        if (!servicesWithTriggers.has(service)) {
          // No Eventarc trigger points to this service — it's stuck in HTTPS state.
          // Delete it so the next deploy can recreate it correctly (with trigger or as HTTP).
          await run(
            `gcloud run services delete ${service} --region=${region} --project=${firebaseProjectId} --quiet`
          );
        }
      }
    }

    // Allow GCP to fully propagate all deletions before the caller retries.
    // Cloud Run service deletion can take 30–45s in org projects — too short a wait
    // causes the next attempt to still see the old services and hit the conflict again.
    await sleep(45 * 1000);
  }

  // Build a human-readable error message with the manual gcloud commands the user
  // (or their GCP admin) needs to run to fix the IAM configuration.
  function buildManualFixMessage(pNum, pId, reason) {
    const computeSa  = `${pNum}-compute@developer.gserviceaccount.com`;
    const eventarcSa = `service-${pNum}@gcp-sa-eventarc.iam.gserviceaccount.com`;
    return (
      `${reason}\n\n` +
      `Execute os comandos abaixo manualmente e rode "kasy deploy" novamente:\n\n` +
      `  # 1. Permissão de Storage para Cloud Build (authTriggers)\n` +
      `  gcloud storage buckets add-iam-policy-binding gs://gcf-sources-${pNum}-us-central1 \\\n` +
      `    --member="serviceAccount:${computeSa}" \\\n` +
      `    --role="roles/storage.objectAdmin" \\\n` +
      `    --project=${pId}\n\n` +
      `  # 2. Permissão do Eventarc Service Agent (notificationsTriggers)\n` +
      `  gcloud projects add-iam-policy-binding ${pId} \\\n` +
      `    --member="serviceAccount:${eventarcSa}" \\\n` +
      `    --role="roles/eventarc.serviceAgent"\n\n` +
      `  # 3. Editor no projeto (conta de serviço do Cloud Build)\n` +
      `  gcloud projects add-iam-policy-binding ${pId} \\\n` +
      `    --member="serviceAccount:${computeSa}" \\\n` +
      `    --role="roles/editor"`
    );
  }

  // Retry loop.  Strategy:
  //   - Attempt 1: deploy → if fails, grant IAM + verify IAM (up to 60s poll).
  //     · If grants NOT in IAM after 60s → org policy blocking grants → stop, show manual fix.
  //     · If grants confirmed → retry.
  //   - Attempt 2+: if same pure permission error with grants confirmed → VPC SC or deeper
  //     org restriction — retrying won't help.  Show manual fix and stop immediately.
  //   - Trigger conflict on any attempt → always delete broken services and retry once more.
  const MAX_ATTEMPTS = 3; // fail fast — each attempt takes 3-5 min; 3 is enough
  let projectNumber = null;
  let lastResult = null;
  let iamConfirmed = false; // true once grants appeared in IAM policy

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    lastResult = normalise(await run(cmd, projectDir));
    if (lastResult.ok) return lastResult;

    const triggerConflict = hasTriggerConflict(lastResult);
    const permError = hasPermissionError(lastResult);

    if (!triggerConflict && !permError) {
      break; // Unknown error — stop retrying.
    }

    if (!projectNumber) projectNumber = await getProjectNumber(firebaseProjectId);

    // If we still can't get the project number, bail out with a clear error.
    if (!projectNumber) {
      lastResult = {
        ok: false,
        error:
          `Não foi possível obter o número do projeto "${firebaseProjectId}" via gcloud.\n` +
          `Verifique se o projeto existe e se você tem acesso:\n` +
          `  gcloud projects describe ${firebaseProjectId}`,
        stdout: '', stderr: '',
      };
      break;
    }

    // If grants were already confirmed in IAM (from a previous attempt) but the SAME
    // permission error recurs without a trigger conflict to clean up, retrying won't fix it.
    // The issue is beyond IAM (VPC Service Controls, org policy on API calls, etc.).
    if (iamConfirmed && permError && !triggerConflict) {
      lastResult = {
        ok: false,
        error: buildManualFixMessage(
          projectNumber, firebaseProjectId,
          'As permissões foram concedidas e confirmadas no IAM, mas o Cloud Build ainda está com acesso negado.\n' +
          'Isso indica VPC Service Controls ou uma Org Policy bloqueando o acesso a nível de rede.\n' +
          'Configure as permissões abaixo manualmente no console do GCP e tente novamente:'
        ),
        stdout: '', stderr: '',
      };
      break;
    }

    // Always sweep for broken Cloud Run services (HTTPS without Eventarc trigger).
    // Previously this only ran when triggerConflict appeared in the error output,
    // but when a storage permission error causes Cloud Build to fail FIRST, the
    // trigger conflict error is masked — Firebase never reaches the deploy phase
    // where it would report the conflict.  Running the sweep unconditionally ensures
    // leftover broken services from previous failed attempts are always cleaned up.
    if (onProgress) onProgress('deploy-fixing-triggers');
    await deleteBrokenFunctions(lastResult);

    // Re-grant all required IAM roles.
    if (onProgress) onProgress('deploy-granting-iam');
    if (projectNumber) await grantBuildPermissions(firebaseProjectId, projectNumber, functionsRegion);

    // Verify the critical grants actually appeared in IAM (up to 60s poll).
    // If they never appear, the grant command itself was blocked — stop immediately.
    const eventarcOk = projectNumber
      ? await waitForEventarcIam(firebaseProjectId, projectNumber, 60 * 1000)
      : true;
    const storageOk = projectNumber
      ? await verifyStorageGrant(projectNumber, 'us-central1')
      : true;

    if (!eventarcOk || !storageOk) {
      lastResult = {
        ok: false,
        error: buildManualFixMessage(
          projectNumber, firebaseProjectId,
          'Tentamos conceder as permissões automaticamente, mas elas não aparecem no IAM após 60s.\n' +
          'Uma Org Policy está bloqueando os grants automáticos.\n' +
          'Configure manualmente e rode "kasy deploy" novamente:'
        ),
        stdout: '', stderr: '',
      };
      break;
    }

    iamConfirmed = true;
    if (onProgress) onProgress('deploy-retry-wait');
    await sleep(30 * 1000);
  }

  return lastResult;
}

/**
 * Create an App Engine app in the project.
 * Required for Cloud Functions v1 (auth triggers always deploy to us-central1 via v1).
 * Firebase uses App Engine infrastructure to store function source code for v1 functions.
 * Safe to call multiple times — if the app already exists, returns ok: true.
 * Non-fatal: if creation fails for any reason other than "already exists", we continue.
 */
async function ensureAppEngineApp(firebaseProjectId) {
  // App Engine regions differ from GCP regions. The standard for Firebase projects
  // is us-central (maps to us-central1), which is where v1 auth triggers deploy.
  const result = await run(`gcloud app create --project=${firebaseProjectId} --region=us-central`);
  if (result.ok) return { ok: true, existed: false };
  const msg = (result.stderr || result.error || '').toLowerCase();
  if (msg.includes('already exists') || msg.includes('already contains') || msg.includes('already been created')) {
    return { ok: true, existed: true };
  }
  // Non-fatal — return error info but don't abort the deploy flow.
  return { ok: false, error: result.stderr || result.error };
}

/**
 * Check whether the currently authenticated gcloud account can grant IAM roles
 * on the project.  Requires roles/owner or roles/resourcemanager.projectIamAdmin.
 *
 * Returns { ok: true } if the account has permission, or
 *         { ok: false, account, missing } with a descriptive error to show the user.
 */
async function checkIamGrantPermission(projectId) {
  // Get active account.
  const authResult = await run('gcloud auth list --filter="status=ACTIVE" --format="value(account)" --limit=1');
  const account = authResult.ok ? authResult.stdout.trim() : null;

  // Read the project IAM policy.
  const policyResult = await run(`gcloud projects get-iam-policy ${projectId} --format=json`);
  if (!policyResult.ok) {
    // Can't even read the policy — likely no access at all.
    return { ok: false, account, missing: 'roles/viewer (sem acesso ao projeto)' };
  }

  let policy;
  try { policy = JSON.parse(policyResult.stdout); } catch (_) {
    return { ok: false, account, missing: 'policy ilegível' };
  }

  const rolesWithIam = new Set([
    'roles/owner',
    'roles/resourcemanager.projectIamAdmin',
    // Editor alone does NOT allow granting roles — owner/iam.admin required.
  ]);

  const accountMember = account ? `user:${account}` : null;
  for (const binding of (policy.bindings || [])) {
    if (!rolesWithIam.has(binding.role)) continue;
    if (!accountMember) continue;
    if (binding.members && binding.members.includes(accountMember)) return { ok: true, account };
  }

  return {
    ok: false,
    account,
    missing: 'roles/owner ou roles/resourcemanager.projectIamAdmin',
  };
}

/**
 * Full deploy sequence:
 *   1. Write .firebaserc (links project)
 *   2. Enable Secret Manager API + ensure required secrets exist
 *   3. Create App Engine app (required for Cloud Functions v1 auth trigger)
 *   4. Pre-grant project-level IAM roles (Cloud Build builder, Eventarc service agent)
 *      so the first deploy attempt doesn't fail due to missing project-level permissions.
 *      Bucket-level grants still happen post-first-attempt (buckets don't exist yet).
 *   5. npm install in functions/
 *   6. firebase deploy — on first-project permission errors: grants bucket roles and retries
 */
async function runDeploy(projectDir, serviceAccountPath, firebaseProjectId, { onProgress, functionsRegion = 'us-central1', secretValues = {} } = {}) {
  const steps = [];

  // Verify the current gcloud account can actually grant IAM roles before attempting
  // the full setup.  All add-iam-policy-binding commands fail silently when this
  // permission is missing, leaving the project in a broken half-configured state.
  const iamCheck = await checkIamGrantPermission(firebaseProjectId);
  if (!iamCheck.ok) {
    const who = iamCheck.account ? `Conta atual: ${iamCheck.account}` : 'Conta gcloud não detectada';
    throw new Error(
      `Permissão insuficiente para configurar IAM no projeto "${firebaseProjectId}".\n` +
      `${who}\n` +
      `Role necessária: ${iamCheck.missing}\n\n` +
      `Peça ao dono do projeto para conceder essa role à sua conta:\n` +
      `  gcloud projects add-iam-policy-binding ${firebaseProjectId} \\\n` +
      `    --member="user:${iamCheck.account || 'SEU_EMAIL'}" \\\n` +
      `    --role="roles/owner"`
    );
  }

  await writeFirebaseRc(projectDir, firebaseProjectId);

  // Enable all required APIs in one batch command — GCP provisions them in parallel,
  // which is faster than enabling one by one. appengine is needed for Cloud Functions v1
  // upload URLs. firebaseextensions is checked by firebase-tools during deploy.
  await run(
    `gcloud services enable ` +
    `secretmanager.googleapis.com ` +
    `appengine.googleapis.com ` +
    `cloudfunctions.googleapis.com ` +
    `cloudbuild.googleapis.com ` +
    `run.googleapis.com ` +
    `artifactregistry.googleapis.com ` +
    `eventarc.googleapis.com ` +
    `pubsub.googleapis.com ` +
    `firebaseextensions.googleapis.com ` +
    `--project=${firebaseProjectId}`
  );

  // Create App Engine app — required for Cloud Functions v1 (auth triggers use v1 and
  // always deploy to us-central1). Firebase needs App Engine to generate upload URLs
  // for v1 function source code. Non-fatal if it fails.
  await ensureAppEngineApp(firebaseProjectId);

  // Install functions dependencies early so the project is ready for manual deploy
  // even if waitForApis or IAM grants fail later.
  const npmInstall = await installFunctionsDeps(projectDir);
  steps.push({
    name: 'npm install (functions)',
    ok: npmInstall.ok,
    skipped: npmInstall.skipped,
    detail: npmInstall.ok ? null : npmInstall.error,
  });
  if (!npmInstall.ok && !npmInstall.skipped) return steps;

  // Wait for APIs — non-fatal. In org GCP projects propagation can exceed 10 min;
  // if the check times out we proceed anyway. deployFirebase has retry logic that
  // handles "API not enabled" errors (added to isPermissionError below), so the
  // deploy attempt will fail gracefully and retry after a wait if needed.
  // secretmanager.googleapis.com is included here because ensureSecretsExist runs
  // immediately after — if the API isn't ready yet, secret creation fails silently
  // and firebase deploy hangs waiting for secrets that don't exist.
  try {
    await waitForApis(firebaseProjectId, [
      'secretmanager.googleapis.com',
      'cloudfunctions.googleapis.com',
      'cloudbuild.googleapis.com',
      'run.googleapis.com',
      'artifactregistry.googleapis.com',
      'eventarc.googleapis.com',
      'pubsub.googleapis.com',
    ], { onProgress, timeoutMs: 60 * 1000 });
  } catch (_) {
    // APIs not confirmed ready — proceed anyway. If firebase deploy fails due to
    // APIs still propagating it will be caught by the permission-error retry path.
    if (onProgress) onProgress('wait-apis-timeout');
  }

  // Create secrets only after secretmanager API is confirmed ready (or timed out).
  // Running this before waitForApis caused silent failures when the API was still
  // propagating — the deploy would then fail because the secrets didn't exist yet.
  await ensureSecretsExist(firebaseProjectId, secretValues);

  // Quick IAM readiness check: if the two critical grants (Eventarc + Storage) are
  // already confirmed in IAM, skip the entire grant block below.  On re-deploy this
  // check returns in ~1s and saves 30-60s of redundant gcloud calls.
  // On first deploy both checks fail → full grant block runs as before.
  const projectNumber = await getProjectNumber(firebaseProjectId);
  if (!projectNumber) {
    throw new Error(
      `Não foi possível obter o número do projeto "${firebaseProjectId}" via gcloud.\n` +
      `Verifique se o projeto existe e se você tem acesso:\n` +
      `  gcloud projects describe ${firebaseProjectId}`
    );
  }

  const iamReady =
    await waitForEventarcIam(firebaseProjectId, projectNumber, 3 * 1000) &&
    await verifyStorageGrant(projectNumber, 'us-central1') &&
    await verifyAppEngineSaGrant(firebaseProjectId);
  if (iamReady) {
    // Permissions already in place — go straight to deploy.
    if (onProgress) onProgress('iam-ready-skipping-grants');
  } else {
    await runIamSetup(firebaseProjectId, projectNumber, functionsRegion);
  }

  const deploy = await deployFirebase(projectDir, firebaseProjectId, { onProgress, functionsRegion });
  steps.push({
    name: 'firebase deploy (Functions + Firestore rules + Indexes + Storage)',
    ok: deploy.ok,
    detail: deploy.ok ? null : [deploy.error, deploy.stderr, deploy.stdout].filter(Boolean).join('\n').trim(),
  });

  return steps;
}

/**
 * Full IAM + bucket setup for first deploy (or when permissions are missing).
 * Extracted so runDeploy can skip this on re-deploy when IAM is already ready.
 */
async function runIamSetup(firebaseProjectId, projectNumber, functionsRegion = 'us-central1') {
  const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
    const cloudBuildSa = `${projectNumber}@cloudbuild.gserviceaccount.com`;
    const gcfSa = `service-${projectNumber}@gcf-admin-robot.iam.gserviceaccount.com`;
    const cloudBuildAgentSa = `service-${projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com`;
    const eventarcSa = `service-${projectNumber}@gcp-sa-eventarc.iam.gserviceaccount.com`;
    // Cloud Run service agent — needed for Gen 2 functions (Cloud Run-based).
    // Must be able to impersonate the compute SA to run Cloud Run services.
    const cloudRunSa = `service-${projectNumber}@serverless-robot-prod.iam.gserviceaccount.com`;

    // Force-create service agents so their default roles are provisioned synchronously.
    // On brand-new projects these agents may not exist yet even after enableApis.
    // gcloud beta services identity create is idempotent (safe to re-run).
    await Promise.all([
      run(`gcloud beta services identity create --service=cloudfunctions.googleapis.com --project=${firebaseProjectId}`),
      run(`gcloud beta services identity create --service=cloudbuild.googleapis.com --project=${firebaseProjectId}`),
      run(`gcloud beta services identity create --service=eventarc.googleapis.com --project=${firebaseProjectId}`),
      run(`gcloud beta services identity create --service=run.googleapis.com --project=${firebaseProjectId}`),
      run(`gcloud beta services identity create --service=pubsub.googleapis.com --project=${firebaseProjectId}`),
    ]);

    // Wait for all service accounts to be visible via IAM before granting roles.
    // On brand-new projects these SAs are created by enabling APIs / identity create but may
    // take a minute to propagate — IAM policy bindings silently fail if the SA doesn't exist yet.
    // All SAs that receive SA-level bindings below must be polled here first.
    const pubsubSa = `service-${projectNumber}@gcp-sa-pubsub.iam.gserviceaccount.com`;
    await Promise.all([
      waitForServiceAccount(computeSa, firebaseProjectId),
      waitForServiceAccount(cloudBuildSa, firebaseProjectId),
      waitForServiceAccount(gcfSa, firebaseProjectId),
      waitForServiceAccount(cloudBuildAgentSa, firebaseProjectId),
      waitForServiceAccount(cloudRunSa, firebaseProjectId),
      // eventarcSa recebe role abaixo — precisa existir antes do grant.
      // Em projetos novos pode demorar até 30s após identity create.
      waitForServiceAccount(eventarcSa, firebaseProjectId),
      // pubsubSa é necessário para triggers Firestore v2 (Eventarc usa Pub/Sub para entregar eventos).
      waitForServiceAccount(pubsubSa, firebaseProjectId),
    ]);

    // Apply all project-level IAM bindings in a single atomic set-iam-policy call.
    // Using parallel add-iam-policy-binding calls causes a race condition: each command
    // reads the current policy, adds its binding, and writes back — later writes overwrite
    // earlier ones, leaving some grants missing. set-iam-policy is one atomic write.
    await applyProjectIamBindings(firebaseProjectId, [
      { role: 'roles/cloudbuild.builds.builder',   member: `serviceAccount:${computeSa}` },
      { role: 'roles/cloudbuild.builds.builder',   member: `serviceAccount:${cloudBuildSa}` },
      { role: 'roles/eventarc.serviceAgent',       member: `serviceAccount:${eventarcSa}` },
      { role: 'roles/cloudfunctions.serviceAgent', member: `serviceAccount:${computeSa}` },
      { role: 'roles/cloudfunctions.serviceAgent', member: `serviceAccount:${cloudBuildSa}` },
      { role: 'roles/run.serviceAgent',            member: `serviceAccount:${cloudRunSa}` },
      { role: 'roles/logging.logWriter',           member: `serviceAccount:${computeSa}` },
      { role: 'roles/logging.logWriter',           member: `serviceAccount:${cloudBuildSa}` },
      { role: 'roles/storage.objectAdmin',         member: `serviceAccount:${computeSa}` },
      { role: 'roles/storage.objectAdmin',         member: `serviceAccount:${cloudBuildSa}` },
      { role: 'roles/artifactregistry.writer',     member: `serviceAccount:${computeSa}` },
      { role: 'roles/artifactregistry.writer',     member: `serviceAccount:${cloudBuildSa}` },
      { role: 'roles/eventarc.eventReceiver',      member: `serviceAccount:${computeSa}` },
      { role: 'roles/firebase.admin',              member: `serviceAccount:${computeSa}` },
      { role: 'roles/datastore.user',              member: `serviceAccount:${computeSa}` },
    ]);

    // Service-account-level bindings: allow service agents to "use" the compute SA.
    // These are SA-level bindings (not project-level) so they don't conflict with the
    // project policy above and can safely run in parallel.
    await Promise.all([
      run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${gcfSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
      run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${cloudBuildAgentSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
      run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${cloudBuildSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
      run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${cloudRunSa} --role=roles/iam.serviceAccountUser --project=${firebaseProjectId}`),
      run(`gcloud iam service-accounts add-iam-policy-binding ${computeSa} --member=serviceAccount:${pubsubSa} --role=roles/iam.serviceAccountTokenCreator --project=${firebaseProjectId}`),
    ]);

    // Grant Firestore write access to the App Engine default service account.
    // Cloud Functions v1 (auth triggers) run as PROJECT_ID@appspot.gserviceaccount.com.
    // On new GCP projects (post April 2024) this SA no longer auto-receives Editor role,
    // causing PERMISSION_DENIED when the function writes to Firestore via Admin SDK.
    // Must wait for the SA to exist — it's created by App Engine app provisioning and can
    // take 30-60s to propagate after the App Engine app is first created.
    const appEngineSa = `${firebaseProjectId}@appspot.gserviceaccount.com`;
    await waitForServiceAccount(appEngineSa, firebaseProjectId);
    // Also use atomic set-iam-policy for these three grants to avoid the same race condition.
    await applyProjectIamBindings(firebaseProjectId, [
      { role: 'roles/datastore.user',   member: `serviceAccount:${appEngineSa}` },
      { role: 'roles/firebase.admin',   member: `serviceAccount:${appEngineSa}` },
      { role: 'roles/logging.logWriter', member: `serviceAccount:${appEngineSa}` },
    ]);

  // Pre-create gcf-sources and gcf-v2-sources buckets and grant bucket-level permissions.
  // In org projects, project-level IAM inheritance to newly created GCS buckets can take
  // 60-120s. Cloud Build fails in that window even though project-level storage.objectAdmin
  // was already granted. Bucket-level grants are immediate and avoid the race condition.
  await ensureGcfSourceBuckets(firebaseProjectId, projectNumber, functionsRegion);

  // Wait for Eventarc binding to propagate before deploy (up to 60s; instant on re-deploy).
  await waitForEventarcIam(firebaseProjectId, projectNumber);
}

module.exports = { runDeploy, ensureServiceAccountKey };
