conpub 0.1.1

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use super::models::*;
use crate::support::now_unix_seconds;
use serde_json::json;
use std::collections::{HashMap, HashSet};

pub(crate) fn build_sync_plan(
    snapshots: &[DocumentSnapshot],
    state: &SyncState,
    include_deleted: bool,
    source: &str,
) -> (Vec<SyncItemResult>, Vec<DocumentSnapshot>) {
    let mut items = Vec::new();
    let mut publish_snapshots = Vec::new();
    let mut current_paths = HashSet::new();

    for snapshot in snapshots {
        current_paths.insert(snapshot.document.path.clone());
        let previous = state.documents.get(&snapshot.document.path);
        let previous_fingerprint = previous.map(|entry| entry.fingerprint.clone());
        let (action, status, reason) = match previous {
            None => ("create", "pending", "not present in local publish state"),
            Some(entry) if entry.parent_path != snapshot.parent_path => (
                "update",
                "pending",
                "parent path differs from local publish state",
            ),
            Some(entry) if entry.fingerprint == snapshot.fingerprint => (
                "unchanged",
                "skipped",
                "fingerprint matches local publish state",
            ),
            Some(_) => (
                "update",
                "pending",
                "fingerprint differs from local publish state",
            ),
        };

        if action == "create" || action == "update" {
            publish_snapshots.push(snapshot.clone());
        }

        items.push(SyncItemResult {
            path: snapshot.document.path.clone(),
            title: snapshot.document.title.clone(),
            slug: snapshot.slug.clone(),
            parent_path: snapshot.parent_path.clone(),
            parent_id: None,
            action: action.to_string(),
            status: status.to_string(),
            fingerprint: Some(snapshot.fingerprint.clone()),
            previous_fingerprint,
            url: None,
            platform_id: None,
            archive_task_id: None,
            error: None,
            reason: Some(reason.to_string()),
        });
    }

    if include_deleted {
        let mut deleted = state
            .documents
            .iter()
            .filter(|(path, _)| !current_paths.contains(*path) && path_is_in_source(path, source))
            .map(|(path, entry)| SyncItemResult {
                path: path.clone(),
                title: entry.title.clone(),
                slug: entry.slug.clone(),
                parent_path: entry.parent_path.clone(),
                parent_id: None,
                action: "deleted".to_string(),
                status: "skipped".to_string(),
                fingerprint: None,
                previous_fingerprint: Some(entry.fingerprint.clone()),
                url: None,
                platform_id: None,
                archive_task_id: None,
                error: None,
                reason: Some("local file is missing; remote delete is not implemented".to_string()),
            })
            .collect::<Vec<_>>();
        deleted.sort_by(|a, b| a.path.cmp(&b.path));
        items.extend(deleted);
    }

    (items, publish_snapshots)
}

fn path_is_in_source(path: &str, source: &str) -> bool {
    if source.is_empty() || source == "." {
        return true;
    }

    path == source
        || path
            .strip_prefix(source)
            .is_some_and(|rest| rest.starts_with('/'))
}

pub(crate) fn merge_publish_results_into_sync_items(
    items: &mut [SyncItemResult],
    publish_results: &[PublishItemResult],
) {
    let by_path = publish_results
        .iter()
        .map(|result| (result.path.as_str(), result))
        .collect::<HashMap<_, _>>();

    for item in items {
        if item.action != "create" && item.action != "update" {
            continue;
        }

        if let Some(result) = by_path.get(item.path.as_str()) {
            item.status = result.status.clone();
            item.parent_id = result.parent_id.clone();
            item.url = result.url.clone();
            item.platform_id = result.platform_id.clone();
            item.error = result.error.clone();
            item.reason = None;
        }
    }
}

