use std::path::{Component, Path, PathBuf};

use anyhow::{Context, Result};
use indexmap::IndexMap;
use log::warn;
use serde::Deserialize;

use crate::parser::gitlab::fetch::cache_dir;

/// In-container path where cached actions are mounted.
pub const ACTIONS_MOUNT_POINT: &str = "/tmp/actions-cache";

// ---------------------------------------------------------------------------
// action.yml types
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields are deserialized from YAML but not all are read
pub struct ActionMetadata {
    pub name: Option<String>,
    #[serde(default)]
    pub inputs: IndexMap<String, ActionInput>,
    pub runs: ActionRuns,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields are deserialized from YAML but not all are read
pub struct ActionInput {
    pub description: Option<String>,
    pub default: Option<String>,
    #[serde(default)]
    pub required: bool,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "using")]
#[allow(dead_code)] // Fields are deserialized from YAML but not all are read
pub enum ActionRuns {
    #[serde(rename = "node20", alias = "node16", alias = "node12")]
    Node {
        main: String,
        #[serde(default)]
        pre: Option<String>,
        #[serde(default)]
        post: Option<String>,
    },
    #[serde(rename = "composite")]
    Composite { steps: Vec<super::loader::GhStep> },
    #[serde(rename = "docker")]
    Docker {
        image: String,
        #[serde(default)]
        entrypoint: Option<String>,
        #[serde(default)]
        args: Option<Vec<String>>,
    },
}

// ---------------------------------------------------------------------------
// uses: reference parsing
// ---------------------------------------------------------------------------

/// Parsed components of a `uses:` reference like `owner/repo/path@ref`.
#[derive(Debug, Clone)]
pub struct UsesRef {
    pub owner: String,
    pub repo: String,
    pub subpath: Option<String>,
    pub git_ref: String,
}

/// Parse a `uses:` value like `actions/checkout@v4` or `actions/cache/save@v4`.
/// Returns None for local references (`./.github/...`) or invalid formats.
pub fn parse_uses_ref(uses: &str) -> Option<UsesRef> {
    // Skip local references
    if uses.starts_with("./") || uses.starts_with("docker://") {
        return None;
    }
    let (path_part, git_ref) = uses.split_once('@')?;
    let mut parts = path_part.splitn(3, '/');
    let owner = parts.next()?.to_string();
    let repo = parts.next()?.to_string();
    let subpath = parts.next().map(|s| s.to_string());
    Some(UsesRef {
        owner,
        repo,
        subpath,
        git_ref: git_ref.to_string(),
    })
}

// ---------------------------------------------------------------------------
// Cache directory helpers
// ---------------------------------------------------------------------------

/// Return the host path to the actions cache directory.
pub fn actions_cache_dir() -> Option<PathBuf> {
    Some(cache_dir()?.join("github-actions"))
}

/// Sanitize a path component for use in cache directory construction.
/// Strips `..`, absolute-path prefixes, and path separators to prevent
/// a crafted `uses:` reference from escaping the cache directory.
fn sanitize_path_component(s: &str) -> String {
    Path::new(s)
        .components()
        .filter(|c| matches!(c, Component::Normal(_)))
        .collect::<PathBuf>()
        .to_string_lossy()
        .into_owned()
}

/// Return the in-container path for a given action.
pub fn container_action_path(r: &UsesRef) -> String {
    let mut path = format!(
        "{}/{}/{}/{}",
        ACTIONS_MOUNT_POINT,
        sanitize_path_component(&r.owner),
        sanitize_path_component(&r.repo),
        sanitize_path_component(&r.git_ref),
    );
    if let Some(ref sub) = r.subpath {
        path = format!("{}/{}", path, sanitize_path_component(sub));
    }
    path
}

// ---------------------------------------------------------------------------
// Fetch and extract
// ---------------------------------------------------------------------------

