// `ccd migrate from-pod-layout` is the non-destructive relocation tool
// shipped by RFC 0006 M2. It moves machine identity and presence records
// from `~/.ccd/pods/{pod}/` to the flat `~/.ccd/machines/` layout,
// leaves every other file in place, and records a `MIGRATED.md` marker
// with a full inventory of what was preserved. Discarding the residual
// pod directory is a separate, explicit operator action.
//
// The tool fails closed on these conditions:
//   1. two pod directories declare the same `machine.id`
//   2. a destination path under `~/.ccd/machines/` already exists AND
//      the matching pod-scoped source is also still present (genuine
//      conflict that would silently overwrite operator state)
//   3. a `MIGRATED.md` marker is present but pod-scoped migration
//      sources (machine.toml or presence/*.json) still live in the pod
//      directory — that disagreement is never trusted
//
// Crash-safety and idempotency:
//   - Per-file source/destination inspection distinguishes `pending`
//     (move needed) from `already_moved` (completed by a prior run)
//     so an interrupted migration re-runs cleanly: only still-pending
//     files are relocated and the marker is written afterwards.
//   - Marker presence alone never suppresses validation. The pod's
//     live state must agree with the marker (no leftover source
//     files) before we treat it as `already_migrated`.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

use crate::output::CommandReport;
use crate::paths::state::default_ccd_root;

const MIGRATED_MARKER: &str = "MIGRATED.md";
const PRESENCE_DIR: &str = "presence";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MoveStatus {
    /// The source file is still present and needs to be relocated on
    /// this run.
    Pending,
    /// The source file is absent and the flat-layout destination is
    /// present — a prior run relocated this record. No action is
    /// taken for it on this run.
    AlreadyMoved,
}

impl MoveStatus {
    fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::AlreadyMoved => "already_moved",
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct MachineMove {
    pub machine_id: String,
    pub source: String,
    pub destination: String,
    pub status: MoveStatus,
}

#[derive(Debug, Clone, Serialize)]
pub struct PresenceMove {
    pub machine_id: String,
    pub source: String,
    pub destination: String,
    pub status: MoveStatus,
}

#[derive(Debug, Clone, Serialize)]
pub struct LegacyArtifact {
    pub path: String,
    pub byte_size: u64,
    pub kind: &'static str,
}

#[derive(Debug, Clone, Serialize)]
pub struct PodMigrationSummary {
    pub pod_name: String,
    pub pod_dir: String,
    pub action: &'static str,
    pub machine_records_moved: Vec<MachineMove>,
    pub presence_records_moved: Vec<PresenceMove>,
    pub legacy_artifacts_preserved: Vec<LegacyArtifact>,
    pub migrated_md_path: String,
}

#[derive(Debug, Serialize)]
pub struct MigrateFromPodLayoutReport {
    command: &'static str,
    ok: bool,
    dry_run: bool,
    ccd_root: String,
    pods_processed: usize,
    pods_already_migrated: usize,
    machine_records_moved: usize,
    presence_records_moved: usize,
    pub pods: Vec<PodMigrationSummary>,
}

impl CommandReport for MigrateFromPodLayoutReport {
    fn exit_code(&self) -> ExitCode {
        if self.ok {
            ExitCode::SUCCESS
        } else {
            ExitCode::FAILURE
        }
    }

