use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, io};

use bone_types::{SchemaHeader, SchemaVersion, SketchId};

use crate::document::{Document, DocumentHeader, FeatureNode, SketchFile, sketch_filename};
use crate::io::blob::{BlobHash, BlobKind};
use crate::io::ron_io::{RonError, from_str, to_string};
use crate::sketch::SketchEditError;

pub const DOCUMENT_FILE: &str = "document.ron";
pub const SKETCHES_DIR: &str = "sketches";
pub const BLOBS_DIR: &str = "blobs";
pub const CACHES_DIR: &str = "caches";

const ROOT_GITIGNORE: &str = "caches/\n";
const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.boneblob binary\n";
const CACHES_GITIGNORE: &str = "*\n!.gitignore\n!CACHEDIR.TAG\n";
const CACHEDIR_TAG: &str = concat!(
    "Signature: 8a477f597d28d172789f06886806bc55\n",
    "# This file is a cache directory tag automatically created by bone.\n",
    "# For information about cache directory tags see https://bford.info/cachedir/\n",
);

#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct FolderError(Box<FolderErrorKind>);

impl FolderError {
    #[must_use]
    pub fn kind(&self) -> &FolderErrorKind {
        &self.0
    }

    #[must_use]
    pub fn into_kind(self) -> FolderErrorKind {
        *self.0
    }
}

impl From<FolderErrorKind> for FolderError {
    fn from(kind: FolderErrorKind) -> Self {
        Self(Box::new(kind))
    }
}

impl FolderErrorKind {
    fn wrap(self) -> FolderError {
        FolderError(Box::new(self))
    }
}

#[derive(Debug, thiserror::Error)]
pub enum FolderErrorKind {
    #[error("io at {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    #[error("ron at {path}: {source}")]
    Ron {
        path: PathBuf,
        #[source]
        source: RonError,
    },
    #[error("unknown schema {found} (expected name {expected_name})")]
    UnknownSchema {
        found: String,
        expected_name: &'static str,
    },
    #[error("schema {name} major v{found} is unsupported (this build supports v{supported})")]
    UnsupportedMajor {
        name: String,
        found: SchemaVersion,
        supported: SchemaVersion,
    },
    #[error("registry references sketch {id:?} with no file on disk")]
    MissingSketchFile { id: SketchId },
    #[error("integrity of {path}: {source}")]
    SketchIntegrity {
        path: PathBuf,
        #[source]
        source: SketchEditError,
    },
    #[error("feature tree references sketch {id:?} not in registry")]
    DanglingTreeSketch { id: SketchId },
    #[error("registry has sketch {id:?} absent from feature tree")]
    OrphanRegistered { id: SketchId },
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DocumentFolder {
    path: PathBuf,
}

impl DocumentFolder {
    #[must_use]
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    #[must_use]
    pub fn document_file(&self) -> PathBuf {
        self.path.join(DOCUMENT_FILE)
    }

    #[must_use]
    pub fn sketches_dir(&self) -> PathBuf {
        self.path.join(SKETCHES_DIR)
    }

    #[must_use]
    pub fn blobs_dir(&self) -> PathBuf {
        self.path.join(BLOBS_DIR)
    }

    #[must_use]
    pub fn caches_dir(&self) -> PathBuf {
        self.path.join(CACHES_DIR)
    }

    #[must_use]
    pub fn sketch_path(&self, id: SketchId) -> PathBuf {
        self.sketches_dir().join(sketch_filename(id))
    }

    #[must_use]
    pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf {
        self.blobs_dir().join(hash.relative_path(kind))
    }
}

pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> {
    ensure_dir(folder.path())?;
    ensure_dir(&folder.sketches_dir())?;
    ensure_dir(&folder.blobs_dir())?;
    ensure_dir(&folder.caches_dir())?;

    write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?;
    write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?;
    write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?;
    write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)?;

    document
        .sketches()
        .try_for_each(|(id, sketch)| -> Result<(), FolderError> {
            let file = SketchFile::new(sketch.clone());
            let ron = to_ron(&folder.sketch_path(id), &file)?;
            write_if_different(&folder.sketch_path(id), &ron)
        })?;

    let document_ron = to_ron(&folder.document_file(), document.header())?;
    write_if_different(&folder.document_file(), &document_ron)?;

    let registry_ids: BTreeSet<SketchId> = document.registry().order().iter().copied().collect();
    remove_stale_sketches(&folder.sketches_dir(), &registry_ids)?;

    Ok(())
}

pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> {
    let header_path = folder.document_file();
    let header_text = read_to_string(&header_path)?;
    let header: DocumentHeader = from_ron(&header_path, &header_text)?;
    check_schema(&header.schema)?;
    validate_header(&header)?;

    let sketches =
        header
            .sketches
            .order()
            .iter()
            .copied()
            .try_fold(BTreeMap::new(), |mut acc, id| {
                let path = folder.sketch_path(id);
                let text = read_to_string(&path).map_err(|e| match e.into_kind() {
                    FolderErrorKind::Io { source, .. }
                        if source.kind() == io::ErrorKind::NotFound =>
                    {
                        FolderErrorKind::MissingSketchFile { id }.wrap()
                    }
                    other => other.wrap(),
                })?;
                let file: SketchFile = from_ron(&path, &text)?;
                check_schema(&file.schema)?;
                file.sketch.validate().map_err(|source| {
                    FolderErrorKind::SketchIntegrity {
                        path: path.clone(),
                        source,
                    }
                    .wrap()
                })?;
                acc.insert(id, file.sketch);
                Ok::<_, FolderError>(acc)
            })?;

    Ok(Document::from_parts(header, sketches))
}

fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> {
    let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect();
    let tree_sketches: BTreeSet<SketchId> = header
        .feature_tree
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Sketch(id) => Some(id),
            FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => None,
        })
        .collect();
    if let Some(&id) = tree_sketches.difference(&registered).next() {
        return Err(FolderErrorKind::DanglingTreeSketch { id }.wrap());
    }
    if let Some(&id) = registered.difference(&tree_sketches).next() {
        return Err(FolderErrorKind::OrphanRegistered { id }.wrap());
    }
    Ok(())
}

fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> {
    if !schema.is_bone_document() {
        return Err(FolderErrorKind::UnknownSchema {
            found: schema.name.clone(),
            expected_name: SchemaHeader::BONE_DOCUMENT_NAME,
        }
        .wrap());
    }
    let supported = SchemaVersion::new(
        SchemaHeader::BONE_DOCUMENT_MAJOR,
        SchemaHeader::BONE_DOCUMENT_MINOR,
    );
    if schema.version.major != SchemaHeader::BONE_DOCUMENT_MAJOR {
        return Err(FolderErrorKind::UnsupportedMajor {
            name: schema.name.clone(),
            found: schema.version,
            supported,
        }
        .wrap());
    }
    if schema.version.minor > SchemaHeader::BONE_DOCUMENT_MINOR {
        tracing::info!(
            name = %schema.name,
            found = %schema.version,
            supported = %supported,
            "accepting newer minor schema version; unknown fields will be rejected by deny_unknown_fields"
        );
    }
    Ok(())
}

fn ensure_dir(path: &Path) -> Result<(), FolderError> {
    fs::create_dir_all(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> {
    if let Ok(existing) = fs::read_to_string(path)
        && existing == contents
    {
        return Ok(());
    }
    atomic_write(path, contents)
}

fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    ensure_dir(parent)?;
    let tmp = tmp_sibling(path);
    write_and_sync(&tmp, contents)?;
    fs::rename(&tmp, path).map_err(|source| {
        let _ = fs::remove_file(&tmp);
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    sync_dir(parent)
}

fn write_and_sync(path: &Path, contents: &str) -> Result<(), FolderError> {
    let mut file = fs::File::create(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    file.write_all(contents.as_bytes()).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })?;
    file.sync_all().map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

#[cfg(unix)]
fn sync_dir(path: &Path) -> Result<(), FolderError> {
    fs::File::open(path)
        .and_then(|f| f.sync_all())
        .map_err(|source| {
            FolderErrorKind::Io {
                path: path.to_path_buf(),
                source,
            }
            .wrap()
        })
}

#[cfg(not(unix))]
fn sync_dir(_: &Path) -> Result<(), FolderError> {
    Ok(())
}

fn tmp_sibling(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .map(std::ffi::OsStr::to_os_string)
        .unwrap_or_default();
    let mut tmp_name = file_name;
    tmp_name.push(".tmp");
    path.with_file_name(tmp_name)
}

fn read_to_string(path: &Path) -> Result<String, FolderError> {
    fs::read_to_string(path).map_err(|source| {
        FolderErrorKind::Io {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn to_ron<T: serde::Serialize>(path: &Path, value: &T) -> Result<String, FolderError> {
    to_string(value).map_err(|source| {
        FolderErrorKind::Ron {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn from_ron<T: serde::de::DeserializeOwned>(path: &Path, text: &str) -> Result<T, FolderError> {
    from_str(text).map_err(|source| {
        FolderErrorKind::Ron {
            path: path.to_path_buf(),
            source,
        }
        .wrap()
    })
}

fn remove_stale_sketches(dir: &Path, live: &BTreeSet<SketchId>) -> Result<(), FolderError> {
    let entries = match fs::read_dir(dir) {
        Ok(iter) => iter,
        Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
        Err(source) => {
            return Err(FolderErrorKind::Io {
                path: dir.to_path_buf(),
                source,
            }
            .into());
        }
    };
    let live_names: BTreeSet<String> = live.iter().copied().map(sketch_filename).collect();
    let modified = entries.into_iter().try_fold(false, |modified, entry| {
        let entry = entry.map_err(|source| {
            FolderErrorKind::Io {
                path: dir.to_path_buf(),
                source,
            }
            .wrap()
        })?;
        let name = entry.file_name().to_string_lossy().into_owned();
        let looks_like_ron = Path::new(&name)
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("ron"));
        if looks_like_ron && !live_names.contains(&name) {
            let path = entry.path();
            fs::remove_file(&path).map_err(|source| FolderErrorKind::Io { path, source }.wrap())?;
            Ok::<_, FolderError>(true)
        } else {
            Ok(modified)
        }
    })?;
    if modified {
        sync_dir(dir)?;
    }
    Ok(())
}
