//! Discover projects/volumes/chapters on disk for the Shelf and Project screens.
//! A project is a dir with a `PROJECT.md`; volumes are `Vol_NN` subdirs; chapters
//! are `raw/ch_NNN.md` with status from the matching `translated/ch_NNN.md`.
//! All functions are sync (std::fs + walkdir) so they stay usable from render paths.

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

use chrono::{DateTime, Utc};

use crate::cleanse;
use crate::model::{Chapter, ChapterKind, ChapterStatus, Project, Volume, VolumeData};
use crate::workspace::data_block;

/// Discover projects one level under `root` (a child dir with `PROJECT.md`),
/// ascending by id, each fully populated with volumes and chapters.
pub fn scan_projects(root: &Path) -> Vec<Project> {
    let mut projects = Vec::new();
    let entries = match std::fs::read_dir(root) {
        Ok(e) => e,
        Err(_) => return projects,
    };

    for entry in entries.flatten() {
        let dir = entry.path();
        if !dir.is_dir() {
            continue;
        }
        if let Some(project) = scan_one_project(&dir) {
            projects.push(project);
        }
    }

    projects.sort_by(|a, b| a.id.cmp(&b.id));
    projects
}

/// Build a single [`Project`] from a directory, or `None` if it is not a project
/// (no `PROJECT.md`, or an unusable directory name). Used both by the shelf scan
/// and by crash-recovery resume, which must reopen a project by absolute path
/// even when it lives outside the current working root.
pub fn scan_one_project(dir: &Path) -> Option<Project> {
    if !dir.join("PROJECT.md").is_file() {
        return None;
    }
    let id = dir.file_name().map(|n| n.to_string_lossy().into_owned())?;
    if id.is_empty() {
        return None;
    }

    let volumes = scan_volumes(dir);
    let (created, touched) = dir_times(dir);
    let title = read_project_title(dir).unwrap_or_else(|| id.clone());
    let title_th = super::scaffold::read_title_th(dir);

    Some(Project {
        id,
        dir: dir.to_path_buf(),
        title,
        title_th,
        created,
        touched,
        volumes,
        models: None,
    })
}

/// Discover `Vol_NN` volumes under a project directory, ascending by number.
pub fn scan_volumes(project_dir: &Path) -> Vec<Volume> {
    let mut volumes = Vec::new();
    let entries = match std::fs::read_dir(project_dir) {
        Ok(e) => e,
        Err(_) => return volumes,
    };

    for entry in entries.flatten() {
        let dir = entry.path();
        if !dir.is_dir() {
            continue;
        }
        let name = match dir.file_name().and_then(|n| n.to_str()) {
            Some(n) => n,
            None => continue,
        };
        let number = match parse_vol_number(name) {
            Some(n) => n,
            None => continue,
        };

        // VOLUME.md's data block carries the persisted per-chapter usage totals.
        let vol_data: VolumeData = data_block::read_data_block(&dir.join("VOLUME.md"));
        let chapters = scan_chapters(&dir, &vol_data);
        let label = read_volume_label(&dir);
        volumes.push(Volume {
            number,
            dir,
            label,
            chapters,
        });
    }

    volumes.sort_by_key(|v| v.number);
    volumes
}

