use crate::pyproject_toml::Format;
use crate::{ModuleWriter, PyProjectToml, SDistWriter, VirtualWriter};
use anyhow::{Context, Result, bail};
use normpath::PathExt as _;
use path_slash::PathExt as _;
use pyproject_toml::check_pep639_glob;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use tracing::{debug, trace};

use super::SdistContext;
use super::cargo_toml_rewrite::parse_toml_file;
use super::utils::is_compiled_artifact;

pub(super) type PyprojectPathRewrite = (&'static str, String);

fn parent_relative_metadata_path(field: &str, path: &Path) -> Result<bool> {
    if path
        .components()
        .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
    {
        bail!(
            "`project.{field}.file` path `{}` must be relative to pyproject.toml",
            path.display()
        );
    }
    Ok(path.components().any(|c| matches!(c, Component::ParentDir)))
}

fn project_metadata_paths(
    project: &pyproject_toml::Project,
) -> Vec<(&'static str, &'static str, &Path)> {
    let mut paths = Vec::new();
    match project.readme.as_ref() {
        Some(pyproject_toml::ReadMe::RelativePath(file)) => {
            paths.push(("readme", "project.readme", Path::new(file.as_str())));
        }
        Some(pyproject_toml::ReadMe::Table {
            file: Some(file), ..
        }) => {
            paths.push(("readme", "project.readme.file", Path::new(file.as_str())));
        }
        _ => {}
    }
    if let Some(pyproject_toml::License::File { file }) = project.license.as_ref() {
        paths.push(("license", "project.license.file", file.as_path()));
    }
    paths
}

/// Add readme/license files referenced from `[project]` metadata.
///
/// Because the generated `pyproject.toml` always lives at the sdist root, any
/// parent-relative metadata path must be rewritten to the referenced file's
/// archive path. The source is still constrained by `allowed_metadata_root`.
pub(super) fn add_pyproject_metadata_files(
    writer: &mut VirtualWriter<SDistWriter>,
    pyproject: &PyProjectToml,
    ctx: &SdistContext<'_>,
    allowed_metadata_root: &Path,
) -> Result<Vec<PyprojectPathRewrite>> {
    let mut rewrites = Vec::new();
    let Some(project) = pyproject.project.as_ref() else {
        return Ok(rewrites);
    };
    let allowed_metadata_root = allowed_metadata_root.normalize()?.into_path_buf();
    let project_root = ctx.project_root.normalize()?.into_path_buf();

    for (field, field_name, path) in project_metadata_paths(project) {
        let mut source = ctx.pyproject_dir.join(path);
        let mut target_relative = path.to_path_buf();
        if parent_relative_metadata_path(field_name, path)? {
            if ctx.pyproject_dir == ctx.sdist_root {
                bail!(
                    "`{field_name}` path `{}` must not contain `..` when pyproject.toml is already at the sdist root",
                    path.display()
                );
            }
            source = source.normalize()?.into_path_buf();
            if !source.starts_with(allowed_metadata_root.as_path()) {
                bail!(
                    "`{field_name}` path `{}` resolves outside allowed metadata root `{}`",
                    path.display(),
                    allowed_metadata_root.display()
                );
            }
            target_relative = source
                .strip_prefix(&project_root)
                .with_context(|| {
                    format!(
                        "`{field_name}` path `{}` resolves outside sdist project root `{}`",
                        path.display(),
                        project_root.display()
                    )
                })?
                .to_path_buf();
            let rewrite_path = target_relative
                .to_slash()
                .with_context(|| {
                    format!(
                        "`{field_name}` path `{}` is not valid UTF-8",
                        path.display()
                    )
                })?
                .into_owned();
            rewrites.push((field, rewrite_path));
        }

        let target = ctx.root_dir.join(target_relative);
        if !writer.contains_target(&target) {
            writer.add_file(target, source, false)?;
        }
    }
    Ok(rewrites)
}

/// Reject parent-relative pyproject metadata paths for the git sdist generator.
///
/// Git sdists are intentionally faithful to `git ls-files` from the
/// `pyproject.toml` directory. Since `pyproject.toml` must be placed at the
/// sdist root, parent-relative metadata paths cannot remain valid without
/// copying and rewriting extra files outside that file list.
pub(super) fn reject_parent_relative_metadata_paths(pyproject: &PyProjectToml) -> Result<()> {
    let Some(project) = pyproject.project.as_ref() else {
        return Ok(());
    };
    for (_, field_name, path) in project_metadata_paths(project) {
        if parent_relative_metadata_path(field_name, path)? {
            bail!(
                "`{field_name}` path `{}` must not contain `..` when using the git sdist generator",
                path.display()
            );
        }
    }
    Ok(())
}

/// Add pyproject.toml to the sdist (rewriting paths if necessary).
pub(super) fn add_pyproject_toml(
    writer: &mut VirtualWriter<SDistWriter>,
    ctx: &SdistContext<'_>,
    pyproject_toml_path: &Path,
    metadata_rewrites: &[PyprojectPathRewrite],
) -> Result<()> {
    if ctx.pyproject_dir != ctx.sdist_root {
        let relative_manifest_path = ctx.relative_main_crate_manifest_dir.join("Cargo.toml");
        let python_dir = &ctx.project.project_layout.python_dir;
        // Compute python-source relative to pyproject_dir.  When python_dir is
        // outside pyproject_dir, compute the path relative to project_root instead.
        let relative_python_source = if python_dir != &ctx.pyproject_dir {
            python_dir
                .strip_prefix(&ctx.pyproject_dir)
                .or_else(|_| python_dir.strip_prefix(&ctx.project_root))
                .ok()
                .map(|p| p.to_path_buf())
        } else {
            None
        };
        let rewritten = rewrite_pyproject_toml(
            pyproject_toml_path,
            &relative_manifest_path,
            relative_python_source.as_deref(),
            metadata_rewrites,
        )?;
        writer.add_bytes(
            ctx.root_dir.join("pyproject.toml"),
            Some(pyproject_toml_path),
            rewritten.as_bytes(),
            false,
        )?;
    } else {
        writer.add_file(
            ctx.root_dir.join("pyproject.toml"),
            pyproject_toml_path,
            false,
        )?;
    }
    Ok(())
}

/// Add python source files to the sdist.
pub(super) fn add_python_sources(
    writer: &mut VirtualWriter<SDistWriter>,
    ctx: &SdistContext<'_>,
) -> Result<()> {
    let project = ctx.project;
    let mut python_packages = Vec::new();
    if let Some(python_module) = project.project_layout.python_module.as_ref() {
        trace!("Resolved python module: {}", python_module.display());
        python_packages.push(python_module.to_path_buf());
    }
    for package in &project.project_layout.python_packages {
        let package_path = project.project_layout.python_dir.join(package);
        if python_packages.contains(&package_path) {
            continue;
        }
        trace!("Resolved python package: {}", package_path.display());
        python_packages.push(package_path);
    }

    for package in python_packages {
        for entry in ignore::Walk::new(package) {
            let source = entry?.into_path();
            if is_compiled_artifact(&source) {
                debug!("Ignoring {}", source.display());
                continue;
            }
            // When python-source points outside pyproject_dir, strip from
            // project_root instead (issue #2202).
            let relative = source
                .strip_prefix(&ctx.pyproject_dir)
                .or_else(|_| source.strip_prefix(&ctx.project_root))
                .with_context(|| {
                    format!(
                        "Python source file `{}` is outside both pyproject dir `{}` and project root `{}`",
                        source.display(),
                        ctx.pyproject_dir.display(),
                        ctx.project_root.display(),
                    )
                })?;
            if !source.is_dir() {
                writer.add_file(ctx.root_dir.join(relative), &source, false)?;
            }
        }
    }
    Ok(())
}

/// Add `license-files` globs and explicit include patterns from
/// `pyproject.toml` metadata.
///
/// Readme and `license.file` references are handled earlier for Cargo sdists;
/// for git sdists they are expected to come from `git ls-files`.
pub(super) fn add_pyproject_metadata(
    writer: &mut VirtualWriter<SDistWriter>,
    pyproject: &PyProjectToml,
    pyproject_dir: &Path,
    root_dir: &Path,
    python_dir: &Path,
) -> Result<()> {
    if let Some(project) = pyproject.project.as_ref()
        && let Some(license_files) = &project.license_files
    {
        let escaped_pyproject_dir =
            PathBuf::from(glob::Pattern::escape(pyproject_dir.to_str().unwrap()));
        let mut seen = HashSet::new();
        for license_glob in license_files {
            check_pep639_glob(license_glob)?;
            for license_path in
                glob::glob(&escaped_pyproject_dir.join(license_glob).to_string_lossy())?
            {
                let license_path = license_path?;
                if !license_path.is_file() {
                    continue;
                }
                let license_path = license_path
                    .strip_prefix(pyproject_dir)
                    .expect("matched path starts with glob root")
                    .to_path_buf();
                if seen.insert(license_path.clone()) {
                    debug!("Including license file `{}`", license_path.display());
                    writer.add_file(
                        root_dir.join(&license_path),
                        pyproject_dir.join(&license_path),
                        false,
                    )?;
                }
            }
        }
    }

    if let Some(glob_patterns) = pyproject.include() {
        for pattern in glob_patterns
            .iter()
            .filter_map(|glob_pattern| glob_pattern.targets(Format::Sdist))
        {
            eprintln!("📦 Including files matching \"{pattern}\"");
            let matches = crate::module_writer::glob::resolve_include_matches(
                pattern,
                Format::Sdist,
                pyproject_dir,
                python_dir,
            )?;
            for m in matches {
                writer.add_file(root_dir.join(&m.target), m.source, false)?;
            }
        }
    }

    Ok(())
}

/// Rewrite `pyproject.toml` paths for the sdist layout.
///
/// When `pyproject.toml` lives inside the Cargo workspace root (not at the
/// sdist root), we update `tool.maturin.manifest-path` and optionally
/// `tool.maturin.python-source` so they resolve correctly from the new
/// relative position inside the archive.
///
/// `metadata` provides additional rewrites for `[project.readme]` and
/// `[project.license]` `file` paths that were elevated to the sdist root.
fn rewrite_pyproject_toml(
    pyproject_toml_path: &Path,
    relative_manifest_path: &Path,
    relative_python_source: Option<&Path>,
    metadata_rewrites: &[PyprojectPathRewrite],
) -> Result<String> {
    let mut data = parse_toml_file(pyproject_toml_path, "pyproject.toml")?;
    let tool = data
        .entry("tool")
        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
        .as_table_like_mut()
        .with_context(|| {
            format!(
                "`[tool]` must be a table in {}",
                pyproject_toml_path.display()
            )
        })?;
    let maturin = tool
        .entry("maturin")
        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
        .as_table_like_mut()
        .with_context(|| {
            format!(
                "`[tool.maturin]` must be a table in {}",
                pyproject_toml_path.display()
            )
        })?;

    maturin.remove("manifest-path");
    let manifest_path_str = relative_manifest_path.to_slash().with_context(|| {
        format!(
            "manifest-path `{}` is not valid UTF-8",
            relative_manifest_path.display()
        )
    })?;
    maturin.insert(
        "manifest-path",
        toml_edit::value(manifest_path_str.as_ref()),
    );

    if let Some(python_source) = relative_python_source {
        maturin.remove("python-source");
        let python_source_str = python_source.to_slash().with_context(|| {
            format!(
                "python-source path `{}` is not valid UTF-8",
                python_source.display()
            )
        })?;
        maturin.insert(
            "python-source",
            toml_edit::value(python_source_str.as_ref()),
        );
    }

    if !metadata_rewrites.is_empty() {
        let project = data
            .entry("project")
            .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
            .as_table_like_mut()
            .with_context(|| {
                format!(
                    "`[project]` must be a table in {}",
                    pyproject_toml_path.display()
                )
            })?;
        for rewrite in metadata_rewrites {
            rewrite_pyproject_field_path(project, rewrite.0, "file", &rewrite.1)?;
        }
    }

    Ok(data.to_string())
}

/// Update a path field in `pyproject.toml`. The string form is replaced
/// wholesale; table or inline-table forms have only their `inner_field`
/// (e.g. `file`) updated so other keys like `content-type` are preserved.
fn rewrite_pyproject_field_path(
    project: &mut dyn toml_edit::TableLike,
    field: &str,
    inner_field: &str,
    new_path: &str,
) -> Result<()> {
    let Some(item) = project.get_mut(field) else {
        return Ok(());
    };
    match item {
        toml_edit::Item::Value(toml_edit::Value::String(s)) => {
            *s = toml_edit::Formatted::new(new_path.to_string());
        }
        toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) => {
            table.insert(
                inner_field,
                toml_edit::Value::String(toml_edit::Formatted::new(new_path.to_string())),
            );
        }
        toml_edit::Item::Table(table) => {
            table.insert(inner_field, toml_edit::value(new_path));
        }
        _ => bail!("unexpected shape for `project.{field}` in pyproject.toml"),
    }
    Ok(())
}
