use anyhow::{bail, Context, Result};
use clap::ValueEnum;
use fission_command_core::{DistributionProvider, FissionProject, Target};
use fission_credentials as credentials;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};

mod docker_registry;
mod files;
mod github_releases;
mod package;
mod static_hosts;
mod stores;

const ARTIFACT_MANIFEST: &str = "artifact-manifest.json";

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum PackageFormat {
    Aab,
    Apk,
    App,
    #[value(name = "docker-image")]
    DockerImage,
    Exe,
    Ipa,
    Msi,
    Msix,
    Pkg,
    Run,
    Static,
}

impl PackageFormat {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Aab => "aab",
            Self::Apk => "apk",
            Self::App => "app",
            Self::DockerImage => "docker-image",
            Self::Exe => "exe",
            Self::Ipa => "ipa",
            Self::Msi => "msi",
            Self::Msix => "msix",
            Self::Pkg => "pkg",
            Self::Run => "run",
            Self::Static => "static",
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum DistributeAction {
    Setup,
    Publish,
    Status,
    Promote,
    Rollback,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ReadinessKind {
    Package,
    Distribute,
    Release,
}

#[derive(Clone, Debug)]
pub struct PackageOptions {
    pub project_dir: PathBuf,
    pub target: Target,
    pub format: PackageFormat,
    pub release: bool,
    pub json: bool,
}

#[derive(Clone, Debug)]
pub struct DistributeOptions {
    pub project_dir: PathBuf,
    pub provider: DistributionProvider,
    pub action: DistributeAction,
    pub artifact: Option<PathBuf>,
    pub site: String,
    pub deploy: Option<String>,
    pub track: Option<String>,
    pub dry_run: bool,
    pub yes: bool,
    pub json: bool,
}

#[derive(Clone, Debug)]
pub struct ReadinessOptions {
    pub project_dir: PathBuf,
    pub kind: ReadinessKind,
    pub target: Option<Target>,
    pub format: Option<PackageFormat>,
    pub provider: Option<DistributionProvider>,
    pub artifact: Option<PathBuf>,
    pub site: String,
    pub track: Option<String>,
    pub json: bool,
}

#[derive(Debug, Serialize, Deserialize)]
struct ArtifactManifest {
    schema_version: u32,
    created_at_unix_seconds: u64,
    project: ArtifactProject,
    target: String,
    format: String,
    profile: String,
    root_dir: String,
    artifacts: Vec<ArtifactFile>,
    validation: ArtifactValidation,
}

#[derive(Debug, Serialize, Deserialize)]
struct ArtifactProject {
    app_id: String,
    name: String,
    version: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ArtifactFile {
    kind: String,
    purpose: Option<String>,
    platform: Option<String>,
    upload_provider: Option<String>,
    path: String,
    relative_path: String,
    sha256: String,
    size_bytes: u64,
    mime_type: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct ArtifactValidation {
    state: String,
    checks: Vec<ReadinessCheck>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct ReadinessCheck {
    id: String,
    severity: CheckSeverity,
    status: CheckStatus,
    summary: String,
    details: Option<String>,
    remediation: Vec<String>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CheckSeverity {
    Error,
    Warning,
    Info,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum CheckStatus {
    Passed,
    Missing,
    Failed,
    Warning,
    Skipped,
}

#[derive(Debug, Serialize)]
struct ReadinessReport {
    project_dir: String,
    target: Option<String>,
    format: Option<String>,
    provider: Option<String>,
    site: Option<String>,
    status: String,
    checks: Vec<ReadinessCheck>,
}

#[derive(Debug, Serialize)]
struct DistributionReceipt {
    schema_version: u32,
    created_at_unix_seconds: u64,
    provider: String,
    site: String,
    action: String,
    artifact_manifest: Option<String>,
    deployment_id: Option<String>,
    canonical_url: Option<String>,
    preview_url: Option<String>,
    custom_domain: Option<String>,
    status: String,
    stdout: Option<String>,
    stderr: Option<String>,
    manual_follow_up: Vec<String>,
}

#[derive(Debug, Deserialize, Default)]
struct PublishManifest {
    site: Option<SiteManifest>,
    distribution: Option<DistributionManifest>,
}

#[derive(Debug, Deserialize, Default)]
struct SiteManifest {
    entry: Option<String>,
    out_dir: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
struct DistributionManifest {
    #[serde(default)]
    s3: BTreeMap<String, S3Config>,
    #[serde(default)]
    google_drive: BTreeMap<String, GoogleDriveConfig>,
    #[serde(default)]
    onedrive: BTreeMap<String, OneDriveConfig>,
    #[serde(default)]
    dropbox: BTreeMap<String, DropboxConfig>,
    play_store: Option<PlayStoreConfig>,
    app_store: Option<AppStoreConfig>,
    microsoft_store: Option<MicrosoftStoreConfig>,
    #[serde(default)]
    github_pages: BTreeMap<String, GithubPagesConfig>,
    #[serde(default)]
    github_releases: BTreeMap<String, GithubReleasesConfig>,
    #[serde(default)]
    cloudflare_pages: BTreeMap<String, CloudflarePagesConfig>,
    #[serde(default)]
    docker_registry: BTreeMap<String, DockerRegistryConfig>,
    #[serde(default)]
    netlify: BTreeMap<String, NetlifyConfig>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct S3Config {
    endpoint: Option<String>,
    region: Option<String>,
    bucket: Option<String>,
    prefix: Option<String>,
    profile: Option<String>,
    path_style: Option<bool>,
    visibility: Option<String>,
    presign_ttl_seconds: Option<u64>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct GoogleDriveConfig {
    folder_id: Option<String>,
    name_prefix: Option<String>,
    share: Option<bool>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct OneDriveConfig {
    root: Option<String>,
    path_prefix: Option<String>,
    conflict_behavior: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct DropboxConfig {
    path_prefix: Option<String>,
    mode: Option<String>,
    autorename: Option<bool>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct PlayStoreConfig {
    package_name: Option<String>,
    default_track: Option<String>,
    service_account: Option<String>,
    release_status: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct AppStoreConfig {
    app_id: Option<String>,
    bundle_id: Option<String>,
    issuer_id: Option<String>,
    key_id: Option<String>,
    api_key_path: Option<String>,
    default_track: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct MicrosoftStoreConfig {
    product_id: Option<String>,
    package_identity_name: Option<String>,
    tenant_id: Option<String>,
    client_id: Option<String>,
    seller_id: Option<String>,
    package_url: Option<String>,
    package_type: Option<String>,
    flight_id: Option<String>,
    package_rollout_percentage: Option<u8>,
    msstore_project: Option<String>,
    msstore_reconfigure: Option<bool>,
    languages: Option<Vec<String>>,
    architectures: Option<Vec<String>>,
    is_silent_install: Option<bool>,
    installer_parameters: Option<String>,
    generic_doc_url: Option<String>,
    submit: Option<bool>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct GithubPagesConfig {
    owner: Option<String>,
    repo: Option<String>,
    mode: Option<String>,
    source: Option<String>,
    source_branch: Option<String>,
    source_path: Option<String>,
    site_kind: Option<String>,
    base_path: Option<String>,
    custom_domain: Option<String>,
    enforce_https: Option<bool>,
    remote: Option<String>,
    production_branch: Option<String>,
    workflow: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct GithubReleasesConfig {
    owner: Option<String>,
    repo: Option<String>,
    tag: Option<String>,
    name: Option<String>,
    target_commitish: Option<String>,
    notes: Option<String>,
    notes_file: Option<String>,
    draft: Option<bool>,
    prerelease: Option<bool>,
    make_latest: Option<String>,
    replace_assets: Option<bool>,
    upload_artifact_manifest: Option<bool>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct CloudflarePagesConfig {
    account_id: Option<String>,
    project_name: Option<String>,
    environment: Option<String>,
    custom_domain: Option<String>,
    base_path: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct NetlifyConfig {
    site_id: Option<String>,
    team_slug: Option<String>,
    production: Option<bool>,
    custom_domain: Option<String>,
    base_path: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Default)]
struct DockerRegistryConfig {
    tags: Option<Vec<String>>,
}

pub fn package(options: PackageOptions) -> Result<()> {
    let manifest = package::package_artifact(&options)?;
    if options.json {
        println!("{}", serde_json::to_string_pretty(&manifest)?);
    } else {
        println!(
            "Packaged {} {} artifact into {}",
            manifest.target, manifest.format, manifest.root_dir
        );
        println!("{} files", manifest.artifacts.len());
        println!(
            "{}",
            Path::new(&manifest.root_dir)
                .join(ARTIFACT_MANIFEST)
                .display()
        );
    }
    Ok(())
}

pub fn distribute(options: DistributeOptions) -> Result<()> {
    let config = load_publish_manifest(&options.project_dir)?;
    match options.action {
        DistributeAction::Setup => setup_provider(&options, &config),
        DistributeAction::Status => provider_status(&options, &config),
        DistributeAction::Promote | DistributeAction::Rollback => {
            provider_lifecycle(&options, &config)
        }
        DistributeAction::Publish => publish_artifact(&options, &config),
    }
}

pub fn readiness(options: ReadinessOptions) -> Result<()> {
    let checks = match options.kind {
        ReadinessKind::Package => {
            readiness_package(&options.project_dir, options.target, options.format)
        }
        ReadinessKind::Release => {
            let config = load_publish_manifest(&options.project_dir)?;
            let mut checks =
                readiness_package(&options.project_dir, options.target, options.format)?;
            let provider = options
                .provider
                .context("readiness release requires --provider")?;
            checks.extend(readiness_distribute(
                &options.project_dir,
                provider,
                &options.site,
                options.track.as_deref(),
                options.artifact.as_deref(),
                &config,
            )?);
            Ok(checks)
        }
        ReadinessKind::Distribute => {
            let config = load_publish_manifest(&options.project_dir)?;
            let provider = options
                .provider
                .context("readiness distribute requires --provider")?;
            let artifact = options.artifact.as_deref();
            readiness_distribute(
                &options.project_dir,
                provider,
                &options.site,
                options.track.as_deref(),
                artifact,
                &config,
            )
        }
    }?;
    let report = ReadinessReport {
        project_dir: options.project_dir.display().to_string(),
        target: options.target.map(|target| target.as_str().to_string()),
        format: options.format.map(|format| format.as_str().to_string()),
        provider: options
            .provider
            .map(|provider| provider.as_str().to_string()),
        site: matches!(
            options.kind,
            ReadinessKind::Distribute | ReadinessKind::Release
        )
        .then(|| options.site.clone()),
        status: report_status(&checks).to_string(),
        checks,
    };
    if options.json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_readiness_report(&report);
        if report.status == "blocked" {
            bail!("readiness checks failed");
        }
    }
    Ok(())
}

fn setup_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    match options.provider {
        DistributionProvider::GithubPages => setup_github_pages(options, config),
        DistributionProvider::GithubReleases => github_releases::setup(options, config),
        DistributionProvider::DockerRegistry => setup_non_static_provider(options, config),
        DistributionProvider::CloudflarePages => {
            let cfg = cloudflare_config(config, &options.site)?;
            println!("Cloudflare Pages setup checks for `{}`", options.site);
            println!(
                "account_id: {}",
                cfg.account_id.as_deref().unwrap_or("<missing>")
            );
            println!(
                "project_name: {}",
                cfg.project_name.as_deref().unwrap_or("<missing>")
            );
            println!("Run `fission readiness distribute --provider cloudflare-pages --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display());
            Ok(())
        }
        DistributionProvider::Netlify => {
            let cfg = netlify_config(config, &options.site)?;
            println!("Netlify setup checks for `{}`", options.site);
            println!("site_id: {}", cfg.site_id.as_deref().unwrap_or("<missing>"));
            println!(
                "team_slug: {}",
                cfg.team_slug.as_deref().unwrap_or("<missing>")
            );
            println!("Run `fission readiness distribute --provider netlify --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display());
            Ok(())
        }
        DistributionProvider::S3
        | DistributionProvider::GoogleDrive
        | DistributionProvider::OneDrive
        | DistributionProvider::Dropbox
        | DistributionProvider::PlayStore
        | DistributionProvider::AppStore
        | DistributionProvider::MicrosoftStore => setup_non_static_provider(options, config),
    }
}

fn publish_artifact(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    let artifact_path = options
        .artifact
        .as_deref()
        .map(PathBuf::from)
        .unwrap_or_else(|| {
            if options.provider == DistributionProvider::DockerRegistry {
                default_artifact_manifest_path_for_format(
                    &options.project_dir,
                    Target::Server,
                    PackageFormat::DockerImage,
                    true,
                )
            } else {
                default_artifact_manifest_path(&options.project_dir, Target::Site, true)
            }
        });
    let manifest = read_artifact_manifest(&artifact_path)?;
    let checks = readiness_distribute(
        &options.project_dir,
        options.provider,
        &options.site,
        options.track.as_deref(),
        Some(&artifact_path),
        config,
    )?;
    let errors = checks
        .iter()
        .filter(|check| {
            check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed
        })
        .collect::<Vec<_>>();
    if !errors.is_empty() {
        print_checks(&checks);
        bail!("distribution readiness failed");
    }

    let receipt = match options.provider {
        DistributionProvider::GithubPages => {
            publish_github_pages(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::GithubReleases => {
            github_releases::publish(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::DockerRegistry => {
            docker_registry::publish(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::CloudflarePages => {
            publish_cloudflare_pages(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::Netlify => {
            static_hosts::publish_netlify(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::S3 => files::publish_s3(options, config, &artifact_path, &manifest)?,
        DistributionProvider::GoogleDrive => {
            files::publish_google_drive(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::OneDrive => {
            files::publish_onedrive(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::Dropbox => {
            files::publish_dropbox(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::PlayStore => {
            stores::publish_play_store(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::AppStore => {
            stores::publish_app_store(options, config, &artifact_path, &manifest)?
        }
        DistributionProvider::MicrosoftStore => {
            stores::publish_microsoft_store(options, config, &artifact_path, &manifest)?
        }
    };
    write_receipt(&options.project_dir, &receipt)?;
    print_distribution_receipt(options, &receipt)
}

fn print_distribution_receipt(
    options: &DistributeOptions,
    receipt: &DistributionReceipt,
) -> Result<()> {
    if options.json {
        println!("{}", serde_json::to_string_pretty(&receipt)?);
    } else {
        println!(
            "{} {} status: {}",
            receipt.provider, receipt.action, receipt.status
        );
        if let Some(url) = &receipt.canonical_url {
            println!("URL: {url}");
        }
        for item in &receipt.manual_follow_up {
            println!("Follow-up: {item}");
        }
    }
    Ok(())
}

fn provider_status(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    let receipt = match options.provider {
        DistributionProvider::GithubPages => github_pages_status(options, config)?,
        DistributionProvider::GithubReleases => github_releases::status(options, config)?,
        DistributionProvider::DockerRegistry => docker_registry::status(options, config)?,
        DistributionProvider::CloudflarePages => cloudflare_pages_status(options, config)?,
        DistributionProvider::Netlify => static_hosts::netlify_status(options, config)?,
        DistributionProvider::PlayStore => stores::play_store_status(options, config)?,
        DistributionProvider::S3 => files::s3_status(options, config)?,
        DistributionProvider::GoogleDrive => files::google_drive_status(options, config)?,
        DistributionProvider::OneDrive => files::onedrive_status(options, config)?,
        DistributionProvider::Dropbox => files::dropbox_status(options, config)?,
        DistributionProvider::AppStore => stores::app_store_status(options, config)?,
        DistributionProvider::MicrosoftStore => stores::microsoft_store_status(options, config)?,
    };
    if options.json {
        println!("{}", serde_json::to_string_pretty(&receipt)?);
    } else {
        println!("{} status: {}", receipt.provider, receipt.status);
        if let Some(stdout) = &receipt.stdout {
            print!("{stdout}");
        }
    }
    Ok(())
}

fn provider_lifecycle(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    let receipt = match options.provider {
        DistributionProvider::Netlify => static_hosts::netlify_lifecycle(options, config)?,
        DistributionProvider::CloudflarePages => cloudflare_pages_lifecycle(options, config)?,
        _ => bail!(
            "{} currently supports setup, publish, and status; {} is not exposed by this provider backend",
            options.provider.as_str(),
            match options.action {
                DistributeAction::Promote => "promote",
                DistributeAction::Rollback => "rollback",
                _ => "this operation",
            }
        ),
    };
    write_receipt(&options.project_dir, &receipt)?;
    print_distribution_receipt(options, &receipt)
}

fn cloudflare_pages_lifecycle(
    options: &DistributeOptions,
    config: &PublishManifest,
) -> Result<DistributionReceipt> {
    let cfg = cloudflare_config(config, &options.site)?;
    let account_id = cfg
        .account_id
        .clone()
        .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok())
        .context(
            "distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID is required",
        )?;
    let project_name = cfg
        .project_name
        .as_deref()
        .context("distribution.cloudflare_pages.<site>.project_name is required")?;
    let deploy = options
        .deploy
        .as_deref()
        .context("cloudflare-pages promote/rollback requires --deploy <deployment-id>")?;
    if options.dry_run {
        return Ok(DistributionReceipt {
            schema_version: 1,
            created_at_unix_seconds: now_unix_seconds(),
            provider: "cloudflare-pages".to_string(),
            site: options.site.clone(),
            action: match options.action {
                DistributeAction::Promote => "promote",
                DistributeAction::Rollback => "rollback",
                _ => "lifecycle",
            }
            .to_string(),
            artifact_manifest: None,
            deployment_id: Some(deploy.to_string()),
            canonical_url: cloudflare_url(&cfg),
            preview_url: None,
            custom_domain: non_empty(cfg.custom_domain.clone()),
            status: "dry-run".to_string(),
            stdout: None,
            stderr: None,
            manual_follow_up: vec![format!(
                "Would make Cloudflare Pages deployment {deploy} live by calling the provider rollback endpoint."
            )],
        });
    }
    let token = credentials::provider_secret(
        DistributionProvider::CloudflarePages,
        &["CLOUDFLARE_API_TOKEN"],
    )?
    .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?;
    let url = format!(
        "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments/{deploy}/rollback"
    );
    let response = reqwest::blocking::Client::builder()
        .user_agent("cargo-fission-publish/0.1")
        .build()?
        .post(url)
        .bearer_auth(token)
        .send()
        .context("failed to rollback Cloudflare Pages deployment")?;
    let status = response.status();
    let text = response.text()?;
    if !status.is_success() {
        bail!("Cloudflare Pages rollback failed with {status}: {text}");
    }
    let value: serde_json::Value = serde_json::from_str(&text)
        .with_context(|| format!("failed to parse Cloudflare Pages rollback response: {text}"))?;
    let result = value.get("result").unwrap_or(&value);
    Ok(DistributionReceipt {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        provider: "cloudflare-pages".to_string(),
        site: options.site.clone(),
        action: match options.action {
            DistributeAction::Promote => "promote",
            DistributeAction::Rollback => "rollback",
            _ => "lifecycle",
        }
        .to_string(),
        artifact_manifest: None,
        deployment_id: result
            .get("id")
            .and_then(serde_json::Value::as_str)
            .map(str::to_string)
            .or_else(|| Some(deploy.to_string())),
        canonical_url: cloudflare_url(&cfg),
        preview_url: result
            .get("url")
            .and_then(serde_json::Value::as_str)
            .map(str::to_string),
        custom_domain: non_empty(cfg.custom_domain.clone()),
        status: result
            .pointer("/latest_stage/status")
            .or_else(|| result.get("status"))
            .and_then(serde_json::Value::as_str)
            .unwrap_or("rollback-requested")
            .to_string(),
        stdout: Some(serde_json::to_string_pretty(&value)?),
        stderr: None,
        manual_follow_up: Vec::new(),
    })
}

fn setup_non_static_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    let checks = readiness_distribute(
        &options.project_dir,
        options.provider,
        &options.site,
        options.track.as_deref(),
        options.artifact.as_deref(),
        config,
    )?;
    if options.json {
        let report = ReadinessReport {
            project_dir: options.project_dir.display().to_string(),
            target: None,
            format: None,
            provider: Some(options.provider.as_str().to_string()),
            site: Some(options.site.clone()),
            status: report_status(&checks).to_string(),
            checks,
        };
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        println!(
            "{} setup checks for `{}`",
            options.provider.as_str(),
            options.site
        );
        print_checks(&checks);
    }
    Ok(())
}

fn setup_github_pages(options: &DistributeOptions, config: &PublishManifest) -> Result<()> {
    let cfg = github_config(config, &options.site)?;
    let workflow = cfg
        .workflow
        .clone()
        .unwrap_or_else(|| "fission-pages.yml".to_string());
    let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow);
    let content = render_github_pages_workflow(&options.project_dir, &cfg);
    if options.dry_run {
        println!("Would write {}:\n{}", workflow_path.display(), content);
        return Ok(());
    }
    if workflow_path.exists() && !options.yes {
        bail!(
            "{} already exists; pass --yes to overwrite or edit it manually",
            workflow_path.display()
        );
    }
    if let Some(parent) = workflow_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&workflow_path, content)
        .with_context(|| format!("failed to write {}", workflow_path.display()))?;
    println!("Wrote {}", workflow_path.display());
    if cfg
        .custom_domain
        .as_deref()
        .filter(|s| !s.is_empty())
        .is_some()
    {
        println!("Custom domains for GitHub Actions Pages must be configured in repository Pages settings or via the GitHub Pages API.");
    }
    Ok(())
}

fn publish_github_pages(
    options: &DistributeOptions,
    config: &PublishManifest,
    artifact_path: &Path,
    manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
    let cfg = github_config(config, &options.site)?;
    let mode = cfg.mode.as_deref().unwrap_or("actions");
    match mode {
        "branch" => publish_github_pages_branch(options, &cfg, artifact_path, manifest),
        "actions" => {
            let owner = cfg
                .owner
                .clone()
                .or_else(|| infer_github_owner(&options.project_dir));
            let repo = cfg
                .repo
                .clone()
                .or_else(|| infer_github_repo(&options.project_dir));
            let workflow = cfg
                .workflow
                .clone()
                .unwrap_or_else(|| "fission-pages.yml".to_string());
            let mut follow_up = vec![format!(
                "Commit the generated workflow and push the configured production branch so GitHub Actions deploys the exact static site build."
            )];
            if let (Some(owner), Some(repo)) = (&owner, &repo) {
                follow_up.push(format!(
                    "Repository Pages URL should be https://{owner}.github.io/{repo}/ unless a custom domain is configured."
                ));
            }
            let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow);
            if !workflow_path.exists() {
                follow_up.push(format!(
                    "Run `fission distribute setup --provider github-pages --site {} --project-dir {}` to generate {}.",
                    options.site,
                    options.project_dir.display(),
                    workflow_path.display()
                ));
            }
            Ok(DistributionReceipt {
                schema_version: 1,
                created_at_unix_seconds: now_unix_seconds(),
                provider: "github-pages".to_string(),
                site: options.site.clone(),
                action: "publish".to_string(),
                artifact_manifest: Some(artifact_path.display().to_string()),
                deployment_id: None,
                canonical_url: github_pages_url(&cfg, owner.as_deref(), repo.as_deref()),
                preview_url: None,
                custom_domain: non_empty(cfg.custom_domain.clone()),
                status: "workflow-required".to_string(),
                stdout: None,
                stderr: None,
                manual_follow_up: follow_up,
            })
        }
        other => bail!("unsupported github-pages mode `{other}`; expected actions or branch"),
    }
}

fn publish_github_pages_branch(
    options: &DistributeOptions,
    cfg: &GithubPagesConfig,
    artifact_path: &Path,
    manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
    let remote = cfg.remote.as_deref().unwrap_or("origin");
    let branch = cfg.source_branch.as_deref().unwrap_or("gh-pages");
    let source_path = cfg.source_path.as_deref().unwrap_or("/");
    let repo_root = git_output(&options.project_dir, ["rev-parse", "--show-toplevel"])?;
    let repo_root = PathBuf::from(repo_root.trim());
    let remote_url = git_output(&repo_root, ["remote", "get-url", remote])?;
    let worktree = options
        .project_dir
        .join("target/fission/publish/github-pages")
        .join(&options.site);
    if options.dry_run {
        println!(
            "Would publish {} to {remote}:{branch} at {}",
            manifest.root_dir, source_path
        );
        return Ok(DistributionReceipt {
            schema_version: 1,
            created_at_unix_seconds: now_unix_seconds(),
            provider: "github-pages".to_string(),
            site: options.site.clone(),
            action: "publish".to_string(),
            artifact_manifest: Some(artifact_path.display().to_string()),
            deployment_id: Some(format!("{remote}:{branch}")),
            canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()),
            preview_url: None,
            custom_domain: non_empty(cfg.custom_domain.clone()),
            status: "dry-run".to_string(),
            stdout: None,
            stderr: None,
            manual_follow_up: Vec::new(),
        });
    }
    if worktree.exists() {
        fs::remove_dir_all(&worktree)
            .with_context(|| format!("failed to clean {}", worktree.display()))?;
    }
    if let Some(parent) = worktree.parent() {
        fs::create_dir_all(parent)?;
    }

    let clone_status = Command::new("git")
        .args([
            "clone",
            "--depth",
            "1",
            "--branch",
            branch,
            remote_url.trim(),
        ])
        .arg(&worktree)
        .status()
        .context("failed to run git clone for GitHub Pages branch")?;
    if !clone_status.success() {
        let status = Command::new("git")
            .args(["clone", "--depth", "1", remote_url.trim()])
            .arg(&worktree)
            .status()
            .context("failed to run git clone for GitHub Pages repository")?;
        if !status.success() {
            bail!("failed to clone {remote} for GitHub Pages publishing");
        }
        run_git(&worktree, ["checkout", "--orphan", branch])?;
    }

    let publish_root = if source_path == "/" || source_path == "." {
        worktree.clone()
    } else {
        worktree.join(source_path.trim_start_matches('/'))
    };
    clean_publish_root(&publish_root)?;
    copy_dir_contents(Path::new(&manifest.root_dir), &publish_root)?;
    fs::write(publish_root.join(".nojekyll"), "")?;
    if let Some(domain) = non_empty(cfg.custom_domain.clone()) {
        fs::write(publish_root.join("CNAME"), format!("{}\n", domain.trim()))?;
    }

    run_git(&worktree, ["add", "--all"])?;
    let commit = Command::new("git")
        .args(["commit", "-m", "Publish Fission static site"])
        .current_dir(&worktree)
        .output()
        .context("failed to run git commit for GitHub Pages")?;
    if !commit.status.success() {
        let stderr = String::from_utf8_lossy(&commit.stderr);
        if !stderr.contains("nothing to commit") && !stderr.contains("no changes added") {
            io::stderr().write_all(&commit.stderr).ok();
            bail!("git commit failed for GitHub Pages publish");
        }
    }
    run_git(&worktree, ["push", remote, branch])?;
    Ok(DistributionReceipt {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        provider: "github-pages".to_string(),
        site: options.site.clone(),
        action: "publish".to_string(),
        artifact_manifest: Some(artifact_path.display().to_string()),
        deployment_id: Some(format!("{remote}:{branch}")),
        canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()),
        preview_url: None,
        custom_domain: non_empty(cfg.custom_domain.clone()),
        status: "published".to_string(),
        stdout: None,
        stderr: None,
        manual_follow_up: github_pages_follow_up(cfg),
    })
}

fn publish_cloudflare_pages(
    options: &DistributeOptions,
    config: &PublishManifest,
    artifact_path: &Path,
    manifest: &ArtifactManifest,
) -> Result<DistributionReceipt> {
    let cfg = cloudflare_config(config, &options.site)?;
    let project_name = cfg
        .project_name
        .as_deref()
        .context("distribution.cloudflare_pages.<site>.project_name is required")?;
    let mut args = vec![
        "pages".to_string(),
        "deploy".to_string(),
        manifest.root_dir.clone(),
        "--project-name".to_string(),
        project_name.to_string(),
    ];
    if let Some(environment) = cfg
        .environment
        .as_deref()
        .filter(|value| *value != "production")
    {
        args.push("--branch".to_string());
        args.push(environment.to_string());
    }
    run_publish_command(
        options,
        "cloudflare-pages",
        "wrangler",
        args,
        artifact_path,
        || cloudflare_url(&cfg),
    )
}

fn run_publish_command<F>(
    options: &DistributeOptions,
    provider: &str,
    program: &str,
    args: Vec<String>,
    artifact_path: &Path,
    canonical_url: F,
) -> Result<DistributionReceipt>
where
    F: FnOnce() -> Option<String>,
{
    if options.dry_run {
        println!("Would run: {} {}", program, args.join(" "));
        return Ok(DistributionReceipt {
            schema_version: 1,
            created_at_unix_seconds: now_unix_seconds(),
            provider: provider.to_string(),
            site: options.site.clone(),
            action: "publish".to_string(),
            artifact_manifest: Some(artifact_path.display().to_string()),
            deployment_id: None,
            canonical_url: canonical_url(),
            preview_url: None,
            custom_domain: None,
            status: "dry-run".to_string(),
            stdout: None,
            stderr: None,
            manual_follow_up: Vec::new(),
        });
    }
    let output = Command::new(program)
        .args(&args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .with_context(|| {
            format!("failed to run {program}; install it or run readiness for remediation")
        })?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    if !output.status.success() {
        eprint!("{stderr}");
        bail!("{provider} publish failed with {}", output.status);
    }
    Ok(DistributionReceipt {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        provider: provider.to_string(),
        site: options.site.clone(),
        action: "publish".to_string(),
        artifact_manifest: Some(artifact_path.display().to_string()),
        deployment_id: None,
        canonical_url: canonical_url(),
        preview_url: first_url(&stdout),
        custom_domain: None,
        status: "published".to_string(),
        stdout: Some(stdout),
        stderr: (!stderr.trim().is_empty()).then_some(stderr),
        manual_follow_up: Vec::new(),
    })
}

fn github_pages_status(
    options: &DistributeOptions,
    config: &PublishManifest,
) -> Result<DistributionReceipt> {
    let cfg = github_config(config, &options.site)?;
    let owner = cfg
        .owner
        .clone()
        .or_else(|| infer_github_owner(&options.project_dir));
    let repo = cfg
        .repo
        .clone()
        .or_else(|| infer_github_repo(&options.project_dir));
    let (Some(owner), Some(repo)) = (owner, repo) else {
        bail!("github-pages status requires owner and repo in fission.toml or a GitHub remote");
    };
    command_status_receipt(
        options,
        "github-pages",
        "gh",
        vec!["api".to_string(), format!("repos/{owner}/{repo}/pages")],
    )
}

fn cloudflare_pages_status(
    options: &DistributeOptions,
    config: &PublishManifest,
) -> Result<DistributionReceipt> {
    let cfg = cloudflare_config(config, &options.site)?;
    let account_id = cfg
        .account_id
        .clone()
        .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok())
        .context(
            "distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID is required",
        )?;
    let project_name = cfg
        .project_name
        .as_deref()
        .context("distribution.cloudflare_pages.<site>.project_name is required")?;
    let token = credentials::provider_secret(
        DistributionProvider::CloudflarePages,
        &["CLOUDFLARE_API_TOKEN"],
    )?
    .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?;
    let url = format!(
        "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments"
    );
    let response = reqwest::blocking::Client::builder()
        .user_agent("cargo-fission-publish/0.1")
        .build()?
        .get(url)
        .bearer_auth(token)
        .send()
        .context("failed to query Cloudflare Pages deployments")?;
    let status = response.status();
    let text = response.text()?;
    if !status.is_success() {
        bail!("Cloudflare Pages status failed with {status}: {text}");
    }
    let value: serde_json::Value = serde_json::from_str(&text)
        .with_context(|| format!("failed to parse Cloudflare Pages status response: {text}"))?;
    let latest = value
        .get("result")
        .and_then(serde_json::Value::as_array)
        .and_then(|items| items.first());
    Ok(DistributionReceipt {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        provider: "cloudflare-pages".to_string(),
        site: options.site.clone(),
        action: "status".to_string(),
        artifact_manifest: None,
        deployment_id: latest
            .and_then(|item| item.get("id"))
            .and_then(serde_json::Value::as_str)
            .map(str::to_string),
        canonical_url: cloudflare_url(&cfg),
        preview_url: latest
            .and_then(|item| item.get("url"))
            .and_then(serde_json::Value::as_str)
            .map(str::to_string),
        custom_domain: non_empty(cfg.custom_domain.clone()),
        status: latest
            .and_then(|item| item.pointer("/latest_stage/status"))
            .or_else(|| latest.and_then(|item| item.get("deployment_trigger")))
            .and_then(serde_json::Value::as_str)
            .unwrap_or("ok")
            .to_string(),
        stdout: Some(serde_json::to_string_pretty(&value)?),
        stderr: None,
        manual_follow_up: Vec::new(),
    })
}

fn command_status_receipt(
    options: &DistributeOptions,
    provider: &str,
    program: &str,
    args: Vec<String>,
) -> Result<DistributionReceipt> {
    let output = Command::new(program)
        .args(&args)
        .output()
        .with_context(|| format!("failed to run {program}; install it or authenticate first"))?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    Ok(DistributionReceipt {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        provider: provider.to_string(),
        site: options.site.clone(),
        action: "status".to_string(),
        artifact_manifest: None,
        deployment_id: options.deploy.clone(),
        canonical_url: first_url(&stdout),
        preview_url: None,
        custom_domain: None,
        status: if output.status.success() {
            "ok"
        } else {
            "failed"
        }
        .to_string(),
        stdout: Some(stdout),
        stderr: (!stderr.trim().is_empty()).then_some(stderr),
        manual_follow_up: Vec::new(),
    })
}

fn readiness_package(
    project_dir: &Path,
    target: Option<Target>,
    format: Option<PackageFormat>,
) -> Result<Vec<ReadinessCheck>> {
    let target = target.unwrap_or(Target::Site);
    let format = format.unwrap_or(PackageFormat::Static);
    let mut checks = Vec::new();
    let format_supported = package_format_supported(target, format);
    checks.push(check(
        "release.package.format_supported",
        CheckSeverity::Error,
        if format_supported {
            CheckStatus::Passed
        } else {
            CheckStatus::Failed
        },
        "package format is supported for the selected target",
        Some(format!(
            "--target {} --format {}",
            target.as_str(),
            format.as_str()
        )),
        vec!["Use a valid target/format pair, such as site/static, site/docker-image, server/docker-image, linux/run, macos/app, macos/pkg, windows/exe, windows/msi, windows/msix, android/apk, android/aab, or ios/ipa."],
    ));
    checks.push(check_path(
        "release.package.fission_toml_exists",
        project_dir.join("fission.toml"),
        "fission.toml exists",
        "Run `fission init .` or point --project-dir at a Fission project.",
    ));
    if let Ok(project) = fission_command_core::read_project_config(project_dir) {
        checks.push(check(
            "release.package.target_configured",
            CheckSeverity::Error,
            if project.targets.contains(&target) {
                CheckStatus::Passed
            } else {
                CheckStatus::Missing
            },
            "target is configured in fission.toml",
            Some(format!("target = {}", target.as_str())),
            vec!["Run `fission add-target <target> --project-dir .` before packaging."],
        ));
    }
    if matches!(target, Target::Site) {
        let has_content = project_dir.join("content").exists();
        let has_entry = load_publish_manifest(project_dir)
            .ok()
            .and_then(|manifest| manifest.site.and_then(|site| site.entry))
            .is_some_and(|entry| !entry.trim().is_empty());
        checks.push(check(
            "release.package.site_content_or_entry",
            CheckSeverity::Error,
            if has_content || has_entry {
                CheckStatus::Passed
            } else {
                CheckStatus::Missing
            },
            "default content directory exists or custom site entry handles routing",
            Some(format!(
                "content: {}, site.entry: {}",
                project_dir.join("content").display(),
                has_entry
            )),
            vec!["Add content/ or configure [site].entry for a custom static site."],
        ));
    }
    readiness_package_tools(project_dir, target, format, &mut checks);
    package::readiness_secondary_artifacts(project_dir, &mut checks);
    Ok(checks)
}

fn package_format_supported(target: Target, format: PackageFormat) -> bool {
    matches!(
        (target, format),
        (Target::Site, PackageFormat::Static)
            | (Target::Web, PackageFormat::Static)
            | (Target::Site, PackageFormat::DockerImage)
            | (Target::Server, PackageFormat::DockerImage)
            | (Target::Linux, PackageFormat::Run)
            | (Target::Macos, PackageFormat::App)
            | (Target::Macos, PackageFormat::Pkg)
            | (Target::Windows, PackageFormat::Exe)
            | (Target::Windows, PackageFormat::Msi)
            | (Target::Windows, PackageFormat::Msix)
            | (Target::Android, PackageFormat::Apk)
            | (Target::Android, PackageFormat::Aab)
            | (Target::Ios, PackageFormat::Ipa)
    )
}

fn readiness_package_tools(
    project_dir: &Path,
    target: Target,
    format: PackageFormat,
    checks: &mut Vec<ReadinessCheck>,
) {
    match (target, format) {
        (Target::Site, PackageFormat::Static) | (Target::Web, PackageFormat::Static) => {
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
        }
        (Target::Site, PackageFormat::DockerImage)
        | (Target::Server, PackageFormat::DockerImage) => {
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
            checks.push(check_tool(
                "release.package.docker_available",
                "docker",
                "Install Docker and ensure the docker CLI can reach a running Docker engine.",
            ));
            if target == Target::Server {
                checks.push(check(
                    "release.package.server_entry_configured",
                    CheckSeverity::Error,
                    if server_entry_configured(project_dir) {
                        CheckStatus::Passed
                    } else {
                        CheckStatus::Missing
                    },
                    "server entry is configured",
                    Some("[server].entry".to_string()),
                    vec!["Add [server].entry to fission.toml so the Docker image can run the server app."],
                ));
            }
        }
        (Target::Linux, PackageFormat::Run) => {
            checks.push(host_os_check("release.package.host_is_linux", "linux"));
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
        }
        (Target::Macos, PackageFormat::App) => {
            checks.push(host_os_check("release.package.host_is_macos", "macos"));
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
            checks.push(check_tool(
                "release.package.codesign_available",
                "codesign",
                "Install Xcode command line tools so Fission can verify signed .app bundles.",
            ));
        }
        (Target::Macos, PackageFormat::Pkg) => {
            checks.push(host_os_check("release.package.host_is_macos", "macos"));
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
            checks.push(check_tool(
                "release.package.pkgbuild_available",
                "pkgbuild",
                "Install Xcode command line tools.",
            ));
            checks.push(check_tool(
                "release.package.productbuild_available",
                "productbuild",
                "Install Xcode command line tools.",
            ));
            checks.push(check_tool(
                "release.package.pkgutil_available",
                "pkgutil",
                "Install macOS package tools so Fission can inspect produced .pkg files.",
            ));
        }
        (Target::Windows, PackageFormat::Exe) => {
            checks.push(host_os_check("release.package.host_is_windows", "windows"));
            checks.push(check_tool(
                "release.package.cargo_available",
                "cargo",
                "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
            ));
        }
        (Target::Windows, PackageFormat::Msi) => {
            checks.push(host_os_check("release.package.host_is_windows", "windows"));
            checks.push(check_path(
                "release.package.windows_msi_script_exists",
                project_dir.join("platforms/windows/package-msi.ps1"),
                "Windows MSI packaging script exists",
                "Configure platforms/windows/package-msi.ps1 or install the Windows packaging target template.",
            ));
            checks.push(check_any_tool(
                "release.package.windows_msi_builder_available",
                &["wix", "candle"],
                "WiX MSI packaging tooling is available",
                "Install WiX Toolset or configure platforms/windows/package-msi.ps1 to call the approved MSI packager.",
            ));
            checks.push(check_tool(
                "release.package.signtool_available",
                "signtool",
                "Install Windows SDK signing tools and ensure signtool is on PATH.",
            ));
        }
        (Target::Windows, PackageFormat::Msix) => {
            checks.push(host_os_check("release.package.host_is_windows", "windows"));
            checks.push(check_path(
                "release.package.windows_msix_script_exists",
                project_dir.join("platforms/windows/package-msix.ps1"),
                "Windows MSIX packaging script exists",
                "Configure platforms/windows/package-msix.ps1 or install the Windows packaging target template.",
            ));
            checks.push(check_tool(
                "release.package.makeappx_available",
                "makeappx",
                "Install Windows SDK MSIX packaging tools and ensure makeappx is on PATH.",
            ));
            checks.push(check_tool(
                "release.package.signtool_available",
                "signtool",
                "Install Windows SDK signing tools and ensure signtool is on PATH.",
            ));
        }
        (Target::Android, PackageFormat::Apk) => {
            checks.push(check_path(
                "release.package.android_apk_script_exists",
                project_dir.join("platforms/android/package-apk.sh"),
                "Android APK packaging script exists",
                "Run `fission add-target android --project-dir .` or restore platforms/android/package-apk.sh.",
            ));
            android_packaging_checks(checks);
        }
        (Target::Android, PackageFormat::Aab) => {
            checks.push(check_path(
                "release.package.android_aab_script_exists",
                project_dir.join("platforms/android/package-aab.sh"),
                "Android AAB packaging script exists",
                "Add platforms/android/package-aab.sh once release AAB packaging is configured.",
            ));
            android_packaging_checks(checks);
            checks.push(check_env_or_tool(
                "release.package.bundletool_available",
                &["BUNDLETOOL"],
                &["bundletool"],
                "Android bundletool is available for AAB validation",
                "Install bundletool or set BUNDLETOOL to the bundletool jar/path used by the project packaging script.",
            ));
        }
        (Target::Ios, PackageFormat::Ipa) => {
            checks.push(host_os_check("release.package.host_is_macos", "macos"));
            checks.push(check_path(
                "release.package.ios_ipa_script_exists",
                project_dir.join("platforms/ios/package-ipa.sh"),
                "iOS IPA packaging script exists",
                "Add platforms/ios/package-ipa.sh once release IPA export is configured.",
            ));
            checks.push(check_tool(
                "release.package.xcrun_available",
                "xcrun",
                "Install Xcode command line tools and select an Xcode installation.",
            ));
            checks.push(check_tool(
                "release.package.xcodebuild_available",
                "xcodebuild",
                "Install Xcode so Fission can archive and export iOS IPA files.",
            ));
            checks.push(check_tool(
                "release.package.codesign_available",
                "codesign",
                "Install Xcode command line tools so Fission can verify iOS signing.",
            ));
        }
        _ => {}
    }
}

fn server_entry_configured(project_dir: &Path) -> bool {
    fs::read_to_string(project_dir.join("fission.toml"))
        .ok()
        .and_then(|data| toml::from_str::<toml::Value>(&data).ok())
        .and_then(|value| {
            value
                .get("server")
                .and_then(|server| server.get("entry"))
                .and_then(toml::Value::as_str)
                .map(|entry| !entry.trim().is_empty())
        })
        .unwrap_or(false)
}

fn android_packaging_checks(checks: &mut Vec<ReadinessCheck>) {
    checks.push(check_tool(
        "release.package.cargo_available",
        "cargo",
        "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.",
    ));
    checks.push(check_any_env(
        "release.package.android_sdk_configured",
        &["ANDROID_HOME", "ANDROID_SDK_ROOT"],
        "Android SDK path is configured",
        "Set ANDROID_HOME or ANDROID_SDK_ROOT to the installed Android SDK.",
    ));
    checks.push(check_any_env(
        "release.package.android_ndk_configured",
        &["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"],
        "Android NDK path is configured",
        "Set ANDROID_NDK_HOME or ANDROID_NDK_ROOT to the installed Android NDK used by Rust cross-compilation.",
    ));
    checks.push(check_tool(
        "release.package.aapt2_available",
        "aapt2",
        "Install Android SDK build-tools and ensure aapt2 is on PATH.",
    ));
    checks.push(check_tool(
        "release.package.zipalign_available",
        "zipalign",
        "Install Android SDK build-tools and ensure zipalign is on PATH.",
    ));
    checks.push(check_tool(
        "release.package.apksigner_available",
        "apksigner",
        "Install Android SDK build-tools and ensure apksigner is on PATH.",
    ));
}

fn readiness_distribute(
    project_dir: &Path,
    provider: DistributionProvider,
    site: &str,
    track: Option<&str>,
    artifact: Option<&Path>,
    config: &PublishManifest,
) -> Result<Vec<ReadinessCheck>> {
    let mut checks = Vec::new();
    if let Some(path) = artifact {
        checks.push(check_path(
            "release.distribution.artifact_manifest_exists",
            path.to_path_buf(),
            "artifact manifest exists",
            "Run `fission package --target site --format static --release` first.",
        ));
        if path.exists() {
            let manifest = read_artifact_manifest(path)?;
            if provider_requires_static_root(provider) {
                checks.push(check(
                    "release.distribution.static_root_exists",
                    CheckSeverity::Error,
                    if Path::new(&manifest.root_dir).join("index.html").exists() {
                        CheckStatus::Passed
                    } else {
                        CheckStatus::Missing
                    },
                    "static artifact root contains index.html",
                    Some(manifest.root_dir),
                    vec!["Rebuild the static package and ensure the output includes index.html."],
                ));
            }
        }
    }

    match provider {
        DistributionProvider::GithubPages => {
            readiness_github_pages(project_dir, site, config, &mut checks)?
        }
        DistributionProvider::GithubReleases => {
            github_releases::readiness(project_dir, site, artifact, config, &mut checks)?
        }
        DistributionProvider::DockerRegistry => {
            docker_registry::readiness(site, artifact, config, &mut checks)?
        }
        DistributionProvider::CloudflarePages => {
            readiness_cloudflare_pages(site, config, &mut checks)?
        }
        DistributionProvider::Netlify => readiness_netlify(site, config, &mut checks)?,
        DistributionProvider::S3 => files::readiness_s3(site, config, &mut checks)?,
        DistributionProvider::GoogleDrive => {
            files::readiness_google_drive(site, config, &mut checks)?
        }
        DistributionProvider::OneDrive => files::readiness_onedrive(site, config, &mut checks)?,
        DistributionProvider::Dropbox => files::readiness_dropbox(site, config, &mut checks)?,
        DistributionProvider::PlayStore => {
            stores::readiness_play_store(track, artifact, config, &mut checks)?
        }
        DistributionProvider::AppStore => {
            stores::readiness_app_store(track, artifact, config, &mut checks)?
        }
        DistributionProvider::MicrosoftStore => {
            stores::readiness_microsoft_store(track, artifact, config, &mut checks)?
        }
    }
    Ok(checks)
}

fn provider_requires_static_root(provider: DistributionProvider) -> bool {
    matches!(
        provider,
        DistributionProvider::GithubPages
            | DistributionProvider::CloudflarePages
            | DistributionProvider::Netlify
    )
}

fn readiness_github_pages(
    project_dir: &Path,
    site: &str,
    config: &PublishManifest,
    checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
    let cfg = github_config(config, site)?;
    let owner = cfg
        .owner
        .clone()
        .or_else(|| infer_github_owner(project_dir));
    let repo = cfg.repo.clone().or_else(|| infer_github_repo(project_dir));
    checks.push(required_value(
        "release.github_pages.owner_configured",
        owner.as_deref(),
        "GitHub owner is configured or inferable from git remote",
        "Set distribution.github_pages.<site>.owner or configure an origin GitHub remote.",
    ));
    checks.push(required_value(
        "release.github_pages.repo_configured",
        repo.as_deref(),
        "GitHub repository is configured or inferable from git remote",
        "Set distribution.github_pages.<site>.repo or configure an origin GitHub remote.",
    ));
    let mode = cfg.mode.as_deref().unwrap_or("actions");
    checks.push(check(
        "release.github_pages.mode_supported",
        CheckSeverity::Error,
        if matches!(mode, "actions" | "branch" | "manual") {
            CheckStatus::Passed
        } else {
            CheckStatus::Failed
        },
        "GitHub Pages mode is supported",
        Some(mode.to_string()),
        vec!["Use mode = \"actions\", \"branch\", or \"manual\"."],
    ));
    if mode == "branch" {
        checks.push(check_tool(
            "release.github_pages.git_available",
            "git",
            "Install Git and authenticate to the repository remote.",
        ));
    } else {
        checks.push(check(
            "release.github_pages.source_is_actions",
            CheckSeverity::Warning,
            if cfg.source.as_deref().unwrap_or("github-actions") == "github-actions" {
                CheckStatus::Passed
            } else {
                CheckStatus::Warning
            },
            "GitHub Pages source is configured for Actions publishing",
            cfg.source.clone(),
            vec!["Set distribution.github_pages.<site>.source = \"github-actions\" for Actions-based Pages publishing."],
        ));
        let workflow = cfg.workflow.as_deref().unwrap_or("fission-pages.yml");
        checks.push(check_path(
            "release.github_pages.workflow_exists",
            github_workflow_path(project_dir, &cfg, workflow),
            "GitHub Pages workflow exists",
            "Run `fission distribute setup --provider github-pages --site production` to generate it.",
        ));
        checks.push(check(
            "release.github_pages.local_api_token_optional",
            CheckSeverity::Info,
            if env::var_os("GH_TOKEN").is_some()
                || env::var_os("GITHUB_TOKEN").is_some()
                || credentials::provider_secret(DistributionProvider::GithubPages, &[])
                    .ok()
                    .flatten()
                    .is_some()
            {
                CheckStatus::Passed
            } else {
                CheckStatus::Skipped
            },
            "GitHub API token is available for local status/domain setup",
            None,
            vec!["For local Pages status or future domain setup automation, set GH_TOKEN/GITHUB_TOKEN or import a GitHub credential into the Fission vault."],
        ));
    }
    let base = cfg.base_path.as_deref().unwrap_or("/");
    let expected = expected_github_base_path(&cfg, repo.as_deref());
    checks.push(check(
        "release.github_pages.base_path_matches_domain_mode",
        CheckSeverity::Warning,
        if base == expected { CheckStatus::Passed } else { CheckStatus::Warning },
        "GitHub Pages base path matches custom-domain/project-site mode",
        Some(format!("configured {base}, expected {expected}")),
        vec!["Set distribution.github_pages.<site>.base_path to the expected value or adjust the site renderer base URL."],
    ));
    checks.push(check(
        "release.github_pages.https_policy_set",
        CheckSeverity::Info,
        if cfg.enforce_https.unwrap_or(true) {
            CheckStatus::Passed
        } else {
            CheckStatus::Warning
        },
        "GitHub Pages HTTPS policy is explicit",
        Some(format!("enforce_https = {}", cfg.enforce_https.unwrap_or(true))),
        vec!["Keep enforce_https = true for public production sites unless there is a provider limitation."],
    ));
    Ok(())
}

fn readiness_cloudflare_pages(
    site: &str,
    config: &PublishManifest,
    checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
    let cfg = cloudflare_config(config, site)?;
    let env_account_id = env::var("CLOUDFLARE_ACCOUNT_ID").ok();
    checks.push(required_value(
        "release.cloudflare_pages.account_id_configured",
        cfg.account_id.as_deref().or(env_account_id.as_deref()),
        "Cloudflare account id is configured",
        "Set distribution.cloudflare_pages.<site>.account_id or CLOUDFLARE_ACCOUNT_ID.",
    ));
    checks.push(required_value(
        "release.cloudflare_pages.project_name_configured",
        cfg.project_name.as_deref(),
        "Cloudflare Pages project name is configured",
        "Set distribution.cloudflare_pages.<site>.project_name.",
    ));
    checks.push(required_provider_secret(
        "release.cloudflare_pages.token_available",
        DistributionProvider::CloudflarePages,
        &["CLOUDFLARE_API_TOKEN"],
        "Create a Cloudflare API token with Pages Edit permission and store it in CI secrets or the Fission release vault.",
    ));
    checks.push(check_tool(
        "release.cloudflare_pages.wrangler_available",
        "wrangler",
        "Install Wrangler and authenticate it; Cloudflare Pages upload intentionally uses the provider CLI backend.",
    ));
    checks.push(base_path_check(
        "release.cloudflare_pages.base_path_root",
        cfg.base_path.as_deref(),
    ));
    Ok(())
}

fn readiness_netlify(
    site: &str,
    config: &PublishManifest,
    checks: &mut Vec<ReadinessCheck>,
) -> Result<()> {
    let cfg = netlify_config(config, site)?;
    checks.push(required_value(
        "release.netlify.site_configured",
        cfg.site_id.as_deref(),
        "Netlify site id is configured",
        "Set distribution.netlify.<site>.site_id or run provider setup after creating a Netlify site.",
    ));
    checks.push(required_provider_secret(
        "release.netlify.token_available",
        DistributionProvider::Netlify,
        &["NETLIFY_AUTH_TOKEN"],
        "Create a Netlify access token and store it in CI secrets, your shell environment, or the Fission release vault.",
    ));
    checks.push(base_path_check(
        "release.netlify.base_path_root",
        cfg.base_path.as_deref(),
    ));
    Ok(())
}

fn build_artifact_manifest(
    project: &FissionProject,
    options: &PackageOptions,
    root: &Path,
    profile: &str,
) -> Result<ArtifactManifest> {
    let mut files = Vec::new();
    collect_artifacts(root, root, &mut files)?;
    files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
    Ok(ArtifactManifest {
        schema_version: 1,
        created_at_unix_seconds: now_unix_seconds(),
        project: ArtifactProject {
            app_id: project.app.app_id.clone(),
            name: project.app.name.clone(),
            version: cargo_package_version(&options.project_dir),
        },
        target: options.target.as_str().to_string(),
        format: options.format.as_str().to_string(),
        profile: profile.to_string(),
        root_dir: root.display().to_string(),
        artifacts: files,
        validation: ArtifactValidation {
            state: "passed".to_string(),
            checks: Vec::new(),
        },
    })
}

fn collect_artifacts(root: &Path, current: &Path, files: &mut Vec<ArtifactFile>) -> Result<()> {
    for entry in
        fs::read_dir(current).with_context(|| format!("failed to read {}", current.display()))?
    {
        let entry = entry?;
        let path = entry.path();
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            if entry.file_name() == ".git" {
                continue;
            }
            collect_artifacts(root, &path, files)?;
        } else if file_type.is_file() {
            if path.file_name().and_then(OsStr::to_str) == Some(ARTIFACT_MANIFEST) {
                continue;
            }
            let relative = path
                .strip_prefix(root)?
                .to_string_lossy()
                .replace('\\', "/");
            let (sha256, size_bytes) = hash_file(&path)?;
            files.push(ArtifactFile {
                kind: if relative == "index.html" {
                    "entry"
                } else {
                    "asset"
                }
                .to_string(),
                purpose: None,
                platform: None,
                upload_provider: None,
                path: path.display().to_string(),
                relative_path: relative,
                sha256,
                size_bytes,
                mime_type: content_type(&path).to_string(),
            });
        }
    }
    Ok(())
}

fn hash_file(path: &Path) -> Result<(String, u64)> {
    let mut file = fs::File::open(path)?;
    let mut hasher = Sha256::new();
    let mut size = 0u64;
    let mut buf = [0u8; 8192];
    loop {
        let read = file.read(&mut buf)?;
        if read == 0 {
            break;
        }
        size += read as u64;
        hasher.update(&buf[..read]);
    }
    Ok((hex_lower(&hasher.finalize()), size))
}

fn hex_lower(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0xf) as usize] as char);
    }
    out
}

fn content_type(path: &Path) -> &'static str {
    if path.file_name().and_then(OsStr::to_str) == Some("Dockerfile") {
        return "text/plain; charset=utf-8";
    }
    match path.extension().and_then(OsStr::to_str).unwrap_or("") {
        "html" => "text/html; charset=utf-8",
        "css" => "text/css; charset=utf-8",
        "js" | "mjs" => "text/javascript; charset=utf-8",
        "wasm" => "application/wasm",
        "json" | "webmanifest" => "application/json; charset=utf-8",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "svg" => "image/svg+xml",
        "ico" => "image/x-icon",
        "txt" => "text/plain; charset=utf-8",
        "xml" => "application/xml; charset=utf-8",
        _ => "application/octet-stream",
    }
}

fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> {
    for entry in
        fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
    {
        let entry = entry?;
        let source_path = entry.path();
        let dest_path = dest.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            fs::create_dir_all(&dest_path)?;
            copy_dir_contents(&source_path, &dest_path)?;
        } else {
            if let Some(parent) = dest_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(&source_path, &dest_path).with_context(|| {
                format!(
                    "failed to copy {} to {}",
                    source_path.display(),
                    dest_path.display()
                )
            })?;
        }
    }
    Ok(())
}

fn clean_publish_root(root: &Path) -> Result<()> {
    fs::create_dir_all(root)?;
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        if entry.file_name() == ".git" {
            continue;
        }
        let path = entry.path();
        if entry.file_type()?.is_dir() {
            fs::remove_dir_all(path)?;
        } else {
            fs::remove_file(path)?;
        }
    }
    Ok(())
}

fn load_publish_manifest(project_dir: &Path) -> Result<PublishManifest> {
    let path = project_dir.join("fission.toml");
    let data =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))
}

fn site_output_dir(project_dir: &Path) -> Result<PathBuf> {
    let manifest = load_publish_manifest(project_dir)?;
    Ok(manifest
        .site
        .and_then(|site| site.out_dir)
        .map(|path| resolve_project_path(project_dir, path))
        .unwrap_or_else(|| project_dir.join("target/fission/site")))
}

fn read_artifact_manifest(path: &Path) -> Result<ArtifactManifest> {
    let data = fs::read_to_string(path)
        .with_context(|| format!("failed to read artifact manifest {}", path.display()))?;
    serde_json::from_str(&data)
        .with_context(|| format!("failed to parse artifact manifest {}", path.display()))
}

fn default_artifact_manifest_path(project_dir: &Path, target: Target, release: bool) -> PathBuf {
    default_artifact_manifest_path_for_format(project_dir, target, PackageFormat::Static, release)
}

fn default_artifact_manifest_path_for_format(
    project_dir: &Path,
    target: Target,
    format: PackageFormat,
    release: bool,
) -> PathBuf {
    project_dir
        .join("target/fission")
        .join(if release { "release" } else { "debug" })
        .join(target.as_str())
        .join(format.as_str())
        .join(ARTIFACT_MANIFEST)
}

fn github_config(config: &PublishManifest, site: &str) -> Result<GithubPagesConfig> {
    Ok(config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.github_pages.get(site))
        .cloned()
        .unwrap_or_default())
}

fn github_releases_config(config: &PublishManifest, site: &str) -> Result<GithubReleasesConfig> {
    config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.github_releases.get(site))
        .cloned()
        .with_context(|| format!("missing [distribution.github_releases.{site}] in fission.toml"))
}

fn docker_registry_config(config: &PublishManifest, site: &str) -> Result<DockerRegistryConfig> {
    Ok(config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.docker_registry.get(site))
        .cloned()
        .unwrap_or_default())
}

fn s3_config(config: &PublishManifest, site: &str) -> Result<S3Config> {
    config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.s3.get(site))
        .cloned()
        .with_context(|| format!("missing [distribution.s3.{site}] in fission.toml"))
}

fn google_drive_config(config: &PublishManifest, site: &str) -> Result<GoogleDriveConfig> {
    Ok(config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.google_drive.get(site))
        .cloned()
        .unwrap_or_default())
}

fn onedrive_config(config: &PublishManifest, site: &str) -> Result<OneDriveConfig> {
    Ok(config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.onedrive.get(site))
        .cloned()
        .unwrap_or_default())
}

fn dropbox_config(config: &PublishManifest, site: &str) -> Result<DropboxConfig> {
    Ok(config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.dropbox.get(site))
        .cloned()
        .unwrap_or_default())
}

fn cloudflare_config(config: &PublishManifest, site: &str) -> Result<CloudflarePagesConfig> {
    config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.cloudflare_pages.get(site))
        .cloned()
        .with_context(|| format!("missing [distribution.cloudflare_pages.{site}] in fission.toml"))
}

fn netlify_config(config: &PublishManifest, site: &str) -> Result<NetlifyConfig> {
    config
        .distribution
        .as_ref()
        .and_then(|distribution| distribution.netlify.get(site))
        .cloned()
        .with_context(|| format!("missing [distribution.netlify.{site}] in fission.toml"))
}

fn github_workflow_path(project_dir: &Path, _cfg: &GithubPagesConfig, workflow: &str) -> PathBuf {
    git_repo_root(project_dir)
        .unwrap_or_else(|| project_dir.to_path_buf())
        .join(".github/workflows")
        .join(workflow)
}

fn project_dir_argument_for_workflow(project_dir: &Path) -> String {
    let Some(repo_root) = git_repo_root(project_dir) else {
        return ".".to_string();
    };
    let Ok(project_dir) = fs::canonicalize(project_dir) else {
        return ".".to_string();
    };
    let Ok(repo_root) = fs::canonicalize(repo_root) else {
        return ".".to_string();
    };
    if project_dir == repo_root {
        ".".to_string()
    } else {
        project_dir
            .strip_prefix(&repo_root)
            .map(|path| path.to_string_lossy().replace('\\', "/"))
            .unwrap_or_else(|_| ".".to_string())
    }
}

fn render_github_pages_workflow(project_dir: &Path, cfg: &GithubPagesConfig) -> String {
    let branch = cfg.production_branch.as_deref().unwrap_or("main");
    let package_project_dir = project_dir_argument_for_workflow(project_dir);
    let artifact_path = if package_project_dir == "." {
        "target/fission/release/site/static".to_string()
    } else {
        format!("{package_project_dir}/target/fission/release/site/static")
    };
    format!(
        r#"name: Publish Fission site

on:
  push:
    branches:
      - {branch}
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: github-pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
    steps:
      - name: Check out repository
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Set up Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Build Fission static package
        run: fission package --project-dir {package_project_dir} --target site --format static --release

      - name: Upload GitHub Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: {artifact_path}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{{{ steps.deployment.outputs.page_url }}}}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
"#,
        branch = branch,
        package_project_dir = package_project_dir,
        artifact_path = artifact_path,
    )
}

fn write_receipt(project_dir: &Path, receipt: &DistributionReceipt) -> Result<()> {
    let dir = project_dir
        .join("target/fission/distribution")
        .join(&receipt.provider)
        .join(&receipt.site);
    fs::create_dir_all(&dir)?;
    let path = dir.join(format!(
        "{}-{}.json",
        receipt.action, receipt.created_at_unix_seconds
    ));
    fs::write(&path, serde_json::to_vec_pretty(receipt)?)
        .with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

fn print_readiness_report(report: &ReadinessReport) {
    println!("Readiness: {}", report.status);
    print_checks(&report.checks);
}

fn print_checks(checks: &[ReadinessCheck]) {
    for check in checks {
        println!(
            "[{:?}/{:?}] {} - {}",
            check.severity, check.status, check.id, check.summary
        );
        if let Some(details) = &check.details {
            println!("  {details}");
        }
        for remediation in &check.remediation {
            println!("  fix: {remediation}");
        }
    }
}

fn report_status(checks: &[ReadinessCheck]) -> &'static str {
    if checks
        .iter()
        .any(|check| check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed)
    {
        "blocked"
    } else if checks
        .iter()
        .any(|check| check.status == CheckStatus::Warning)
    {
        "warning"
    } else {
        "ready"
    }
}

fn check(
    id: impl Into<String>,
    severity: CheckSeverity,
    status: CheckStatus,
    summary: impl Into<String>,
    details: Option<String>,
    remediation: Vec<&str>,
) -> ReadinessCheck {
    ReadinessCheck {
        id: id.into(),
        severity,
        status,
        summary: summary.into(),
        details,
        remediation: remediation.into_iter().map(str::to_string).collect(),
    }
}

fn check_path(id: &str, path: PathBuf, summary: &str, remediation: &str) -> ReadinessCheck {
    check(
        id,
        CheckSeverity::Error,
        if path.exists() {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        summary,
        Some(path.display().to_string()),
        vec![remediation],
    )
}

fn required_value(
    id: &str,
    value: Option<&str>,
    summary: &str,
    remediation: &str,
) -> ReadinessCheck {
    check(
        id,
        CheckSeverity::Error,
        if value.is_some_and(|value| !value.trim().is_empty()) {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        summary,
        value.map(str::to_string),
        vec![remediation],
    )
}

fn required_provider_secret(
    id: &str,
    provider: DistributionProvider,
    env_names: &[&str],
    remediation: &str,
) -> ReadinessCheck {
    let env_name = env_names.iter().find(|name| env::var_os(name).is_some());
    let vault_present = credentials::provider_secret(provider, &[])
        .ok()
        .flatten()
        .is_some();
    check(
        id,
        CheckSeverity::Error,
        if env_name.is_some() || vault_present {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        "provider credentials are available",
        env_name
            .map(|name| format!("environment variable {name}"))
            .or_else(|| vault_present.then(|| "Fission release vault".to_string())),
        vec![remediation],
    )
}

fn base_path_check(id: &str, base_path: Option<&str>) -> ReadinessCheck {
    let value = base_path.unwrap_or("/");
    check(
        id,
        CheckSeverity::Warning,
        if value == "/" {
            CheckStatus::Passed
        } else {
            CheckStatus::Warning
        },
        "static hosting provider base path is root",
        Some(format!("base_path = {value}")),
        vec!["Dedicated static hosting providers usually serve production sites from `/`; use a non-root base path only when deliberately hosting below a subpath."],
    )
}

fn host_os_check(id: &str, expected: &str) -> ReadinessCheck {
    let current = env::consts::OS;
    check(
        id,
        CheckSeverity::Error,
        if current == expected {
            CheckStatus::Passed
        } else {
            CheckStatus::Failed
        },
        format!("host operating system is {expected}"),
        Some(format!("current host: {current}")),
        vec!["Run this package format on the platform that owns the native packaging/signing toolchain."],
    )
}

fn check_tool(id: &str, tool: &str, remediation: &str) -> ReadinessCheck {
    check(
        id,
        CheckSeverity::Error,
        if find_in_path(tool).is_some() {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        format!("{tool} is available on PATH"),
        find_in_path(tool).map(|path| path.display().to_string()),
        vec![remediation],
    )
}

fn check_any_tool(id: &str, tools: &[&str], summary: &str, remediation: &str) -> ReadinessCheck {
    let found = tools
        .iter()
        .find_map(|tool| find_in_path(tool).map(|path| (*tool, path)));
    check(
        id,
        CheckSeverity::Error,
        if found.is_some() {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        summary,
        found
            .map(|(tool, path)| format!("{tool}: {}", path.display()))
            .or_else(|| Some(format!("checked: {}", tools.join(", ")))),
        vec![remediation],
    )
}

fn check_any_env(id: &str, names: &[&str], summary: &str, remediation: &str) -> ReadinessCheck {
    let found = names.iter().find(|name| env::var_os(name).is_some());
    check(
        id,
        CheckSeverity::Error,
        if found.is_some() {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        summary,
        found.map(|name| format!("{name}={}", env::var(name).unwrap_or_default())),
        vec![remediation],
    )
}

fn check_env_or_tool(
    id: &str,
    env_names: &[&str],
    tools: &[&str],
    summary: &str,
    remediation: &str,
) -> ReadinessCheck {
    let found_env = env_names.iter().find(|name| env::var_os(name).is_some());
    let found_tool = tools
        .iter()
        .find_map(|tool| find_in_path(tool).map(|path| (*tool, path)));
    check(
        id,
        CheckSeverity::Error,
        if found_env.is_some() || found_tool.is_some() {
            CheckStatus::Passed
        } else {
            CheckStatus::Missing
        },
        summary,
        found_env
            .map(|name| format!("{name}={}", env::var(name).unwrap_or_default()))
            .or_else(|| found_tool.map(|(tool, path)| format!("{tool}: {}", path.display()))),
        vec![remediation],
    )
}

fn find_in_path(name: &str) -> Option<PathBuf> {
    let path = env::var_os("PATH")?;
    for dir in env::split_paths(&path) {
        let candidate = dir.join(name);
        if candidate.exists() {
            return Some(candidate);
        }
        if cfg!(windows) {
            for extension in ["exe", "cmd", "bat", "ps1"] {
                let candidate = dir.join(format!("{name}.{extension}"));
                if candidate.exists() {
                    return Some(candidate);
                }
            }
        }
    }
    None
}

fn now_unix_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn cargo_package_version(project_dir: &Path) -> Option<String> {
    let data = fs::read_to_string(project_dir.join("Cargo.toml")).ok()?;
    let value: toml::Value = toml::from_str(&data).ok()?;
    value
        .get("package")
        .and_then(|package| package.get("version"))
        .and_then(|version| version.as_str())
        .map(str::to_string)
}

fn resolve_project_path(project_dir: &Path, path: String) -> PathBuf {
    let path = PathBuf::from(path);
    if path.is_absolute() {
        path
    } else {
        project_dir.join(path)
    }
}

fn non_empty(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let trimmed = value.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_string())
    })
}

fn expected_github_base_path(cfg: &GithubPagesConfig, repo: Option<&str>) -> String {
    if cfg
        .custom_domain
        .as_deref()
        .is_some_and(|value| !value.trim().is_empty())
    {
        "/".to_string()
    } else if cfg.site_kind.as_deref() == Some("user")
        || cfg.site_kind.as_deref() == Some("organization")
    {
        "/".to_string()
    } else {
        repo.map(|repo| format!("/{repo}/"))
            .unwrap_or_else(|| "/".to_string())
    }
}

fn github_pages_url(
    cfg: &GithubPagesConfig,
    owner: Option<&str>,
    repo: Option<&str>,
) -> Option<String> {
    if let Some(domain) = cfg
        .custom_domain
        .as_ref()
        .filter(|value| !value.trim().is_empty())
    {
        return Some(format!("https://{}", domain.trim()));
    }
    let owner = owner?;
    if cfg.site_kind.as_deref() == Some("user") || cfg.site_kind.as_deref() == Some("organization")
    {
        Some(format!("https://{owner}.github.io/"))
    } else {
        repo.map(|repo| format!("https://{owner}.github.io/{repo}/"))
    }
}

fn cloudflare_url(cfg: &CloudflarePagesConfig) -> Option<String> {
    if let Some(domain) = cfg
        .custom_domain
        .as_ref()
        .filter(|value| !value.trim().is_empty())
    {
        Some(format!("https://{}", domain.trim()))
    } else {
        cfg.project_name
            .as_ref()
            .map(|name| format!("https://{name}.pages.dev"))
    }
}

fn github_pages_follow_up(cfg: &GithubPagesConfig) -> Vec<String> {
    let mut follow_up = Vec::new();
    if cfg
        .custom_domain
        .as_deref()
        .filter(|value| !value.is_empty())
        .is_some()
    {
        follow_up.push(
            "Verify the GitHub Pages custom domain and HTTPS state in repository settings."
                .to_string(),
        );
    }
    follow_up
}

fn infer_github_owner(project_dir: &Path) -> Option<String> {
    parse_github_remote(project_dir).map(|(owner, _)| owner)
}

fn infer_github_repo(project_dir: &Path) -> Option<String> {
    parse_github_remote(project_dir).map(|(_, repo)| repo)
}

fn parse_github_remote(project_dir: &Path) -> Option<(String, String)> {
    let remote = git_output(project_dir, ["remote", "get-url", "origin"]).ok()?;
    let remote = remote.trim().trim_end_matches(".git");
    if let Some(rest) = remote.strip_prefix("git@github.com:") {
        let (owner, repo) = rest.split_once('/')?;
        return Some((owner.to_string(), repo.to_string()));
    }
    if let Some(rest) = remote.strip_prefix("https://github.com/") {
        let (owner, repo) = rest.split_once('/')?;
        return Some((owner.to_string(), repo.to_string()));
    }
    None
}

fn git_repo_root(project_dir: &Path) -> Option<PathBuf> {
    git_output(project_dir, ["rev-parse", "--show-toplevel"])
        .ok()
        .map(|value| PathBuf::from(value.trim()))
}

fn git_output<'a, I>(dir: &Path, args: I) -> Result<String>
where
    I: IntoIterator<Item = &'a str>,
{
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .context("failed to run git")?;
    if !output.status.success() {
        bail!(
            "git command failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

fn run_git<'a, I>(dir: &Path, args: I) -> Result<()>
where
    I: IntoIterator<Item = &'a str>,
{
    let status = Command::new("git")
        .args(args)
        .current_dir(dir)
        .status()
        .context("failed to run git")?;
    if !status.success() {
        bail!("git command failed with {status}");
    }
    Ok(())
}

fn first_url(text: &str) -> Option<String> {
    text.split_whitespace()
        .find(|part| part.starts_with("https://") || part.starts_with("http://"))
        .map(|value| {
            value
                .trim_matches(|c| c == ',' || c == ')' || c == '(')
                .to_string()
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn unique_dir(name: &str) -> PathBuf {
        let dir =
            std::env::temp_dir().join(format!("fission-publish-{}-{}", name, std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        dir
    }

    fn write_minimal_site(dir: &Path) {
        fs::create_dir_all(dir.join("content")).unwrap();
        fs::write(
            dir.join("fission.toml"),
            r#"targets = ["site"]

[app]
name = "site-demo"
app_id = "com.example.site_demo"

[site]
title = "Site Demo"
out_dir = "dist/site"
generate_sitemap = false
generate_robots = false

[distribution.github_pages.production]
owner = "example"
repo = "site-demo"
mode = "actions"
site_kind = "project"
base_path = "/site-demo/"

[distribution.github_releases.production]
owner = "example"
repo = "site-demo"
tag = "v1.2.3"
name = "Site Demo 1.2.3"
draft = true
prerelease = false
replace_assets = true
upload_artifact_manifest = true
"#,
        )
        .unwrap();
        fs::write(
            dir.join("content/index.md"),
            "---\ntitle: Home\n---\n# Home\n",
        )
        .unwrap();
    }

    #[test]
    fn static_package_builds_artifact_manifest() {
        let dir = unique_dir("package");
        write_minimal_site(&dir);
        let manifest = package::package_static(&PackageOptions {
            project_dir: dir.clone(),
            target: Target::Site,
            format: PackageFormat::Static,
            release: true,
            json: false,
        })
        .unwrap();
        assert_eq!(manifest.target, "site");
        assert!(dir
            .join("target/fission/release/site/static/artifact-manifest.json")
            .exists());
        assert!(manifest
            .artifacts
            .iter()
            .any(|file| file.relative_path == "index.html"));
        assert!(dir
            .join("target/fission/release/site/static/fission-route-manifest.json")
            .exists());
        assert!(dir
            .join("target/fission/release/site/static/fission-mime-map.json")
            .exists());
        assert!(manifest
            .validation
            .checks
            .iter()
            .any(|check| check.id == "release.package.artifact.primary_present"));
    }

    #[test]
    fn static_package_includes_configured_secondary_artifacts() {
        let dir = unique_dir("secondary-artifacts");
        write_minimal_site(&dir);
        fs::create_dir_all(dir.join("release-content/symbols")).unwrap();
        fs::write(dir.join("release-content/symbols/app.dSYM.zip"), b"symbols").unwrap();
        let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
        toml.push_str(
            r#"
[[package.symbols]]
path = "release-content/symbols/app.dSYM.zip"
platform = "ios"
upload_provider = "crash-service"
"#,
        );
        fs::write(dir.join("fission.toml"), toml).unwrap();

        let manifest = package::package_static(&PackageOptions {
            project_dir: dir.clone(),
            target: Target::Site,
            format: PackageFormat::Static,
            release: true,
            json: false,
        })
        .unwrap();

        let symbols = manifest
            .artifacts
            .iter()
            .find(|file| file.kind == "debug_symbols")
            .expect("debug symbols should be present");
        assert_eq!(symbols.platform.as_deref(), Some("ios"));
        assert_eq!(symbols.upload_provider.as_deref(), Some("crash-service"));
    }

    #[test]
    fn github_pages_setup_writes_workflow() {
        let dir = unique_dir("github-setup");
        write_minimal_site(&dir);
        let config = load_publish_manifest(&dir).unwrap();
        setup_github_pages(
            &DistributeOptions {
                project_dir: dir.clone(),
                provider: DistributionProvider::GithubPages,
                action: DistributeAction::Setup,
                artifact: None,
                site: "production".to_string(),
                deploy: None,
                track: None,
                dry_run: false,
                yes: true,
                json: false,
            },
            &config,
        )
        .unwrap();
        let workflow = fs::read_to_string(dir.join(".github/workflows/fission-pages.yml")).unwrap();
        assert!(workflow.contains("actions/upload-pages-artifact"));
        assert!(workflow.contains("actions/deploy-pages"));
        assert!(workflow.contains("fission package"));
    }

    #[test]
    fn github_base_path_accounts_for_custom_domain() {
        let cfg = GithubPagesConfig {
            custom_domain: Some("docs.example.com".to_string()),
            repo: Some("repo".to_string()),
            ..Default::default()
        };
        assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/");
        let cfg = GithubPagesConfig {
            repo: Some("repo".to_string()),
            ..Default::default()
        };
        assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/repo/");
    }

    #[test]
    fn android_aab_readiness_checks_official_toolchain() {
        let dir = unique_dir("android-aab-readiness");
        write_minimal_site(&dir);
        let checks = readiness_package(&dir, Some(Target::Android), Some(PackageFormat::Aab))
            .expect("readiness should produce checks even when blocked");
        for id in [
            "release.package.android_aab_script_exists",
            "release.package.android_sdk_configured",
            "release.package.android_ndk_configured",
            "release.package.aapt2_available",
            "release.package.zipalign_available",
            "release.package.apksigner_available",
            "release.package.bundletool_available",
        ] {
            assert!(checks.iter().any(|check| check.id == id), "missing {id}");
        }
    }

    #[test]
    fn cloudflare_readiness_requires_wrangler_backend() {
        let dir = unique_dir("cloudflare-readiness");
        write_minimal_site(&dir);
        let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
        toml.push_str(
            r#"
[distribution.cloudflare_pages.production]
account_id = "account"
project_name = "site-demo"
"#,
        );
        fs::write(dir.join("fission.toml"), toml).unwrap();
        let config = load_publish_manifest(&dir).unwrap();
        let checks = readiness_distribute(
            &dir,
            DistributionProvider::CloudflarePages,
            "production",
            None,
            None,
            &config,
        )
        .unwrap();
        assert!(checks
            .iter()
            .any(|check| check.id == "release.cloudflare_pages.wrangler_available"));
    }

    #[test]
    fn github_releases_readiness_is_not_static_site_specific() {
        let dir = unique_dir("github-releases-readiness");
        write_minimal_site(&dir);
        let artifact_root = dir.join("target/fission/release/linux/run");
        fs::create_dir_all(&artifact_root).unwrap();
        let binary = artifact_root.join("site-demo.run");
        fs::write(&binary, b"run").unwrap();
        let manifest_path = artifact_root.join(ARTIFACT_MANIFEST);
        fs::write(
            &manifest_path,
            serde_json::to_vec_pretty(&ArtifactManifest {
                schema_version: 1,
                created_at_unix_seconds: 0,
                project: ArtifactProject {
                    app_id: "com.example.site_demo".to_string(),
                    name: "site-demo".to_string(),
                    version: Some("1.2.3".to_string()),
                },
                target: "linux".to_string(),
                format: "run".to_string(),
                profile: "release".to_string(),
                root_dir: artifact_root.display().to_string(),
                artifacts: vec![ArtifactFile {
                    kind: "asset".to_string(),
                    purpose: None,
                    platform: None,
                    upload_provider: None,
                    path: binary.display().to_string(),
                    relative_path: "site-demo.run".to_string(),
                    sha256: "abc".to_string(),
                    size_bytes: 3,
                    mime_type: "application/octet-stream".to_string(),
                }],
                validation: ArtifactValidation {
                    state: "passed".to_string(),
                    checks: Vec::new(),
                },
            })
            .unwrap(),
        )
        .unwrap();
        let config = load_publish_manifest(&dir).unwrap();
        let checks = readiness_distribute(
            &dir,
            DistributionProvider::GithubReleases,
            "production",
            None,
            Some(&manifest_path),
            &config,
        )
        .unwrap();
        assert!(checks.iter().any(|check| {
            check.id == "release.github_releases.assets_available"
                && check.status == CheckStatus::Passed
        }));
        assert!(!checks
            .iter()
            .any(|check| check.id == "release.distribution.static_root_exists"));
    }

    #[test]
    fn microsoft_store_msix_readiness_uses_msstore_not_package_url() {
        let dir = unique_dir("microsoft-msix-readiness");
        write_minimal_site(&dir);
        let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap();
        toml.push_str(
            r#"
[distribution.microsoft_store]
product_id = "9N1234567890"
package_identity_name = "Example.SiteDemo"
package_type = "msix"
"#,
        );
        fs::write(dir.join("fission.toml"), toml).unwrap();

        let artifact_root = dir.join("target/fission/release/windows/msix");
        fs::create_dir_all(&artifact_root).unwrap();
        let package = artifact_root.join("site-demo.msixupload");
        fs::write(&package, b"msix").unwrap();
        let manifest_path = artifact_root.join(ARTIFACT_MANIFEST);
        fs::write(
            &manifest_path,
            serde_json::to_vec_pretty(&ArtifactManifest {
                schema_version: 1,
                created_at_unix_seconds: 0,
                project: ArtifactProject {
                    app_id: "com.example.site_demo".to_string(),
                    name: "site-demo".to_string(),
                    version: Some("1.2.3".to_string()),
                },
                target: "windows".to_string(),
                format: "msix".to_string(),
                profile: "release".to_string(),
                root_dir: artifact_root.display().to_string(),
                artifacts: vec![ArtifactFile {
                    kind: "installer".to_string(),
                    purpose: Some("store-upload".to_string()),
                    platform: Some("windows".to_string()),
                    upload_provider: Some("microsoft-store".to_string()),
                    path: package.display().to_string(),
                    relative_path: "site-demo.msixupload".to_string(),
                    sha256: "abc".to_string(),
                    size_bytes: 4,
                    mime_type: "application/vnd.ms-appx".to_string(),
                }],
                validation: ArtifactValidation {
                    state: "passed".to_string(),
                    checks: Vec::new(),
                },
            })
            .unwrap(),
        )
        .unwrap();

        let config = load_publish_manifest(&dir).unwrap();
        let checks = readiness_distribute(
            &dir,
            DistributionProvider::MicrosoftStore,
            "production",
            None,
            Some(&manifest_path),
            &config,
        )
        .unwrap();

        assert!(checks
            .iter()
            .any(|check| check.id == "release.microsoft_store.msstore_available"));
        assert!(checks.iter().any(|check| {
            check.id == "release.microsoft_store.msix_upload_artifact_present"
                && check.status == CheckStatus::Passed
        }));
        assert!(!checks
            .iter()
            .any(|check| check.id == "release.microsoft_store.package_url_configured"));
    }
}