/// Discover chapters from `raw/ch_NNN.md`, ascending. Title defaults to
/// "Chapter NNN" since the EPUB TOC title is not persisted on disk. `vol_data`
/// supplies each chapter's persisted lifetime usage.
pub fn scan_chapters(vol_dir: &Path, vol_data: &VolumeData) -> Vec<Chapter> {
    let raw_dir = vol_dir.join("raw");
    let mut numbers: Vec<u32> = Vec::new();

    if let Ok(entries) = std::fs::read_dir(&raw_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            if let Some(n) = parse_chapter_number(path.file_name().and_then(|n| n.to_str())) {
                numbers.push(n);
            }
        }
    }

    numbers.sort_unstable();
    numbers.dedup();

    numbers
        .into_iter()
        .map(|number| {
            let raw_md =
                std::fs::read_to_string(vol_dir.join("raw").join(format!("ch_{:03}.md", number)))
                    .unwrap_or_default();
            let kind = if !raw_md.trim().is_empty() && cleanse::is_image_only(&raw_md) {
                ChapterKind::ImageOnly
            } else if raw_md.trim().is_empty() {
                ChapterKind::Empty
            } else {
                ChapterKind::Prose
            };
            let status = derive_status(vol_dir, number, kind);
            let last_run = file_modified(
                &vol_dir
                    .join("translated")
                    .join(format!("ch_{:03}.md", number)),
            )
            .or_else(|| file_modified(&vol_dir.join("raw").join(format!("ch_{:03}.md", number))));

            Chapter {
                number,
                // Import writes the TOC title as a leading `# ` heading; fall
                // back to the generic label when absent.
                title: first_md_heading(&raw_md)
                    .unwrap_or_else(|| format!("Chapter {:03}", number)),
                kind,
                status,
                source_segments: count_segments(&raw_md),
                total_chunks: recorded_total_chunks(vol_dir, number),
                committed_chunks: count_committed_chunks(vol_dir, number),
                skipped_chunks: count_skipped_chunks(vol_dir, number),
                last_run,
                usage: vol_data
                    .chapter_usage
                    .get(&number.to_string())
                    .copied()
                    .unwrap_or_default(),
            }
        })
        .collect()
}

/// Title from a leading `# ` heading; stops at the first non-heading line so
/// narrative text is never mistaken for a title.
fn first_md_heading(md: &str) -> Option<String> {
    for line in md.lines() {
        let t = line.trim_start();
        if let Some(h) = t.strip_prefix("# ") {
            let h = h.trim();
            if !h.is_empty() {
                return Some(h.to_string());
            }
        }
        if !t.is_empty() && !t.starts_with('#') {
            break;
        }
    }
    None
}

/// Find importable files one level under `root` (with byte size) for the Shelf
/// import list; depth 1 so we don't descend into existing project dirs.
pub fn find_importable_files(root: &Path) -> Vec<(PathBuf, u64)> {
    crate::document_import::importable_files(root)
}

#[allow(dead_code)]
pub fn find_unimported_epubs(root: &Path) -> Vec<(PathBuf, u64)> {
    find_importable_files(root)
        .into_iter()
        .filter(|(path, _)| crate::document_import::is_epub_path(path))
        .collect()
}

/// Resting lifecycle status from disk: `Done` when translated/ is non-empty (or,
/// for `ImageOnly`, merely present), else `Pending`. When the pipeline recorded
/// the expected chunk total (`<!-- honya:chunks-total N -->`), a file missing
/// chunks is `Partial` (run stopped or failed mid-chapter) instead of `Done`.
/// Files without that marker predate it; for them disk can't tell "mid-run"
/// from "finished", so non-empty still reads as `Done`.
pub fn derive_status(vol_dir: &Path, chapter: u32, kind: ChapterKind) -> ChapterStatus {
    let translated = vol_dir
        .join("translated")
        .join(format!("ch_{:03}.md", chapter));

    if matches!(kind, ChapterKind::ImageOnly) {
        return if translated.is_file() {
            ChapterStatus::Done
        } else {
            ChapterStatus::Pending
        };
    }

    let content = std::fs::read_to_string(&translated).unwrap_or_default();
    if content.trim().is_empty() {
        return ChapterStatus::Pending;
    }
    if let Some(total) = super::translation::total_chunks_in(&content) {
        let committed = super::translation::committed_chunk_indices_in(&content);
        if !(0..total).all(|i| committed.contains(&i)) {
            return if committed.is_empty() {
                // Only the header marker landed before the run halted.
                ChapterStatus::Pending
            } else {
                ChapterStatus::Partial
            };
        }
    }
    if content.contains(super::translation::REVIEW_NEEDED_MARKER) {
        // A chunk was committed unreviewed: keep the chapter flagged across reopens.
        ChapterStatus::NeedsReview
    } else {
        ChapterStatus::Done
    }
}

