use std::{collections::BTreeSet, fs, path::Path};

use anyhow::{Context, Result, bail};

use crate::{
    changelog::PendingChangelog,
    config::{Config, VersionFileConfig},
    conventional_commits::ConventionalCommit,
    git::{CommitSummary, GitRepository},
    version::{BumpLevel, Version},
    version_files,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseAnalysis {
    pub current_version: Version,
    pub next_version: Option<Version>,
    pub bump: BumpLevel,
    pub commits: Vec<CommitSummary>,
    pub changelog: PendingChangelog,
    pub package_plan: PackagePlan,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackagePlan {
    pub release_mode: String,
    pub discovery_source: String,
    pub packages: Vec<PackageReleaseAnalysis>,
}

impl PackagePlan {
    pub fn selected_packages(&self) -> Vec<&PackageReleaseAnalysis> {
        self.packages
            .iter()
            .filter(|package| package.selected)
            .collect()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageReleaseAnalysis {
    pub name: String,
    pub root: String,
    pub current_version: Version,
    pub next_version: Option<Version>,
    pub bump: BumpLevel,
    pub changelog: PendingChangelog,
    pub version_files: Vec<VersionFileConfig>,
    pub commits: Vec<CommitSummary>,
    pub changed_paths: Vec<String>,
    pub selected: bool,
    pub selection_reason: String,
}

#[derive(Debug, Clone)]
struct PackageDefinition {
    name: String,
    root: String,
    version_files: Vec<VersionFileConfig>,
}

pub fn analyze(repo: &GitRepository, config: &Config) -> Result<ReleaseAnalysis> {
    config.validate()?;

    let commits = repo.commits_since_latest_tag()?;
    if config.monorepo.is_multi_package() {
        analyze_monorepo(repo, config, commits)
    } else {
        analyze_single_package(repo, config, commits)
    }
}

fn analyze_single_package(
    repo: &GitRepository,
    config: &Config,
    commits: Vec<CommitSummary>,
) -> Result<ReleaseAnalysis> {
    let current_version = match read_current_version(repo.path(), &config.version_files)? {
        Some(version) => version.parse()?,
        None => config.versioning.initial_version.parse()?,
    };

    let conventional_commits = commits
        .iter()
        .filter_map(|commit| ConventionalCommit::parse_message(&commit.message).ok())
        .collect::<Vec<_>>();
    let bump = BumpLevel::from_commits(&conventional_commits);
    let next_version = bump.apply(&current_version);
    let changelog = PendingChangelog::from_commits(config, &conventional_commits);

    Ok(ReleaseAnalysis {
        current_version: current_version.clone(),
        next_version: next_version.clone(),
        bump,
        commits: commits.clone(),
        changelog: changelog.clone(),
        package_plan: PackagePlan {
            release_mode: "single".to_string(),
            discovery_source: "top-level [[version_files]] configuration".to_string(),
            packages: vec![PackageReleaseAnalysis {
                name: package_name_from_repo_root(repo.path()),
                root: ".".to_string(),
                current_version,
                next_version,
                bump,
                changelog,
                version_files: config.version_files.clone(),
                commits,
                changed_paths: Vec::new(),
                selected: true,
                selection_reason: "single-package repository".to_string(),
            }],
        },
    })
}

fn analyze_monorepo(
    repo: &GitRepository,
    config: &Config,
    commits: Vec<CommitSummary>,
) -> Result<ReleaseAnalysis> {
    let (definitions, discovery_source) = discover_packages(repo.path(), config)?;
    if definitions.is_empty() {
        bail!("monorepo.enabled is true but no packages were discovered");
    }

    let mut packages = Vec::new();
    for definition in definitions {
        let package_commits = commits_for_package(&commits, &definition.root);
        let conventional_commits = package_commits
            .iter()
            .filter_map(|commit| ConventionalCommit::parse_message(&commit.message).ok())
            .collect::<Vec<_>>();
        let changed_paths = changed_paths_for_package(&package_commits, &definition.root);
        let current_version = match read_current_version(repo.path(), &definition.version_files)? {
            Some(version) => version.parse()?,
            None => config.versioning.initial_version.parse()?,
        };
        let bump = BumpLevel::from_commits(&conventional_commits);
        let next_version = bump.apply(&current_version);
        let selected = !changed_paths.is_empty() && next_version.is_some();

        packages.push(PackageReleaseAnalysis {
            name: definition.name,
            root: definition.root.clone(),
            current_version,
            next_version,
            bump,
            changelog: PendingChangelog::from_commits(config, &conventional_commits),
            version_files: definition.version_files,
            commits: package_commits,
            changed_paths,
            selected,
            selection_reason: if selected {
                "package files changed since the latest tag and produced a release bump".to_string()
            } else {
                "no releasable package changes detected since the latest tag".to_string()
            },
        });
    }

    let selected_packages = packages.iter().filter(|package| package.selected);
    let aggregate_current_version = selected_packages
        .clone()
        .next()
        .map(|package| package.current_version.clone())
        .unwrap_or_else(|| {
            config
                .versioning
                .initial_version
                .parse()
                .expect("valid version")
        });
    let aggregate_bump = packages
        .iter()
        .filter(|package| package.selected)
        .fold(BumpLevel::None, |level, package| level.max(package.bump));
    let aggregate_next_version = aggregate_bump.apply(&aggregate_current_version);
    let aggregate_changelog = aggregate_changelog(&packages);

    Ok(ReleaseAnalysis {
        current_version: aggregate_current_version,
        next_version: aggregate_next_version,
        bump: aggregate_bump,
        commits,
        changelog: aggregate_changelog,
        package_plan: PackagePlan {
            release_mode: config.monorepo.release_mode.clone(),
            discovery_source,
            packages,
        },
    })
}

fn discover_packages(
    repo_root: &Path,
    config: &Config,
) -> Result<(Vec<PackageDefinition>, String)> {
    if !config.monorepo.packages.is_empty() {
        let packages = config
            .monorepo
            .packages
            .iter()
            .map(|package_root| load_package_definition(repo_root, package_root))
            .collect::<Result<Vec<_>>>()?;
        return Ok((packages, "[monorepo].packages".to_string()));
    }

    let mut package_roots = Vec::new();
    scan_for_package_roots(repo_root, repo_root, &mut package_roots);
    package_roots.sort();
    package_roots.dedup();

    let packages = package_roots
        .iter()
        .map(|package_root| load_package_definition(repo_root, package_root))
        .collect::<Result<Vec<_>>>()?;
    Ok((
        packages,
        "auto-discovered package pyproject.toml files".to_string(),
    ))
}

fn load_package_definition(repo_root: &Path, package_root: &str) -> Result<PackageDefinition> {
    let package_path = repo_root.join(package_root);
    if !package_path.is_dir() {
        bail!(
            "configured monorepo package {} is not a directory",
            package_root
        );
    }

    let version_files = detect_package_version_files(repo_root, &package_path)?;
    if version_files.is_empty() {
        bail!(
            "monorepo package {} has no supported version files",
            package_root
        );
    }

    Ok(PackageDefinition {
        name: detect_package_name(&package_path).unwrap_or_else(|| {
            package_path
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .into()
        }),
        root: normalize_relative_path(package_root),
        version_files,
    })
}

fn scan_for_package_roots(repo_root: &Path, current: &Path, package_roots: &mut Vec<String>) {
    let Ok(entries) = fs::read_dir(current) else {
        return;
    };

    for entry in entries.flatten() {
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
            continue;
        };

        if matches!(
            name,
            ".git" | "target" | ".venv" | "venv" | "__pycache__" | ".mypy_cache"
        ) {
            continue;
        }

        if path.is_dir() {
            scan_for_package_roots(repo_root, &path, package_roots);
            continue;
        }

        if name != "pyproject.toml" || path.parent() == Some(repo_root) {
            continue;
        }

        if let Some(parent) = path
            .parent()
            .and_then(|parent| parent.strip_prefix(repo_root).ok())
        {
            package_roots.push(parent.to_string_lossy().replace('\\', "/"));
        }
    }
}

fn detect_package_version_files(
    repo_root: &Path,
    package_root: &Path,
) -> Result<Vec<VersionFileConfig>> {
    let mut version_files = Vec::new();
    let pyproject_path = package_root.join("pyproject.toml");
    if pyproject_path.exists() {
        version_files.push(VersionFileConfig {
            path: relative_to_repo(repo_root, &pyproject_path)?,
            key: Some("project.version".to_string()),
            pattern: None,
        });
    }

    let setup_cfg_path = package_root.join("setup.cfg");
    if setup_cfg_path.exists() {
        version_files.push(VersionFileConfig {
            path: relative_to_repo(repo_root, &setup_cfg_path)?,
            key: Some("metadata.version".to_string()),
            pattern: None,
        });
    }

    scan_python_version_files(repo_root, package_root, &mut version_files)?;

    version_files.sort_by(|left, right| left.path.cmp(&right.path));
    version_files.dedup_by(|left, right| left.path == right.path);
    Ok(version_files)
}

fn scan_python_version_files(
    repo_root: &Path,
    package_root: &Path,
    version_files: &mut Vec<VersionFileConfig>,
) -> Result<()> {
    let mut stack = vec![package_root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(_) => continue,
        };

        for entry in entries.flatten() {
            let path = entry.path();
            let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
                continue;
            };

            if matches!(name, ".git" | "target" | ".venv" | "venv" | "__pycache__") {
                continue;
            }

            if path.is_dir() {
                stack.push(path);
                continue;
            }

            if name != "__init__.py" {
                continue;
            }

            let Some(pattern) = detect_python_pattern(&path) else {
                continue;
            };

            version_files.push(VersionFileConfig {
                path: relative_to_repo(repo_root, &path)?,
                key: None,
                pattern: Some(pattern),
            });
        }
    }

    Ok(())
}

fn detect_package_name(package_root: &Path) -> Option<String> {
    let pyproject = package_root.join("pyproject.toml");
    let contents = fs::read_to_string(pyproject).ok()?;
    let parsed = contents.parse::<toml::Table>().ok()?;
    parsed
        .get("project")?
        .as_table()?
        .get("name")?
        .as_str()
        .map(ToString::to_string)
}

fn detect_python_pattern(path: &Path) -> Option<String> {
    let contents = fs::read_to_string(path).ok()?;

    for line in contents.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("__version__") {
            continue;
        }

        let (prefix, raw_value) = trimmed.split_once('=')?;
        let value = raw_value.trim();
        if value.len() < 2 {
            continue;
        }

        let quote = value.chars().next()?;
        if (quote != '"' && quote != '\'') || !value.ends_with(quote) {
            continue;
        }

        return Some(format!("{}= {}{{version}}{}", prefix, quote, quote));
    }

    None
}