    fn render_text(&self) {
        let prefix = if self.dry_run { "[dry-run] " } else { "" };
        if self.pods.is_empty() {
            println!("{prefix}No pod directories found under {}.", self.ccd_root);
            return;
        }
        println!(
            "{prefix}Processed {} pod(s) ({} already migrated). Relocated {} machine record(s) and {} presence record(s).",
            self.pods_processed,
            self.pods_already_migrated,
            self.machine_records_moved,
            self.presence_records_moved
        );
        for pod in &self.pods {
            println!();
            println!("pod `{}` ({})", pod.pod_name, pod.action);
            for mm in &pod.machine_records_moved {
                println!(
                    "  machine `{}` [{}] -> {}",
                    mm.machine_id,
                    mm.status.as_str(),
                    mm.destination
                );
            }
            for pm in &pod.presence_records_moved {
                println!(
                    "  presence `{}` [{}] -> {}",
                    pm.machine_id,
                    pm.status.as_str(),
                    pm.destination
                );
            }
            if !pod.legacy_artifacts_preserved.is_empty() {
                println!(
                    "  {} legacy artifact(s) preserved in place",
                    pod.legacy_artifacts_preserved.len()
                );
            }
            println!("  marker: {}", pod.migrated_md_path);
        }
    }
}

#[derive(Deserialize)]
struct PodManifestFile {
    pod: PodSection,
}

#[derive(Deserialize)]
struct PodSection {
    name: String,
}

#[derive(Deserialize)]
struct MachineManifestFile {
    machine: MachineSection,
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct MachineSection {
    id: String,
}

pub fn from_pod_layout(
    ccd_root_override: Option<&Path>,
    dry_run: bool,
) -> Result<MigrateFromPodLayoutReport> {
    let ccd_root = match ccd_root_override {
        Some(path) => path.to_path_buf(),
        None => default_ccd_root()?,
    };

    let pods_root = ccd_root.join("pods");
    let pod_dirs = sorted_pod_dirs(&pods_root)?;

    let mut pending: Vec<PendingPod> = Vec::new();
    let mut already_migrated: Vec<PodMigrationSummary> = Vec::new();

    for (pod_name, pod_dir) in pod_dirs {
        let marker = pod_dir.join(MIGRATED_MARKER);
        if marker.is_file() {
            let offenders = find_pending_source_files(&pod_dir)?;
            if !offenders.is_empty() {
                bail!(
                    "{marker_path} is present but pod-scoped migration sources still exist: \
                     {offenders}. Remove the marker to re-migrate or move these files manually \
                     before re-running.",
                    marker_path = marker.display(),
                    offenders = offenders.join(", ")
                );
            }
            already_migrated.push(PodMigrationSummary {
                pod_name: pod_name.clone(),
                pod_dir: pod_dir.display().to_string(),
                action: "already_migrated",
                machine_records_moved: Vec::new(),
                presence_records_moved: Vec::new(),
                legacy_artifacts_preserved: Vec::new(),
                migrated_md_path: marker.display().to_string(),
            });
            continue;
        }
        pending.push(scan_pending_pod(&pod_name, &pod_dir, &ccd_root)?);
    }

    verify_no_duplicate_machine_ids(&pending)?;

    let mut summaries: Vec<PodMigrationSummary> = Vec::new();
    let mut total_machines = 0usize;
    let mut total_presence = 0usize;

    for plan in pending {
        if !dry_run {
            execute_pending_moves(&plan)?;
            write_migrated_marker(&plan)?;
        }
        total_machines += plan
            .machine_move
            .as_ref()
            .map(|mm| (mm.status == MoveStatus::Pending) as usize)
            .unwrap_or(0);
        total_presence += plan
            .presence_moves
            .iter()
            .filter(|pm| pm.status == MoveStatus::Pending)
            .count();
        summaries.push(plan.into_summary());
    }

    let pods_already_migrated = already_migrated.len();
    summaries.extend(already_migrated);
    summaries.sort_by(|a, b| a.pod_name.cmp(&b.pod_name));

    Ok(MigrateFromPodLayoutReport {
        command: "migrate-from-pod-layout",
        ok: true,
        dry_run,
        ccd_root: ccd_root.display().to_string(),
        pods_processed: summaries.len(),
        pods_already_migrated,
        machine_records_moved: total_machines,
        presence_records_moved: total_presence,
        pods: summaries,
    })
}

// ── internal plan type ──────────────────────────────────────────────────

struct PendingPod {
    pod_name: String,
    pod_dir: PathBuf,
    machine_move: Option<MachineMove>,
    presence_moves: Vec<PresenceMove>,
    legacy_artifacts: Vec<LegacyArtifact>,
    marker_path: PathBuf,
    ccd_root: PathBuf,
}

impl PendingPod {
    fn into_summary(self) -> PodMigrationSummary {
        let machine_records_moved = self.machine_move.into_iter().collect::<Vec<_>>();
        PodMigrationSummary {
            pod_name: self.pod_name,
            pod_dir: self.pod_dir.display().to_string(),
            action: "migrated",
            machine_records_moved,
            presence_records_moved: self.presence_moves,
            legacy_artifacts_preserved: self.legacy_artifacts,
            migrated_md_path: self.marker_path.display().to_string(),
        }
    }
}

// ── scanning ────────────────────────────────────────────────────────────

fn sorted_pod_dirs(pods_root: &Path) -> Result<Vec<(String, PathBuf)>> {
    let Ok(iter) = fs::read_dir(pods_root) else {
        return Ok(Vec::new());
    };
    let mut entries = Vec::new();
    for entry in iter {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let Some(name) = entry.file_name().to_str().map(String::from) else {
            continue;
        };
        entries.push((name, entry.path()));
    }
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(entries)
}

fn find_pending_source_files(pod_dir: &Path) -> Result<Vec<String>> {
    let mut offenders = Vec::new();
    let machine_toml = pod_dir.join("machine.toml");
    if machine_toml.is_file() {
        offenders.push(machine_toml.display().to_string());
    }
    let presence_root = pod_dir.join(PRESENCE_DIR);
    if presence_root.is_dir() {
        let entries: Vec<fs::DirEntry> = fs::read_dir(&presence_root)
            .with_context(|| format!("failed to read {}", presence_root.display()))?
            .collect::<std::io::Result<Vec<_>>>()?;
        for entry in entries {
            if entry.file_type()?.is_file()
                && entry
                    .file_name()
                    .to_str()
                    .is_some_and(|name| name.ends_with(".json"))
            {
                offenders.push(entry.path().display().to_string());
            }
        }
    }
    offenders.sort();
    Ok(offenders)
}

fn scan_pending_pod(pod_name: &str, pod_dir: &Path, ccd_root: &Path) -> Result<PendingPod> {
    // A pod directory without a valid pod.toml is not a real pod;
    // we still process it (to write a marker so a future `ccd migrate
    // from-pod-layout` run can skip it), but its `machine.toml` and
    // presence records are treated as legacy artifacts rather than
    // migratable records.
    let pod_toml_valid = load_pod_toml(pod_dir)?.is_some();

    let machine_toml_source = pod_dir.join("machine.toml");
    let machine_move = if pod_toml_valid {
        if machine_toml_source.is_file() {
            let id = read_machine_id(&machine_toml_source)?;
            let destination = ccd_root.join("machines").join(&id).join("machine.toml");
            classify_move(&machine_toml_source, &destination, &id, "machine identity")?.map(
                |status| MachineMove {
                    machine_id: id,
                    source: machine_toml_source.display().to_string(),
                    destination: destination.display().to_string(),
                    status,
                },
            )
        } else {
            None
        }
    } else {
        None
    };

    let mut presence_moves: Vec<PresenceMove> = Vec::new();
    let presence_root = pod_dir.join(PRESENCE_DIR);
    if pod_toml_valid && presence_root.is_dir() {
        let mut entries: Vec<fs::DirEntry> = fs::read_dir(&presence_root)
            .with_context(|| format!("failed to read {}", presence_root.display()))?
            .collect::<std::io::Result<Vec<_>>>()?;
        entries.sort_by_key(|entry| entry.file_name());
        for entry in entries {
            if !entry.file_type()?.is_file() {
                continue;
            }
            let file_name = entry.file_name();
            let Some(name) = file_name.to_str() else {
                continue;
            };
            let Some(machine_id) = name.strip_suffix(".json") else {
                continue;
            };
            let source = entry.path();
            let destination = ccd_root
                .join("machines")
                .join(PRESENCE_DIR)
                .join(format!("{machine_id}.json"));
            if let Some(status) = classify_move(&source, &destination, machine_id, "presence")? {
                presence_moves.push(PresenceMove {
                    machine_id: machine_id.to_string(),
                    source: source.display().to_string(),
                    destination: destination.display().to_string(),
                    status,
                });
            }
        }
    }

    let moved_source_paths: Vec<PathBuf> = machine_move
        .as_ref()
        .filter(|mm| mm.status == MoveStatus::Pending)
        .map(|mm| PathBuf::from(&mm.source))
        .into_iter()
        .chain(
            presence_moves
                .iter()
                .filter(|pm| pm.status == MoveStatus::Pending)
                .map(|pm| PathBuf::from(&pm.source)),
        )
        .collect();

    let mut legacy_artifacts = Vec::new();
    inventory_residual_files(pod_dir, pod_dir, &moved_source_paths, &mut legacy_artifacts)?;
    legacy_artifacts.sort_by(|a, b| a.path.cmp(&b.path));

    Ok(PendingPod {
        pod_name: pod_name.to_string(),
        pod_dir: pod_dir.to_path_buf(),
        machine_move,
        presence_moves,
        legacy_artifacts,
        marker_path: pod_dir.join(MIGRATED_MARKER),
        ccd_root: ccd_root.to_path_buf(),
    })
}

/// Decide whether a planned move is still pending, already complete,
/// or a conflict that must fail the command.
///
/// Returns `Ok(None)` only when neither source nor destination exists
/// (nothing to do for this entry).
fn classify_move(
    source: &Path,
    destination: &Path,
    machine_id: &str,
    kind: &str,
) -> Result<Option<MoveStatus>> {
    let source_present = source.is_file();
    let dest_present = destination.is_file();
    match (source_present, dest_present) {
        (true, false) => Ok(Some(MoveStatus::Pending)),
        (false, true) => Ok(Some(MoveStatus::AlreadyMoved)),
        (true, true) => bail!(
            "flat-layout destination already exists for machine `{machine_id}` ({kind}): \
             source `{src}` and destination `{dest}` both present; resolve manually before \
             re-running.",
            src = source.display(),
            dest = destination.display()
        ),
        (false, false) => Ok(None),
    }
}

fn load_pod_toml(pod_dir: &Path) -> Result<Option<String>> {
    let path = pod_dir.join("pod.toml");
    if !path.is_file() {
        return Ok(None);
    }
    let raw =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let manifest: PodManifestFile =
        toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
    if manifest.pod.name.trim().is_empty() {
        bail!("{} must declare a non-empty [pod].name", path.display());
    }
    Ok(Some(manifest.pod.name))
}

fn read_machine_id(manifest_path: &Path) -> Result<String> {
    let raw = fs::read_to_string(manifest_path)
        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
    let manifest: MachineManifestFile = toml::from_str(&raw)
        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
    if manifest.machine.id.trim().is_empty() {
        bail!(
            "{} must declare a non-empty machine.id",
            manifest_path.display()
        );
    }
    crate::paths::state::validate_machine_id(&manifest.machine.id)?;
    Ok(manifest.machine.id)
}

fn inventory_residual_files(
    root: &Path,
    dir: &Path,
    moved_paths: &[PathBuf],
    out: &mut Vec<LegacyArtifact>,
) -> Result<()> {
    let mut entries: Vec<fs::DirEntry> = fs::read_dir(dir)
        .with_context(|| format!("failed to read {}", dir.display()))?
        .collect::<std::io::Result<Vec<_>>>()?;
    entries.sort_by_key(|entry| entry.file_name());
    for entry in entries {
        let path = entry.path();
        if moved_paths.iter().any(|moved| moved == &path) {
            continue;
        }
        if path.file_name().and_then(|n| n.to_str()) == Some(MIGRATED_MARKER) {
            continue;
        }
        let ft = entry.file_type()?;
        if ft.is_dir() {
            inventory_residual_files(root, &path, moved_paths, out)?;
            continue;
        }
        if !ft.is_file() {
            continue;
        }
        let metadata = entry.metadata()?;
        let relative = path
            .strip_prefix(root)
            .unwrap_or(&path)
            .display()
            .to_string();
        out.push(LegacyArtifact {
            path: relative,
            byte_size: metadata.len(),
            kind: classify_artifact(&path, root),
        });
    }
    Ok(())
}

fn classify_artifact(path: &Path, root: &Path) -> &'static str {
    let relative = path.strip_prefix(root).unwrap_or(path);
    match relative.to_str() {
        Some("policy.md") => "pod_policy",
        Some("memory.md") => "pod_memory",
        _ => "unknown",
    }
}

// ── validation ──────────────────────────────────────────────────────────

fn verify_no_duplicate_machine_ids(pending: &[PendingPod]) -> Result<()> {
    let mut by_id: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
    for plan in pending {
        if let Some(mm) = &plan.machine_move {
            by_id
                .entry(mm.machine_id.clone())
                .or_default()
                .push(plan.pod_dir.clone());
        }
    }
    for (id, pods) in &by_id {
        if pods.len() > 1 {
            let listing = pods
                .iter()
                .map(|p| p.display().to_string())
                .collect::<Vec<_>>()
                .join(", ");
            bail!("machine id `{id}` is declared in multiple pod directories: {listing}");
        }
    }
    Ok(())
}

// ── execution ───────────────────────────────────────────────────────────

fn execute_pending_moves(plan: &PendingPod) -> Result<()> {
    if let Some(mm) = &plan.machine_move {
        if mm.status == MoveStatus::Pending {
            let dest = Path::new(&mm.destination);
            if let Some(parent) = dest.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("failed to create {}", parent.display()))?;
            }
            fs::rename(&mm.source, dest)
                .with_context(|| format!("failed to move {} to {}", mm.source, dest.display()))?;
        }
    }
    for pm in &plan.presence_moves {
        if pm.status != MoveStatus::Pending {
            continue;
        }
        let dest = Path::new(&pm.destination);
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        fs::rename(&pm.source, dest)
            .with_context(|| format!("failed to move {} to {}", pm.source, dest.display()))?;
    }
    // Remove the now-empty presence/ subdir if it was fully drained.
    let presence_root = plan.pod_dir.join(PRESENCE_DIR);
    if presence_root.is_dir() {
        if let Ok(mut entries) = fs::read_dir(&presence_root) {
            if entries.next().is_none() {
                let _ = fs::remove_dir(&presence_root);
            }
        }
    }
    Ok(())
}