/// Count committed chunk markers in translated text.
fn count_chunk_markers(text: &str) -> usize {
    super::translation::committed_chunk_indices_in(text).len()
}

fn count_committed_chunks(vol_dir: &Path, chapter: u32) -> u32 {
    let path = vol_dir
        .join("translated")
        .join(format!("ch_{:03}.md", chapter));
    match std::fs::read_to_string(path) {
        Ok(text) => count_chunk_markers(&text) as u32,
        Err(_) => 0,
    }
}

/// Count chunks committed flagged-for-review with no usable translation (skipped).
fn count_skipped_chunks(vol_dir: &Path, chapter: u32) -> u32 {
    let path = vol_dir
        .join("translated")
        .join(format!("ch_{:03}.md", chapter));
    match std::fs::read_to_string(path) {
        Ok(text) => super::translation::skipped_chunk_count_in(&text),
        Err(_) => 0,
    }
}

/// The expected chunk total the pipeline recorded at chunking time (0 when absent).
fn recorded_total_chunks(vol_dir: &Path, chapter: u32) -> u32 {
    let path = vol_dir
        .join("translated")
        .join(format!("ch_{:03}.md", chapter));
    std::fs::read_to_string(path)
        .ok()
        .and_then(|text| super::translation::total_chunks_in(&text))
        .unwrap_or(0)
}

/// Best-effort source-segment count: non-empty lines of the raw markdown.
fn count_segments(raw_md: &str) -> u32 {
    raw_md.lines().filter(|l| !l.trim().is_empty()).count() as u32
}

/// Parse `Vol_07` → `7`.
fn parse_vol_number(name: &str) -> Option<u32> {
    let rest = name.strip_prefix("Vol_")?;
    rest.parse::<u32>().ok()
}

/// Parse `ch_003.md` → `3`.
fn parse_chapter_number(name: Option<&str>) -> Option<u32> {
    let name = name?;
    let stem = name.strip_suffix(".md")?;
    let num = stem.strip_prefix("ch_")?;
    num.parse::<u32>().ok()
}

/// Read the project title from PROJECT.md's first `# ` heading.
fn read_project_title(dir: &Path) -> Option<String> {
    let text = std::fs::read_to_string(dir.join("PROJECT.md")).ok()?;
    for line in text.lines() {
        let trimmed = line.trim_start();
        if let Some(title) = trimmed.strip_prefix("# ") {
            let title = title.trim();
            if !title.is_empty() {
                return Some(title.to_string());
            }
        }
    }
    None
}

/// Read a volume label from VOLUME.md's running recap header (`เล่ม: ...`).
fn read_volume_label(vol_dir: &Path) -> Option<String> {
    let text = std::fs::read_to_string(vol_dir.join("VOLUME.md")).ok()?;
    for line in text.lines() {
        let trimmed = line.trim();
        if let Some(label) = trimmed.strip_prefix("เล่ม:") {
            let label = label.trim();
            if !label.is_empty() {
                return Some(label.to_string());
            }
        }
    }
    None
}

/// Filesystem created/modified times for a directory, as UTC datetimes.
fn dir_times(dir: &Path) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
    let meta = match std::fs::metadata(dir) {
        Ok(m) => m,
        Err(_) => return (None, None),
    };
    let created = meta.created().ok().map(DateTime::<Utc>::from);
    let touched = meta.modified().ok().map(DateTime::<Utc>::from);
    (created, touched)
}