/// Fetch an action repository tarball and extract it to the cache.
/// Returns the host-side path to the cached action directory.
pub fn fetch_action(r: &UsesRef) -> Result<PathBuf> {
    // For subpath actions (e.g. actions/cache/save@v4), the tarball is
    // extracted to owner/repo/ref/ and action.yml lives in a subdirectory.
    // Cache the whole repo, then return the subpath.
    let repo_dest = actions_cache_dir()
        .context("cannot determine cache directory")?
        .join(sanitize_path_component(&r.owner))
        .join(sanitize_path_component(&r.repo))
        .join(sanitize_path_component(&r.git_ref));

    let dest = match r.subpath {
        Some(ref sub) => repo_dest.join(sanitize_path_component(sub)),
        None => repo_dest.clone(),
    };

    // Check for action.yml/action.yaml to determine if already cached
    if dest.join("action.yml").exists() || dest.join("action.yaml").exists() {
        return Ok(dest);
    }
    // Also check the repo root (may already be extracted for a different subpath)
    if repo_dest.join("action.yml").exists() || repo_dest.join("action.yaml").exists() {
        return Ok(dest);
    }

    let short_ref = if r.git_ref.len() > 12 {
        &r.git_ref[..12]
    } else {
        &r.git_ref
    };
    eprintln!("Fetching action {}/{}@{}...", r.owner, r.repo, short_ref);

    let url = format!(
        "https://api.github.com/repos/{}/{}/tarball/{}",
        r.owner, r.repo, r.git_ref
    );

    let mut request = reqwest::blocking::Client::new()
        .get(&url)
        .header("User-Agent", "cui-cui");

    // Use GITHUB_TOKEN for authentication if available
    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
        request = request.header("Authorization", format!("Bearer {}", token));
    }

    let response = request.send().context("failed to fetch action tarball")?;

    if !response.status().is_success() {
        anyhow::bail!(
            "failed to fetch action {}/{} (HTTP {})",
            r.owner,
            r.repo,
            response.status()
        );
    }

    let bytes = crate::parser::gitlab::fetch::read_response_bytes(
        response,
        crate::parser::gitlab::fetch::MAX_DOWNLOAD_SIZE,
    )
    .context("failed to read tarball body")?;

    extract_tarball(&bytes, &repo_dest)?;

    Ok(dest)
}

// ---------------------------------------------------------------------------
// Tarball extraction (with path traversal protection)
// ---------------------------------------------------------------------------