fn write_migrated_marker(plan: &PendingPod) -> Result<()> {
    let body = build_migrated_marker_body(plan);
    fs::write(&plan.marker_path, body)
        .with_context(|| format!("failed to write {}", plan.marker_path.display()))
}

fn build_migrated_marker_body(plan: &PendingPod) -> String {
    let mut out = String::new();
    out.push_str("# CCD Pod-Layer Migration\n\n");
    out.push_str(&format!(
        "Migrated by `ccd migrate from-pod-layout` at {} with ccd-cli {}.\n\n",
        iso8601_now(),
        env!("CARGO_PKG_VERSION"),
    ));
    out.push_str(&format!(
        "Source: {}\nDestination root: {}\n\n",
        plan.pod_dir.display(),
        plan.ccd_root.join("machines").display()
    ));

    out.push_str("## Relocated\n\n");
    if plan.machine_move.is_none() && plan.presence_moves.is_empty() {
        out.push_str("- (no machine identity or presence records found)\n");
    }
    if let Some(mm) = &plan.machine_move {
        let note = match mm.status {
            MoveStatus::Pending => "",
            MoveStatus::AlreadyMoved => " (already relocated by a prior run)",
        };
        out.push_str(&format!(
            "- `machine.toml` -> `{}` (machine id `{}`){}\n",
            mm.destination, mm.machine_id, note
        ));
    }
    for pm in &plan.presence_moves {
        let note = match pm.status {
            MoveStatus::Pending => "",
            MoveStatus::AlreadyMoved => " (already relocated by a prior run)",
        };
        out.push_str(&format!(
            "- `presence/{id}.json` -> `{dest}` (machine id `{id}`){note}\n",
            id = pm.machine_id,
            dest = pm.destination,
            note = note
        ));
    }

    out.push_str("\n## Preserved in this directory\n\n");
    out.push_str(
        "These files were NOT migrated. They remain here for your review.\n\
         CCD does not delete operator-authored state on the operator's behalf.\n\n",
    );
    if plan.legacy_artifacts.is_empty() {
        out.push_str("- (none)\n");
    } else {
        for artifact in &plan.legacy_artifacts {
            out.push_str(&format!(
                "- `{path}` ({size} bytes, kind={kind})\n",
                path = artifact.path,
                size = artifact.byte_size,
                kind = artifact.kind
            ));
        }
    }
    out.push_str(
        "\nTo reclaim space after reviewing, remove this directory with a\n\
         standard filesystem tool (e.g. `rm -rf`). CCD provides no subcommand\n\
         for the discard step by design.\n",
    );
    out
}