/// Modified time of a file as a UTC datetime, if it exists.
fn file_modified(path: &Path) -> Option<DateTime<Utc>> {
    let meta = std::fs::metadata(path).ok()?;
    meta.modified().ok().map(DateTime::<Utc>::from)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::workspace::translation::REVIEW_NEEDED_MARKER;

    /// Resting status from disk distinguishes a clean translation from one that
    /// carries the review-needed marker, so a `NeedsReview` chapter stays flagged
    /// across a project re-scan instead of reverting to `Done`.
    #[test]
    fn derive_status_flags_review_needed_marker() {
        let base = std::env::temp_dir().join(format!("honya_scan_status_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        let tdir = base.join("translated");
        std::fs::create_dir_all(&tdir).unwrap();

        // Plain committed content → Done.
        std::fs::write(tdir.join("ch_001.md"), "<!-- honya:chunk 0 -->\nเนื้อหา\n").unwrap();
        assert_eq!(
            derive_status(&base, 1, ChapterKind::Prose),
            ChapterStatus::Done
        );

        // Carries the review-needed marker → NeedsReview.
        std::fs::write(
            tdir.join("ch_002.md"),
            format!("<!-- honya:chunk 0 -->\n{REVIEW_NEEDED_MARKER}\nเนื้อหา\n"),
        )
        .unwrap();
        assert_eq!(
            derive_status(&base, 2, ChapterKind::Prose),
            ChapterStatus::NeedsReview
        );

        // Absent translated file → Pending.
        assert_eq!(
            derive_status(&base, 3, ChapterKind::Prose),
            ChapterStatus::Pending
        );

        let _ = std::fs::remove_dir_all(&base);
    }

    /// A chapter whose run stopped or failed mid-way must not read back as Done:
    /// the recorded chunk total exposes the missing chunks as `Partial`.
    #[test]
    fn derive_status_detects_partial_translation() {
        let base = std::env::temp_dir().join(format!("honya_scan_partial_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        let tdir = base.join("translated");
        std::fs::create_dir_all(&tdir).unwrap();

        // 2 of 4 chunks committed → Partial (the old behavior wrongly said Done).
        std::fs::write(
            tdir.join("ch_001.md"),
            "<!-- honya:chunks-total 4 -->\n\n<!-- honya:chunk 0 -->\nหนึ่ง\n\n<!-- honya:chunk 1 -->\nสอง\n",
        )
        .unwrap();
        assert_eq!(
            derive_status(&base, 1, ChapterKind::Prose),
            ChapterStatus::Partial
        );

        // All chunks committed → Done.
        std::fs::write(
            tdir.join("ch_002.md"),
            "<!-- honya:chunks-total 2 -->\n\n<!-- honya:chunk 0 -->\nหนึ่ง\n\n<!-- honya:chunk 1 -->\nสอง\n",
        )
        .unwrap();
        assert_eq!(
            derive_status(&base, 2, ChapterKind::Prose),
            ChapterStatus::Done
        );

        // Complete but carrying a review-needed chunk → NeedsReview still wins.
        std::fs::write(
            tdir.join("ch_003.md"),
            format!(
                "<!-- honya:chunks-total 1 -->\n\n<!-- honya:chunk 0 -->\n{REVIEW_NEEDED_MARKER}\nเนื้อหา\n"
            ),
        )
        .unwrap();
        assert_eq!(
            derive_status(&base, 3, ChapterKind::Prose),
            ChapterStatus::NeedsReview
        );

        // Only the header marker landed (stopped before the first commit) → Pending.
        std::fs::write(tdir.join("ch_004.md"), "<!-- honya:chunks-total 3 -->\n").unwrap();
        assert_eq!(
            derive_status(&base, 4, ChapterKind::Prose),
            ChapterStatus::Pending
        );

        // The recorded total also surfaces as the chapter's resting chunk progress.
        assert_eq!(recorded_total_chunks(&base, 1), 4);
        assert_eq!(recorded_total_chunks(&base, 99), 0);

        let _ = std::fs::remove_dir_all(&base);
    }
}