fn package_name_from_repo_root(repo_root: &Path) -> String {
    repo_root
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .into_owned()
}

fn relative_to_repo(repo_root: &Path, path: &Path) -> Result<String> {
    Ok(path
        .strip_prefix(repo_root)
        .with_context(|| format!("{} is not inside {}", path.display(), repo_root.display()))?
        .to_string_lossy()
        .replace('\\', "/"))
}

fn normalize_relative_path(path: &str) -> String {
    let normalized = path.trim_matches('/').replace('\\', "/");
    if normalized.is_empty() {
        ".".to_string()
    } else {
        normalized
    }
}

fn commits_for_package(commits: &[CommitSummary], package_root: &str) -> Vec<CommitSummary> {
    commits
        .iter()
        .filter(|commit| commit_touches_package(commit, package_root))
        .cloned()
        .collect()
}

fn changed_paths_for_package(commits: &[CommitSummary], package_root: &str) -> Vec<String> {
    let mut paths = BTreeSet::new();
    for commit in commits {
        for path in &commit.changed_paths {
            if path_in_package(path, package_root) {
                paths.insert(path.clone());
            }
        }
    }
    paths.into_iter().collect()
}

fn commit_touches_package(commit: &CommitSummary, package_root: &str) -> bool {
    commit
        .changed_paths
        .iter()
        .any(|path| path_in_package(path, package_root))
}