fn iso8601_now() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let days = secs / 86_400;
    let days_since_epoch = days as i64;
    let (year, month, day) = civil_from_days(days_since_epoch);
    let hour = (secs % 86_400) / 3_600;
    let minute = (secs % 3_600) / 60;
    let second = secs % 60;
    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}

// Howard Hinnant's civil_from_days; avoids pulling in chrono.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = y + (m <= 2) as i64;
    (year, m as u32, d as u32)
}

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

    #[test]
    fn classify_artifact_tags_known_files() {
        let root = Path::new("/tmp/pod");
        assert_eq!(
            classify_artifact(&root.join("policy.md"), root),
            "pod_policy"
        );
        assert_eq!(
            classify_artifact(&root.join("memory.md"), root),
            "pod_memory"
        );
        assert_eq!(classify_artifact(&root.join("pod.toml"), root), "unknown");
        assert_eq!(
            classify_artifact(&root.join("repos/foo/config.toml"), root),
            "unknown"
        );
    }

    #[test]
    fn iso8601_now_has_expected_shape() {
        let stamp = iso8601_now();
        assert_eq!(stamp.len(), 20);
        assert!(stamp.ends_with('Z'));
        assert!(stamp.as_bytes()[4] == b'-');
        assert!(stamp.as_bytes()[10] == b'T');
    }
}