/// Extract a gzipped tarball into `dest_dir`, stripping the first path component
/// (GitHub tarballs wrap contents in an `owner-repo-sha/` directory).
/// Rejects entries with path traversal (`..`) or absolute paths.
fn extract_tarball(data: &[u8], dest_dir: &Path) -> Result<()> {
    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(data));
    let mut archive = tar::Archive::new(decoder);

    std::fs::create_dir_all(dest_dir).context("failed to create action cache dir")?;

    for entry in archive
        .entries()
        .context("failed to read tarball entries")?
    {
        let mut entry = entry.context("bad tarball entry")?;
        let raw_path = entry.path().context("bad entry path")?.into_owned();

        // Strip the first path component (e.g. `owner-repo-abc123/`)
        let stripped: PathBuf = raw_path.components().skip(1).collect();
        if stripped.as_os_str().is_empty() {
            continue;
        }

        // Reject path traversal attempts (absolute paths or `..` components)
        if stripped.is_absolute() || stripped.components().any(|c| c == Component::ParentDir) {
            warn!(
                "Skipping tarball entry with unsafe path: {}",
                stripped.display()
            );
            continue;
        }

        let out_path = dest_dir.join(&stripped);
        if let Some(parent) = out_path.parent() {
            std::fs::create_dir_all(parent).ok();
        }

        if entry.header().entry_type().is_dir() {
            std::fs::create_dir_all(&out_path).ok();
        } else {
            let mut file = std::fs::File::create(&out_path)
                .with_context(|| format!("failed to create {}", out_path.display()))?;
            std::io::copy(&mut entry, &mut file)
                .with_context(|| format!("failed to write {}", out_path.display()))?;
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Parse action.yml
// ---------------------------------------------------------------------------

/// Read and parse `action.yml` (or `action.yaml`) from a cached action directory.
pub fn read_action_metadata(action_dir: &Path) -> Result<ActionMetadata> {
    let yml_path = action_dir.join("action.yml");
    let yaml_path = action_dir.join("action.yaml");
    let path = if yml_path.exists() {
        yml_path
    } else if yaml_path.exists() {
        yaml_path
    } else {
        anyhow::bail!(
            "no action.yml or action.yaml found in {}",
            action_dir.display()
        );
    };

    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;

    crate::parser::check_yaml_size(&content, &path.display().to_string())?;
    serde_yaml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))
}

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

    #[test]
    fn test_parse_uses_ref_simple() {
        let r = parse_uses_ref("actions/checkout@v4").unwrap();
        assert_eq!(r.owner, "actions");
        assert_eq!(r.repo, "checkout");
        assert_eq!(r.git_ref, "v4");
        assert!(r.subpath.is_none());
    }

    #[test]
    fn test_parse_uses_ref_with_sha() {
        let r = parse_uses_ref("snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79")
            .unwrap();
        assert_eq!(r.owner, "snapcore");
        assert_eq!(r.repo, "action-build");
        assert_eq!(r.git_ref, "3bdaa03e1ba6bf59a65f84a751d943d549a54e79");
    }

    #[test]
    fn test_parse_uses_ref_with_subpath() {
        let r = parse_uses_ref("actions/cache/save@v4").unwrap();
        assert_eq!(r.owner, "actions");
        assert_eq!(r.repo, "cache");
        assert_eq!(r.subpath.as_deref(), Some("save"));
        assert_eq!(r.git_ref, "v4");
    }

    #[test]
    fn test_parse_uses_ref_local_returns_none() {
        assert!(parse_uses_ref("./.github/workflows/snap.yml").is_none());
    }

    #[test]
    fn test_parse_uses_ref_docker_returns_none() {
        assert!(parse_uses_ref("docker://alpine:3").is_none());
    }

    #[test]
    fn test_container_action_path() {
        let r = UsesRef {
            owner: "actions".to_string(),
            repo: "checkout".to_string(),
            subpath: None,
            git_ref: "v4".to_string(),
        };
        assert_eq!(
            container_action_path(&r),
            "/tmp/actions-cache/actions/checkout/v4"
        );
    }

    #[test]
    fn test_sanitize_path_component_strips_traversal() {
        assert_eq!(sanitize_path_component("v4"), "v4");
        assert_eq!(sanitize_path_component("../../etc"), "etc");
        assert_eq!(sanitize_path_component("../.."), "");
        assert_eq!(sanitize_path_component("/absolute/path"), "absolute/path");
    }

    #[test]
    fn test_container_action_path_traversal_sanitized() {
        let r = UsesRef {
            owner: "actions".to_string(),
            repo: "checkout".to_string(),
            subpath: None,
            git_ref: "../../etc".to_string(),
        };
        assert_eq!(
            container_action_path(&r),
            "/tmp/actions-cache/actions/checkout/etc"
        );
    }

    #[test]
    fn test_container_action_path_with_subpath() {
        let r = UsesRef {
            owner: "actions".to_string(),
            repo: "cache".to_string(),
            subpath: Some("save".to_string()),
            git_ref: "v4".to_string(),
        };
        assert_eq!(
            container_action_path(&r),
            "/tmp/actions-cache/actions/cache/v4/save"
        );
    }

    #[test]
    fn test_parse_action_metadata_node() {
        let yml = r#"
name: 'Snapcraft Build'
inputs:
  path:
    description: 'Project location'
    default: '.'
  build-info:
    description: 'Include build info'
    default: 'true'
runs:
  using: 'node20'
  main: 'dist/index.js'
"#;
        let meta: ActionMetadata = serde_yaml::from_str(yml).unwrap();
        assert_eq!(meta.name.as_deref(), Some("Snapcraft Build"));
        assert_eq!(meta.inputs.len(), 2);
        assert_eq!(meta.inputs["path"].default.as_deref(), Some("."));
        assert!(matches!(meta.runs, ActionRuns::Node { ref main, .. } if main == "dist/index.js"));
    }

    #[test]
    fn test_parse_action_metadata_composite() {
        let yml = r#"
name: 'My Composite'
runs:
  using: 'composite'
  steps:
    - run: echo hello
      shell: bash
    - uses: actions/checkout@v4
"#;
        let meta: ActionMetadata = serde_yaml::from_str(yml).unwrap();
        assert!(matches!(meta.runs, ActionRuns::Composite { ref steps } if steps.len() == 2));
    }

    #[test]
    fn test_parse_action_metadata_docker() {
        let yml = r#"
name: 'Docker Action'
runs:
  using: 'docker'
  image: 'Dockerfile'
"#;
        let meta: ActionMetadata = serde_yaml::from_str(yml).unwrap();
        assert!(matches!(meta.runs, ActionRuns::Docker { ref image, .. } if image == "Dockerfile"));
    }

    /// Build a gzipped tarball in memory from a list of (path, content) pairs.
    /// Writes path bytes directly into the tar header to allow crafting
    /// malicious paths with `..` that the tar crate's API would reject.
    fn build_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
        let mut tar_data = Vec::new();
        for (path, content) in entries {
            let mut header_bytes = [0u8; 512];

            // Write path into name field (offset 0, 100 bytes max)
            let path_bytes = path.as_bytes();
            let len = path_bytes.len().min(100);
            header_bytes[..len].copy_from_slice(&path_bytes[..len]);

            // File mode "0000644\0" at offset 100
            header_bytes[100..108].copy_from_slice(b"0000644\0");

            // Owner/group ID "0000000\0" at offset 108 and 116
            header_bytes[108..116].copy_from_slice(b"0000000\0");
            header_bytes[116..124].copy_from_slice(b"0000000\0");

            // File size in octal at offset 124 (11 digits + NUL)
            let size_str = format!("{:011o}\0", content.len());
            header_bytes[124..136].copy_from_slice(size_str.as_bytes());

            // Mtime "00000000000\0" at offset 136
            header_bytes[136..148].copy_from_slice(b"00000000000\0");

            // Type flag '0' (regular file) at offset 156
            header_bytes[156] = b'0';

            // UStar magic at offset 257
            header_bytes[257..263].copy_from_slice(b"ustar\0");
            header_bytes[263..265].copy_from_slice(b"00");

            // Compute checksum: sum of all bytes with checksum field as spaces
            header_bytes[148..156].copy_from_slice(b"        ");
            let cksum: u32 = header_bytes.iter().map(|&b| b as u32).sum();
            let cksum_str = format!("{:06o}\0 ", cksum);
            header_bytes[148..156].copy_from_slice(cksum_str.as_bytes());

            tar_data.extend_from_slice(&header_bytes);
            tar_data.extend_from_slice(content);
            // Pad to 512-byte boundary
            let padding = (512 - (content.len() % 512)) % 512;
            tar_data.extend(std::iter::repeat(0u8).take(padding));
        }
        // Two zero blocks mark end of archive
        tar_data.extend(std::iter::repeat(0u8).take(1024));

        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        std::io::Write::write_all(&mut encoder, &tar_data).unwrap();
        encoder.finish().unwrap()
    }

    #[test]
    fn test_extract_tarball_normal() {
        let dir = tempfile::tempdir().unwrap();
        let data = build_tar_gz(&[
            ("owner-repo-abc123/action.yml", b"name: test"),
            ("owner-repo-abc123/src/main.js", b"console.log('hi')"),
        ]);
        extract_tarball(&data, dir.path()).unwrap();
        assert_eq!(
            std::fs::read_to_string(dir.path().join("action.yml")).unwrap(),
            "name: test"
        );
        assert_eq!(
            std::fs::read_to_string(dir.path().join("src/main.js")).unwrap(),
            "console.log('hi')"
        );
    }

    #[test]
    fn test_extract_tarball_skips_parent_dir_traversal() {
        let dir = tempfile::tempdir().unwrap();
        let data = build_tar_gz(&[
            ("owner-repo-abc123/action.yml", b"name: legit"),
            ("owner-repo-abc123/../../../etc/evil", b"pwned"),
            ("owner-repo-abc123/sub/../../escape", b"pwned2"),
        ]);
        extract_tarball(&data, dir.path()).unwrap();
        // Legitimate file extracted
        assert!(dir.path().join("action.yml").exists());
        // Traversal entries skipped — files do not exist anywhere
        assert!(!dir.path().join("etc/evil").exists());
        assert!(!dir.path().join("escape").exists());
        assert!(!Path::new("/etc/evil").exists());
    }

    #[test]
    fn test_extract_tarball_absolute_path_stripped_to_relative() {
        let dir = tempfile::tempdir().unwrap();
        // A raw path starting with "/" — components() = [RootDir, "etc", "passwd"]
        // skip(1) strips RootDir → "etc/passwd" (relative, safe).
        let data = build_tar_gz(&[
            ("prefix/action.yml", b"name: ok"),
            ("/etc/passwd", b"not really"),
        ]);
        extract_tarball(&data, dir.path()).unwrap();
        assert!(dir.path().join("action.yml").exists());
        // "/etc/passwd" → skip(1) strips RootDir → "etc/passwd" inside dest_dir
        assert!(dir.path().join("etc/passwd").exists());
        assert_eq!(
            std::fs::read_to_string(dir.path().join("etc/passwd")).unwrap(),
            "not really"
        );
    }

    #[test]
    fn test_extract_tarball_strips_first_component() {
        let dir = tempfile::tempdir().unwrap();
        let data = build_tar_gz(&[("some-prefix-dir/nested/deep/file.txt", b"hello")]);
        extract_tarball(&data, dir.path()).unwrap();
        // First component stripped: "some-prefix-dir/nested/deep/file.txt" -> "nested/deep/file.txt"
        assert_eq!(
            std::fs::read_to_string(dir.path().join("nested/deep/file.txt")).unwrap(),
            "hello"
        );
    }

    #[test]
    fn test_extract_tarball_skips_toplevel_only_entries() {
        let dir = tempfile::tempdir().unwrap();
        // An entry with only the top-level dir (no content beneath) should be skipped
        let data = build_tar_gz(&[
            ("owner-repo-abc123/", b""),
            ("owner-repo-abc123/real.txt", b"content"),
        ]);
        extract_tarball(&data, dir.path()).unwrap();
        assert!(dir.path().join("real.txt").exists());
    }

    #[test]
    fn test_extract_tarball_empty_archive() {
        let dir = tempfile::tempdir().unwrap();
        let data = build_tar_gz(&[]);
        extract_tarball(&data, dir.path()).unwrap();
        // No files extracted, directory exists
        assert!(dir.path().exists());
    }

    #[test]
    fn test_extract_tarball_double_dot_in_middle() {
        let dir = tempfile::tempdir().unwrap();
        let data = build_tar_gz(&[
            ("prefix/a/b/../../secret", b"nope"),
            ("prefix/legit.txt", b"yes"),
        ]);
        extract_tarball(&data, dir.path()).unwrap();
        assert!(dir.path().join("legit.txt").exists());
        assert!(!dir.path().join("secret").exists());
        assert!(!dir.path().join("a/b/../../secret").exists());
    }
}