fn path_in_package(path: &str, package_root: &str) -> bool {
    package_root == "." || path == package_root || path.starts_with(&format!("{package_root}/"))
}

fn aggregate_changelog(packages: &[PackageReleaseAnalysis]) -> PendingChangelog {
    let mut sections = std::collections::BTreeMap::new();
    for package in packages.iter().filter(|package| package.selected) {
        for (section, entries) in &package.changelog.sections {
            let bucket = sections.entry(section.clone()).or_insert_with(Vec::new);
            for entry in entries {
                bucket.push(format!("{}: {}", package.name, entry));
            }
        }
    }
    PendingChangelog { sections }
}

pub fn read_current_version(
    repo_root: &Path,
    version_files: &[VersionFileConfig],
) -> Result<Option<String>> {
    for version_file in version_files {
        let path = repo_root.join(&version_file.path);
        if !path.exists() {
            continue;
        }

        let value = if let Some(key) = &version_file.key {
            version_files::read_key(&path, key)?
        } else if let Some(pattern) = &version_file.pattern {
            version_files::read_pattern(&path, pattern)?
        } else {
            None
        };

        if value.is_some() {
            return Ok(value);
        }
    }

    Ok(None)
}

pub fn update_version_files(
    repo_root: &Path,
    version_files: &[VersionFileConfig],
    version: &Version,
) -> Result<()> {
    for version_file in version_files {
        let path = repo_root.join(&version_file.path);

        if let Some(key) = &version_file.key {
            version_files::rewrite_key(&path, key, &version.to_string())
                .with_context(|| format!("failed to update {}", path.display()))?;
            continue;
        }

        if let Some(pattern) = &version_file.pattern {
            version_files::rewrite_pattern(&path, pattern, &version.to_string())
                .with_context(|| format!("failed to update {}", path.display()))?;
            continue;
        }

        bail!("version file {} has no key or pattern", path.display());
    }

    Ok(())
}
