// SPDX-FileCopyrightText: 2023 CELESTIFYX Team
// SPDX-License-Identifier: GPL-3.0-or-later

use std::{
    io::Error,
    ffi::OsString,
    collections::HashSet,

    fs::{
        DirEntry,
        read_dir,
        read_to_string
    },

    path::{
        Path,
        PathBuf
    }
};

use rayon::iter::{
    IntoParallelRefIterator,
    ParallelIterator
};

use super::{
    pkg::BUILDFILE_NAME,

    repo::{
        Repo,
        RepoPaths
    }
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Status {
    Installed,
    NotInstalled
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Port {
    pub(super) rel_path: String,
    pub(super) name: String,
    pub(super) repo: String,
    pub(super) path: PathBuf,
    pub(super) status: Status,
    pub(super) port_version: String,
    pub(super) port_release: String,
    pub(super) installed_version: String,
    pub(super) installed_release: String,
    pub(super) masked: bool
}

impl Port {
    pub(super) fn needs_update(&self) -> bool {
        if self.status != Status::Installed {
            return false;
        }

        if self.masked {
            return false;
        }

        if self.port_version != self.installed_version {
            return version_compare(&self.port_version, &self.installed_version) == 1;
        }

        if self.port_release != self.installed_release {
            return version_compare(&self.port_release, &self.installed_release) == 1;
        }

        false
    }
}

fn version_compare(a: &str, b: &str) -> i32 {
    if a == b {
        return 0;
    }

    compare_versions(a, b)
}

fn compare_versions(a: &str, b: &str) -> i32 {
    let parts_a: Vec<&str> = a.split(|c: char| !c.is_ascii_digit() && (c != '.')).collect();
    let parts_b: Vec<&str> = b.split(|c: char| !c.is_ascii_digit() && (c != '.')).collect();

    for (pa, pb) in parts_a.iter().zip(parts_b.iter()) {
        let num_a: i32 = pa.parse().unwrap_or(0);
        let num_b: i32 = pb.parse().unwrap_or(0);

        if num_a < num_b {
            return -1;
        } else if num_a > num_b {
            return 1;
        }
    }

    (parts_a.len() as i32) - (parts_b.len() as i32)
}

#[derive(Debug, thiserror::Error)]
pub(super) enum ScanError {
    #[error("failed to read installed db at {}: {}", 0, 1)]
    InstalledDb(PathBuf, #[source] Error),

    #[error("failed to scan repo {}: {}", repo, err)]
    Repo {
        repo: String,
        #[source]
        err: Error
    }
}

pub(super) struct InstalledInfo {
    version: String,
    release: String
}

pub(super) fn load_installed_set(db_dir: &Path) -> Result<HashSet<String>, Error> {
    let mut set: HashSet<String> = HashSet::new();

    for entry in read_dir(db_dir)? {
        let entry: DirEntry = entry?;

        if entry.file_type()?.is_file() && let Some(name) = entry.file_name().to_str() {
            (&mut set).insert(name.to_string());
        }
    }

    Ok(set)
}

fn load_installed_info(db_dir: &Path, name: &str) -> Option<InstalledInfo> {
    let db_file: PathBuf = db_dir.join(name);
    let contents: String = read_to_string(&db_file).ok()?;
    let first_line: &str = contents.lines().next()?;

    let parts: Vec<&str> = first_line.split_whitespace().collect();
    let version: String = parts.first().unwrap_or(&"").to_string();
    let release: String = parts.get(1).unwrap_or(&"1").to_string();

    Some(InstalledInfo {
        version,
        release
    })
}

fn parse_port_variable(build_file: &Path, var_name: &str) -> String {
    let contents: String = match read_to_string(build_file) {
        Ok(c) => c,
        Err(_) => return String::new()
    };

    for line in contents.lines() {
        let trimmed: &str = line.trim();

        if trimmed.starts_with(var_name) && trimmed.contains('=') {
            let pos: usize = trimmed.find('=').unwrap();
            let value: &str = trimmed[pos + 1..].trim();

            return value.trim_matches('"').to_string();
        }
    }

    String::new()
}

fn scan_repo(repo: &Repo, installed: &HashSet<String>, mask: &HashSet<String>, db_dir: &Path) -> Result<Vec<Port>, Error> {
    let mut ports: Vec<Port> = Vec::new();

    for entry in read_dir(&repo.path)? {
        let entry: DirEntry = entry?;

        if !entry.file_type()?.is_dir() {
            continue;
        }

        let file_name: OsString = entry.file_name();

        let Some(name) = file_name.to_str() else {
            continue;
        };

        let status: Status = if installed.contains(name) {
            Status::Installed
        } else {
            Status::NotInstalled
        };

        let masked: bool = mask.contains(name);
        let build_file: PathBuf = entry.path().join(BUILDFILE_NAME);
        let port_version: String = parse_port_variable(&build_file, "version");
        let port_release: String = parse_port_variable(&build_file, "release");

        let (installed_version, installed_release): (String, String) = if (status == Status::Installed) && let Some(info) = load_installed_info(db_dir, name) {
            (info.version, info.release)
        } else {
            (String::new(), String::new())
        };

        (&mut ports).push(Port {
            rel_path: format!("{}/{}", repo.name, name),
            name: name.to_string(),
            repo: repo.name.clone(),
            path: entry.path(),
            status,
            port_version,
            port_release,
            installed_version,
            installed_release,
            masked
        });
    }

    Ok(ports)
}

pub(super) fn scan_all(repos: &[Repo], paths: &RepoPaths, mask: &HashSet<String>) -> Result<Vec<Port>, ScanError> {
    let installed: HashSet<String> = load_installed_set(&paths.installed_db).map_err(|e: Error| ScanError::InstalledDb(paths.installed_db.clone(), e))?;
    let db_dir: PathBuf = paths.installed_db.clone();

    let results: Vec<Result<Vec<Port>, Error>> = repos.par_iter().map(|repo: &Repo| {
        if !repo.exists_on_disk() {
            return Ok(Vec::new());
        }

        scan_repo(repo, &installed, mask, &db_dir)
    }).collect();

    let mut all: Vec<Port> = results.into_iter().zip(repos.iter()).filter_map(|(res, repo): (Result<Vec<Port>, Error>, &Repo)| {
        res.map_err(|err: Error| ScanError::Repo {
            repo: repo.name.clone(),
            err
        }).ok()
    }).flatten().collect();

    (&mut *all).sort_by(|a: &Port, b: &Port| a.name.cmp(&b.name));
    Ok(all)
}
