#![warn(missing_docs)]
use std::{
    borrow::Cow,
    collections::HashMap,
    default,
    fmt::{Display, Write as _},
};
use anyhow::{anyhow, bail, Context as _, Result};
use camino::{Utf8Path, Utf8PathBuf};
use tracing::{span, Level};
use diskplan_filesystem::{Filesystem, PlantedPath, SetAttrs};
use diskplan_schema::{Binding, DirectorySchema, SchemaNode, SchemaType};
use self::{eval::evaluate, pattern::CompiledPattern};
mod eval;
mod pattern;
mod stack;
pub use stack::{StackFrame, VariableSource};
#[derive(Copy, Clone, Default)]
pub enum Extent {
    #[default]
    Full,
    Restricted,
}
pub fn traverse<FS>(
    path: impl AsRef<Utf8Path>,
    stack: &StackFrame,
    filesystem: &mut FS,
    extent: Extent,
) -> Result<()>
where
    FS: Filesystem,
{
    let path = path.as_ref();
    let span = span!(Level::DEBUG, "traverse", path = path.as_str());
    let _span = span.enter();
    if !path.is_absolute() {
        bail!("Path must be absolute: {}", path);
    }
    let (schema_node, root) = stack.config.schema_for(path)?;
    let start_path = PlantedPath::new(root, None)?;
    let remaining_path = path
        .strip_prefix(root.path())
        .expect("Located root must prefix path");
    tracing::debug!(
        r#"Traversing root directory "{}" ("{}" relative path remains)"#,
        start_path,
        remaining_path,
    );
    traverse_node(
        schema_node,
        &start_path,
        remaining_path,
        extent,
        stack,
        filesystem,
    )
    .with_context(|| {
        schema_context(
            "Failed to apply schema",
            schema_node,
            start_path.absolute(),
            remaining_path,
            stack,
        )
    })?;
    Ok(())
}
fn traverse_node<'a, FS>(
    schema_node: &'a SchemaNode<'a>,
    path: &PlantedPath,
    remaining: &Utf8Path,
    extent: Extent,
    stack: &StackFrame<'a, '_, '_>,
    filesystem: &mut FS,
) -> Result<()>
where
    FS: Filesystem,
{
    let span = span!(Level::DEBUG, "traverse_node", node = schema_node.line);
    let _span = span.enter();
    let mut unresolved = if remaining == "" { None } else { Some(vec![]) };
    let expanded = expand_uses(schema_node, stack)?;
    let mut owner = None;
    let mut group = None;
    let mut mode = None;
    for usage in std::iter::once(&schema_node).chain(expanded.iter()) {
        owner = owner.or(usage.attributes.owner.as_ref());
        group = group.or(usage.attributes.group.as_ref());
        mode = mode.or(usage.attributes.mode);
    }
    let evaluated_owner;
    let owner = match owner {
        Some(expr) => {
            evaluated_owner = evaluate(expr, stack, path)?;
            Some(stack.config.map_user(&evaluated_owner))
        }
        None => Some(stack.owner()),
    };
    let evaluated_group;
    let group = match group {
        Some(expr) => {
            evaluated_group = evaluate(expr, stack, path)?;
            Some(stack.config.map_group(&evaluated_group))
        }
        None => Some(stack.group()),
    };
    let mode = Some(mode.map(Into::into).unwrap_or_else(|| stack.mode()));
    let attrs = SetAttrs { owner, group, mode };
    let mut stack = stack.push(VariableSource::Empty);
    if let Some(owner) = owner {
        stack.put_owner(owner);
    }
    if let Some(group) = group {
        stack.put_group(group);
    }
    let stack = &stack;
    for schema_node in expanded {
        tracing::debug!("Applying: {}", schema_node);
        create(schema_node, path, attrs.clone(), stack, filesystem)
            .with_context(|| format!("Creating {}", &path))?;
        if let SchemaType::Directory(ref directory_schema) = schema_node.schema {
            let resolution = traverse_directory(
                schema_node,
                directory_schema,
                path,
                remaining,
                extent,
                stack,
                filesystem,
            )
            .with_context(|| {
                schema_context(
                    "Applying directory schema",
                    schema_node,
                    path.absolute(),
                    remaining,
                    stack,
                )
            })?;
            match resolution {
                Resolution::FullyResolved => unresolved = None,
                Resolution::Unresolved(path) => {
                    if let Some(ref mut issues) = unresolved {
                        issues.push((schema_node, path));
                    }
                }
            }
        }
    }
    if let Some(issues) = unresolved {
        let mut message =
            format!("No schema within \"{path}\" was able to produce \"{remaining}\"");
        for (schema_node, _) in issues {
            write!(message, "\nInside: {schema_node}:")?;
            if let SchemaType::Directory(dir) = &schema_node.schema {
                if dir.entries().is_empty() {
                    write!(message, "\n  No entries to match",)?;
                }
                for (binding, node) in dir.entries() {
                    write!(message, "\n  Considered: {binding} - {node}")?;
                }
            }
        }
        Err(anyhow!("{}", message)).with_context(|| {
            schema_context(
                "Applying directory entries",
                schema_node,
                path.absolute(),
                remaining,
                stack,
            )
        })?;
    }
    Ok(())
}
#[must_use]
enum Resolution {
    FullyResolved,
    Unresolved(Utf8PathBuf),
}
#[derive(Debug, Clone, Copy)]
enum Source {
    Disk,
    Path,
    Schema,
}
impl Display for Source {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Source::Disk => write!(f, "on disk"),
            Source::Path => write!(f, "the target path"),
            Source::Schema => write!(f, "the schema"),
        }
    }
}
fn schema_context(
    message: &str,
    schema_node: &SchemaNode,
    path: &Utf8Path,
    remaining: &Utf8Path,
    stack: &StackFrame,
) -> anyhow::Error {
    anyhow!(
        "{}\n  To path: \"{}\" (\"{}\" remaining)\n  {}\n{}",
        message,
        path,
        remaining,
        schema_node,
        stack,
    )
}
fn traverse_directory<'a, FS>(
    schema_node: &SchemaNode,
    directory_schema: &'a DirectorySchema,
    directory_path: &PlantedPath,
    remaining: &Utf8Path,
    extent: Extent,
    stack: &StackFrame<'a, '_, '_>,
    filesystem: &mut FS,
) -> Result<Resolution>
where
    FS: Filesystem,
{
    if let (Extent::Restricted, "") = (extent, remaining.as_ref()) {
        return Ok(Resolution::FullyResolved);
    }
    let stack = stack.push(VariableSource::Directory(directory_schema));
    let (sought, remaining) = remaining
        .as_str()
        .split_once('/')
        .map(|(name, remaining)| (Some(name), Utf8Path::new(remaining)))
        .unwrap_or(if remaining == "" {
            (None, Utf8Path::new(""))
        } else {
            (Some(remaining.as_str()), Utf8Path::new(""))
        });
    let mut names: HashMap<Cow<str>, (Source, Option<_>)> = HashMap::new();
    let with_source = |src: Source| move |key| (key, (src, None));
    if let Extent::Full = extent {
        names.extend(
            filesystem
                .list_directory(directory_path.absolute())
                .unwrap_or_default()
                .into_iter()
                .map(Cow::Owned)
                .map(with_source(Source::Disk)),
        );
    }
    names.extend(sought.map(Cow::Borrowed).map(with_source(Source::Path)));
    let mut compiled_schema_entries = Vec::with_capacity(directory_schema.entries().len());
    for (binding, child_node) in directory_schema.entries() {
        let pattern = CompiledPattern::compile(
            child_node.match_pattern.as_ref(),
            child_node.avoid_pattern.as_ref(),
            &stack,
            directory_path,
        )?;
        if let Some(name) = match *binding {
            Binding::Static(name) => Some(Cow::Borrowed(name)),
            Binding::Dynamic(var) => evaluate(&var.into(), &stack, directory_path)
                .ok()
                .filter(|name| pattern.matches(name))
                .map(Cow::Owned),
        } {
            names.insert(name, (Source::Schema, None));
        }
        compiled_schema_entries.push((binding, child_node, pattern));
    }
    tracing::trace!("Within {}...", directory_path);
    for (binding, child_node, pattern) in compiled_schema_entries {
        for (name, (_, have_match)) in names.iter_mut() {
            match binding {
                Binding::Static(bound_name) if bound_name == name => match have_match {
                    None => {
                        *have_match = Some((binding, child_node));
                        Ok(())
                    }
                    Some((bound, _)) => Err(anyhow!(
                        r#""{}" matches multiple static bindings "{}" and "{}""#,
                        name,
                        bound,
                        binding
                    )),
                },
                Binding::Dynamic(_) if pattern.matches(name) => {
                    match have_match {
                        None => {
                            *have_match = Some((binding, child_node));
                            Ok(())
                        }
                        Some((bound, _)) => match bound {
                            Binding::Static(_) => Ok(()), Binding::Dynamic(_) => Err(anyhow!(
                                r#""{}" matches multiple dynamic bindings "{}" and "{}" (latter matched: {})"#,
                                name,
                                bound,
                                binding,
                                pattern,
                            )),
                        },
                    }
                }
                _ => Ok(()),
            }?;
        }
    }
    for (name, (source, have_match)) in names.iter() {
        match have_match {
            None => tracing::warn!(
                r#""{}" from {} has no match in "{}" under {}"#,
                name,
                source,
                directory_path,
                schema_node
            ),
            Some((Binding::Static(_), _)) => {
                tracing::trace!(r#""{}" from {} matches same, binding static"#, name, source)
            }
            Some((Binding::Dynamic(id), node)) => tracing::trace!(
                r#""{}" from {} matches {:?}, binding to variable ${{{}}}"#,
                name,
                source,
                node.match_pattern,
                id.value()
            ),
        }
    }
    let mut sought_matched = sought.is_none();
    for (name, (_, matched)) in names {
        let Some((binding, child_schema)) = matched else { continue };
        let name = name.as_ref();
        let child_path = directory_path.join(name)?;
        let remaining = if sought == Some(name) {
            sought_matched = true;
            remaining
        } else {
            if let Extent::Restricted = extent {
                continue;
            }
            Utf8Path::new("")
        };
        match binding {
            Binding::Static(s) => {
                tracing::debug!(
                    r#"Traversing static directory entry "{}" at {} ("{}" relative path remains)"#,
                    s,
                    &child_path,
                    remaining,
                );
                traverse_node(
                    child_schema,
                    &child_path,
                    remaining,
                    extent,
                    &stack,
                    filesystem,
                )
                .with_context(|| format!("Processing path {}", &child_path))?;
            }
            Binding::Dynamic(var) => {
                tracing::debug!(
                    r#"Traversing variable directory entry ${}="{}" at {} ("{}" relative path remains)"#,
                    var,
                    name,
                    &child_path,
                    remaining,
                );
                let stack = StackFrame::push(&stack, VariableSource::Binding(var, name.into()));
                traverse_node(
                    child_schema,
                    &child_path,
                    remaining,
                    extent,
                    &stack,
                    filesystem,
                )
                .with_context(|| {
                    format!(
                        r#"Processing path {} (with {})"#,
                        &child_path,
                        &stack
                            .variables()
                            .as_binding()
                            .map(|(var, value)| format!("${var} = {value}"))
                            .unwrap_or_else(|| "<no binding>".into()),
                    )
                })?;
            }
        }
    }
    if !sought_matched {
        let unresolved = Utf8PathBuf::from(format!("{}/{}", sought.unwrap(), remaining));
        Ok(Resolution::Unresolved(unresolved))
    } else {
        Ok(Resolution::FullyResolved)
    }
}
fn create<FS>(
    schema_node: &SchemaNode,
    path: &PlantedPath,
    attrs: SetAttrs,
    stack: &StackFrame,
    filesystem: &mut FS,
) -> Result<()>
where
    FS: Filesystem,
{
    let span = span!(
        Level::DEBUG,
        "create",
        node = schema_node.line,
        path = path.absolute().as_str(),
        attrs = &attrs.owner
    );
    let _span = span.enter();
    let link_str;
    let link_path;
    let link_target;
    let to_create;
    if let Some(expr) = &schema_node.symlink {
        link_str = evaluate(expr, stack, path)?;
        link_path = Utf8Path::new(&link_str);
        tracing::info!("Creating {} -> {}", path, link_path);
        if !link_path.is_absolute() {
            if schema_node.attributes.is_empty()
                && schema_node.uses.is_empty()
                && schema_node
                    .schema
                    .as_directory()
                    .map(|d| d.entries().is_empty())
                    .unwrap_or_default()
            {
                filesystem
                    .create_symlink(path.absolute(), link_path)
                    .context("As symlink")?;
                return Ok(());
            } else {
                bail!(concat!(
                    "Relative paths in symlinks are only supported for directories whose schema ",
                    "nodes have no attributes, use statements, or child entries"
                ));
            }
        }
        let (_, link_root) = stack.config.schema_for(link_path).with_context(|| {
            anyhow!(
                "No schema found for symlink target {} -> {}",
                path,
                link_path
            )
        })?;
        link_target = PlantedPath::new(link_root, Some(link_path))
            .with_context(|| format!("Following symlink {path} -> {link_path}"))?;
        if !filesystem.exists(link_target.absolute()) {
            traverse(
                link_target.absolute(),
                stack,
                filesystem,
                Extent::Restricted,
            )?;
            assert!(filesystem.exists(link_target.absolute()));
        }
        filesystem
            .create_symlink(path.absolute(), link_target.absolute())
            .context("As symlink")?;
        to_create = link_target.absolute();
    } else {
        tracing::info!("Creating {}", path);
        to_create = path.absolute();
    }
    match &schema_node.schema {
        SchemaType::Directory(_) => {
            if !filesystem.is_directory(to_create) {
                tracing::debug!("Make directory: {}", to_create);
                filesystem
                    .create_directory(to_create, attrs)
                    .context("As directory")?;
            } else {
                let dir_attrs = filesystem.attributes(to_create)?;
                if !attrs.matches(&dir_attrs) {
                    filesystem.set_attributes(to_create, attrs)?;
                }
            }
        }
        SchemaType::File(file) => {
            if !filesystem.is_file(to_create) {
                let source = evaluate(file.source(), stack, path)?;
                let content = filesystem.read_file(source)?;
                filesystem
                    .create_file(to_create, attrs, content)
                    .context("As file")?;
            }
        }
    }
    Ok(())
}
fn expand_uses<'a>(
    schema_node: &'a SchemaNode<'_>,
    stack: &StackFrame<'a, '_, '_>,
) -> Result<Vec<&'a SchemaNode<'a>>> {
    let mut use_schemas = Vec::with_capacity(1 + schema_node.uses.len());
    use_schemas.push(schema_node);
    let stack = stack.push(match schema_node {
        SchemaNode {
            schema: SchemaType::Directory(d),
            ..
        } => VariableSource::Directory(d),
        _ => VariableSource::Empty,
    });
    for used in &schema_node.uses {
        tracing::trace!("Seeking definition of '{}'", used);
        use_schemas.push(
            stack
                .find_definition(used)
                .ok_or_else(|| anyhow!("No definition (:def) found for \"{}\"", used))?,
        );
    }
    Ok(use_schemas)
}
#[cfg(test)]
mod tests;