pub(crate) fn apply_archive_deleted_plan(items: &mut [SyncItemResult], archive_deleted: bool) {
    if !archive_deleted {
        return;
    }

    for item in items {
        if item.action != "deleted" {
            continue;
        }

        if item.platform_id.is_some() {
            item.status = "pending_archive".to_string();
            item.reason = Some("would archive Confluence page from typub status".to_string());
        } else {
            item.status = "skipped".to_string();
            item.reason = Some("cannot archive deleted page without a Confluence ID".to_string());
        }
    }
}

pub(crate) fn remove_archived_deleted_from_state(state: &mut SyncState, items: &[SyncItemResult]) {
    for item in items {
        if item.action == "deleted" && item.status == "archived" {
            state.documents.remove(&item.path);
        }
    }
}

pub(crate) fn update_sync_state_from_sync_results(
    state: &mut SyncState,
    snapshots: &[DocumentSnapshot],
    items: &[SyncItemResult],
) {
    let snapshot_by_path = snapshots
        .iter()
        .map(|snapshot| (snapshot.document.path.as_str(), snapshot))
        .collect::<HashMap<_, _>>();

    for item in items {
        if item.status != "published" {
            continue;
        }

        if let Some(snapshot) = snapshot_by_path.get(item.path.as_str()) {
            state.documents.insert(
                item.path.clone(),
                SyncStateDocument {
                    fingerprint: snapshot.fingerprint.clone(),
                    title: snapshot.document.title.clone(),
                    slug: snapshot.slug.clone(),
                    parent_path: snapshot.parent_path.clone(),
                    synced_at: now_unix_seconds(),
                },
            );
        }
    }
}

pub(crate) fn sync_counts(items: &[SyncItemResult]) -> serde_json::Value {
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for item in items {
        *counts.entry(item.action.as_str()).or_default() += 1;
    }

    json!({
        "create": counts.get("create").copied().unwrap_or(0),
        "update": counts.get("update").copied().unwrap_or(0),
        "unchanged": counts.get("unchanged").copied().unwrap_or(0),
        "deleted": counts.get("deleted").copied().unwrap_or(0),
    })
}

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

    #[test]
    fn sync_state_update_does_not_persist_remote_publish_fields() {
        let snapshots = vec![DocumentSnapshot {
            document: Document {
                path: "projects/cuda-agent/notes.typ".to_string(),
                title: "Notes".to_string(),
                extension: "typ".to_string(),
            },
            slug: "projects-cuda-agent-notes".to_string(),
            fingerprint: "fingerprint-v1".to_string(),
            parent_path: Some("projects/cuda-agent/_index.md".to_string()),
            hierarchy_order: 1,
        }];
        let items = vec![SyncItemResult {
            path: "projects/cuda-agent/notes.typ".to_string(),
            title: "Notes".to_string(),
            slug: "projects-cuda-agent-notes".to_string(),
            parent_path: Some("projects/cuda-agent/_index.md".to_string()),
            parent_id: Some("parent-42".to_string()),
            action: "update".to_string(),
            status: "published".to_string(),
            fingerprint: Some("fingerprint-v1".to_string()),
            previous_fingerprint: Some("fingerprint-v0".to_string()),
            url: Some("https://example.atlassian.net/wiki/spaces/GPU/pages/42/Notes".to_string()),
            platform_id: Some("42".to_string()),
            archive_task_id: None,
            error: None,
            reason: None,
        }];
        let mut state = SyncState::new(SyncStateIdentity {
            root: "/kb".to_string(),
            source: "projects/cuda-agent".to_string(),
            base_url: Some("https://example.atlassian.net".to_string()),
            space: "GPU".to_string(),
            parent_id: "123456789".to_string(),
        });

        update_sync_state_from_sync_results(&mut state, &snapshots, &items);

        let entry = state
            .documents
            .get("projects/cuda-agent/notes.typ")
            .expect("state entry");
        assert_eq!(entry.fingerprint, "fingerprint-v1");
        assert_eq!(
            entry.parent_path.as_deref(),
            Some("projects/cuda-agent/_index.md")
        );
    }
}