//! Mount [`Tocco`][1] DMS locally.
//!
//! [1]: https://www.tocco.ch

mod attr;
mod consts;
mod deser;
mod error;
mod file_name;
mod http;
mod utils;

use attr::{Attr, Ino};
use clap::{Parser, Subcommand, ValueEnum};
use consts::*;
use deser::structs::*;
use error::Result;
use file_name::FileName;
use fuser::{
    FileType, Filesystem, KernelConfig, MountOption, ReplyAttr, ReplyData, ReplyDirectory,
    ReplyEmpty, ReplyEntry, ReplyXattr, Request,
};
use http::{ToccoClientConfig, ToccoCred};
use libc::{EACCES, EINVAL, EIO, EISDIR, ENOENT, ENOTDIR, ENOTEMPTY, EPERM, c_int};
use log::{debug, error, trace, warn};
use reqwest::StatusCode;
use reqwest::blocking::Client;
use serde::Serialize;
use std::collections::HashMap;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Instant;
use std::time::{Duration, SystemTime};
use utils::TqlQuote as _;

type IoResult<T> = std::result::Result<T, c_int>;

#[derive(Parser)]
#[command(max_term_width = 100)]
pub struct Args {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    #[command()]
    Mount(MountArgs),
}

#[derive(Parser)]
#[command()]
pub struct MountArgs {
    /// Host name
    ///
    /// Example: master.tocco.ch
    #[arg()]
    host_name: String,

    /// Path to empty directory where FS is to be mounted
    #[arg()]
    mount_path: PathBuf,

    /// User name sent to Tocco
    #[arg(long, short, requires = "api_key_file")]
    user_name: Option<String>,

    /// API key send to Tocco
    #[arg(long, requires = "user_name")]
    api_key_file: Option<String>,

    /// Business unit sent to Tocco
    #[arg(long, short)]
    business_unit: Option<String>,

    /// Domain type used on mkdir
    #[arg(long, default_value_t = CreateDomainType::Internal, value_enum)]
    create_domain_type: CreateDomainType,
}

#[derive(Clone, Copy, ValueEnum)]
enum CreateDomainType {
    Internal,
    External,
}

impl CreateDomainType {
    fn unique_id(self) -> &'static str {
        match self {
            CreateDomainType::Internal => "internal_file_repository",
            CreateDomainType::External => "public_file_repository",
        }
    }
}

struct Fs {
    base_url: String,
    cache_timeout: Duration,
    create_domain_type: CreateDomainType,
    create_domain_lang: String,
    binary_url_cache: HashMap<Ino, BinaryCacheEntry>,
    lookup_cache: LookupCache,
    http_tocco: Client,
    http_unauth: Client,
}

#[derive(Default)]
struct LookupCache {
    cache: HashMap<(Ino, String), CachedAttr>,
}

impl LookupCache {
    fn retain(&mut self, cutoff_time: Instant) {
        self.cache.retain(|_, v| v.cache_time >= cutoff_time);
    }

    fn insert(&mut self, ino: Ino, file_name: &FileName, attr: CachedAttr) {
        self.cache
            .insert((ino, file_name.fs_file_name().to_owned()), attr);
    }

    fn get(&self, ino: Ino, file_name: &FileName) -> Option<&CachedAttr> {
        self.cache.get(&(ino, file_name.fs_file_name().to_owned()))
    }

    fn remove(&mut self, ino: Ino, file_name: &FileName) {
        // TODO this shouldn't use to_owned()
        self.cache
            .remove(&(ino, file_name.fs_file_name().to_owned()));
    }

    fn remove_by_inode(&mut self, ino: Ino) {
        // TODO maybe a bit expensive
        self.cache.retain(|_k, v| v.inner.ino() != ino);
    }
}

struct BinaryCacheEntry {
    cache_time: Instant,
    size: u64,
    final_url: String,
}

struct CachedAttr {
    cache_time: Instant,
    inner: Attr,
}

impl CachedAttr {
    fn new(attr: &Attr) -> CachedAttr {
        CachedAttr {
            cache_time: Instant::now(),
            inner: attr.to_owned(),
        }
    }
}

struct FileMetadata {
    size: u64,
    url: String,
    mtime: SystemTime,
}

fn main() -> ExitCode {
    if env::var_os(env_logger::DEFAULT_FILTER_ENV).is_some() {
        env_logger::init();
    } else {
        env_logger::builder()
            .filter_module(module_path!(), log::LevelFilter::Info)
            .init();
    }

    let global_args = Args::parse();
    let result = match &global_args.command {
        Commands::Mount(mount_args) => mount(&global_args, mount_args),
    };
    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            error!("terminating because of a fatal error: {e}");
            ExitCode::FAILURE
        }
    }
}

fn mount(_global_args: &Args, mount_args: &MountArgs) -> Result<()> {
    let base_url = format!("https://{}", mount_args.host_name);
    let cred = match (&mount_args.user_name, &mount_args.api_key_file) {
        (Some(user), Some(api_key_file)) => {
            let api_key = fs::read_to_string(api_key_file)
                .map_err(|e| io::Error::new(e.kind(), format!("failed to load API key: {e}")))?;
            let api_key = api_key.trim().to_string();
            Some((user, api_key))
        }
        (None, None) => None,
        _ => unreachable!(),
    };
    let http_tocco = http::client_for_tocco(&ToccoClientConfig {
        cred: match &cred {
            Some((user, api_key)) => ToccoCred::Some {
                user,
                api_key: &api_key[..],
            },
            None => ToccoCred::None,
        },
        business_unit: mount_args.business_unit.as_deref(),
        base_url: &base_url,
    })?;
    let fs = Fs {
        create_domain_lang: http::arbitrary_language(&base_url)?,
        base_url,
        binary_url_cache: HashMap::new(),
        cache_timeout: Duration::from_secs(60),
        create_domain_type: mount_args.create_domain_type,
        lookup_cache: LookupCache::default(),
        http_tocco,
        http_unauth: http::client()?,
    };

    fuser::mount2(
        fs,
        &mount_args.mount_path,
        &[
            MountOption::FSName("toccofs".to_string()),
            MountOption::NoExec,
            MountOption::NoSuid,
            // unsupported as of FreeBSD 14.2
            #[cfg(not(target_os = "freebsd"))]
            MountOption::NoDev,
            // MountOption::AutoUnmount,
        ],
    )?;
    Ok(())
}

struct Entries {
    next_offset: Ino,
    entries: Vec<Attr>,
}

impl Fs {
    fn clear_expired_cache_entries(&mut self) {
        // TODO
        // self.lookup_cache.clear();
        // self.binary_url_cache.clear();

        let cutoff_time = Instant::now() - self.cache_timeout;
        self.lookup_cache.retain(cutoff_time);
        self.binary_url_cache
            .retain(|_, v| v.cache_time >= cutoff_time);
    }

    fn lookup_in_root(&self, name: &FileName) -> IoResult<Attr> {
        use deser::structs::domains::*;

        let url = format!("{}/nice2/rest/entities/2.0/Domain", self.base_url);
        let domains: Domains = self
            .http_tocco
            .get(url)
            .query(&[
                ("_where", &format!(r#"label == {}"#, name.tql_quote())[..]),
                ("_paths", "label,update_timestamp"),
                ("_sort", "pk DESC"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                error!("Failed to look up domain {name:?}: {e}.");
                EIO
            })?;

        if domains.domains.len() > 1 {
            warn!("Multiple domains with name {name:?} found, returning entry with highest key");
        }
        if let Some(domain) = domains.domains.into_iter().next() {
            Ok(Attr::new(
                domain.key(),
                DOMAIN_TYPE,
                domain.version(),
                domain.name(),
                0,
                Some(domain.mtime()),
            ))
        } else {
            Err(ENOENT)
        }
    }

    fn lookup_in_folder(&self, parent_ino: Ino, name: &FileName) -> IoResult<Attr> {
        enum Type {
            Unknown,
            Dir,
            File,
        }

        let (r#where, r#type) = if let Some(parent_key) = parent_ino.dir_key() {
            let r#where = format!(
                r#"relFolder.pk == {parent_key} and label == {}"#,
                name.tql_quote()
            );
            (r#where, Type::Unknown)
        } else if let Some(parent_key) = parent_ino.domain_key() {
            let r#where = format!(
                r#"relDomain.pk == {parent_key} and label == {}"#,
                name.tql_quote()
            );
            (r#where, Type::Unknown)
        } else if parent_ino.raw() == LOST_AND_FOUND_ID {
            let ino = name.lost_and_found_entry_ino().unwrap();
            let name = name.tocco_file_name();
            let r#where = format!(
                r#"pk == {} and label == {}"#,
                ino.key().unwrap(),
                name.tql_quote()
            );
            let r#type = if ino.is_file() {
                Type::File
            } else {
                debug_assert!(ino.is_dir());
                Type::Dir
            };
            (r#where, r#type)
        } else {
            unreachable!("unexpected parent ino, {parent_ino:?}")
        };

        if matches!(r#type, Type::Unknown | Type::Dir) {
            let url = format!("{}/nice2/rest/entities/2.0/Folder", self.base_url);
            let folders: Folders = self
            .http_tocco
            .get(url)
            .query(&[
                ("_where", &r#where[..]),
                ("_paths", "label,update_timestamp"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    debug!(
                        "{parent_ino:?}: parent directory missing looking up contained folders: {e}"
                    );
                    ENOENT
                } else {
                    warn!("{parent_ino:?}: failed to read directories contained in directory: {e}");
                    EIO
                }
            })?;

            debug_assert!(folders.folders.len() < 2);
            if let Some(folder) = folders.folders.into_iter().next() {
                return Ok(Attr::new(
                    folder.key(),
                    FOLDER_TYPE,
                    folder.version(),
                    folder.name(),
                    0,
                    Some(folder.mtime()),
                ));
            }
        }

        if matches!(r#type, Type::Unknown | Type::File) {
            let url = format!("{}/nice2/rest/entities/2.0/Resource", self.base_url);
            let files: Resources = self
            .http_tocco
            .get(url)
            .query(&[
                ("_where", &r#where[..]),
                (
                    "_paths",
                    "label,relContent.data,relContent.update_timestamp",
                ),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    debug!(
                        "{parent_ino:?}: parent directory missing looking up contained files: {e}"
                    );
                    ENOENT
                } else {
                    warn!("{parent_ino:?}: failed to read directories contained in directory: {e}");
                    EIO
                }
            })?;

            debug_assert!(files.resources.len() < 2);
            if let Some(file) = files.resources.into_iter().next() {
                return Ok(Attr::new(
                    file.key(),
                    FILE_TYPE,
                    file.version(),
                    file.name(),
                    file.size(),
                    Some(file.mtime()),
                ));
            }
        }

        Err(ENOENT)
    }

    fn domains(&mut self, offset: Ino) -> IoResult<Entries> {
        assert!(offset.raw() <= DOMAIN_TYPE_END);

        let url = format!("{}/nice2/rest/entities/2.0/Domain", self.base_url);
        let domains: Domains = self
            .http_tocco
            .get(url)
            .query(&[
                ("_paths", "label,update_timestamp"),
                ("_sort", "pk"),
                ("_limit", "100"),
                ("_where", &format!("pk > {offset}")[..]),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                warn!("failed to list domains: {e}");
                libc::EIO
            })?;
        let has_more = domains.has_more();
        let entries: Vec<_> = domains
            .domains
            .into_iter()
            .map(|domain| {
                Attr::new(
                    domain.key(),
                    DOMAIN_TYPE,
                    domain.version(),
                    domain.name(),
                    0,
                    Some(domain.mtime()),
                )
            })
            .collect();

        Ok(Entries {
            next_offset: if has_more {
                entries.last().unwrap().ino()
            } else {
                Ino::from_raw(DOMAIN_TYPE_END)
            },
            entries,
        })
    }

    fn list_files(
        &mut self,
        ino: Ino,
        offset: Ino,
        parent_name: &str,
        limit: u32,
    ) -> IoResult<Entries> {
        let dir_key = ino
            .dir_key()
            .or_else(|| ino.domain_key())
            .expect("parent not a folder or domain");
        let offset = offset.file_key().unwrap_or(0);
        let url = format!("{}/nice2/rest/entities/2.0/Resource", self.base_url);
        let files: Resources = self
            .http_tocco
            .get(url)
            .query(&[
                (
                    "_where",
                    &format!(
                        r#"rel{parent_name}.pk == {dir_key} and pk > {offset} and label != """#
                    )[..],
                ),
                ("_limit", &limit.to_string()),
                (
                    "_paths",
                    "label,relContent.data,relContent.update_timestamp",
                ),
                ("_sort", "pk"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    warn!("{ino:?}, offset: {offset}: failed to list files in directory: {e}");
                    libc::EIO
                }
            })?;

        let has_more = files.has_more();
        let entries: Vec<_> = files
            .resources
            .into_iter()
            .map(|file| {
                Attr::new(
                    file.key(),
                    FILE_TYPE,
                    file.version(),
                    file.name(),
                    file.size(),
                    Some(file.mtime()),
                )
            })
            .collect();

        Ok(Entries {
            next_offset: if has_more {
                entries.last().unwrap().ino()
            } else {
                Ino::from_raw(FILE_TYPE_END)
            },
            entries,
        })
    }

    fn list_dirs(
        &mut self,
        ino: Ino,
        offset: Ino,
        parent_name: &str,
        limit: u32,
    ) -> IoResult<Entries> {
        let dir_key = ino
            .dir_key()
            .or_else(|| ino.domain_key())
            .expect("parent not a folder or domain");
        let offset = offset.dir_key().unwrap_or(0);
        let url = format!("{}/nice2/rest/entities/2.0/Folder", self.base_url);
        let folders: Folders = self
            .http_tocco
            .get(url)
            .query(&[
                (
                    "_where",
                    &format!(
                        r#"rel{parent_name}.pk == {dir_key} and pk > {offset} and label != """#
                    )[..],
                ),
                ("_limit", &limit.to_string()),
                ("_paths", "label,update_timestamp"),
                ("_sort", "pk"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    warn!(
                        "{ino:?}, offset: {offset}: failed to list directories in directory: {e}"
                    );
                    libc::EIO
                }
            })?;

        let has_more = folders.has_more();
        let entries: Vec<_> = folders
            .folders
            .into_iter()
            .map(|dir| {
                Attr::new(
                    dir.key(),
                    FOLDER_TYPE,
                    dir.version(),
                    dir.name(),
                    0,
                    Some(dir.mtime()),
                )
            })
            .collect();

        Ok(Entries {
            next_offset: if has_more {
                entries.last().unwrap().ino()
            } else {
                Ino::from_raw(FOLDER_TYPE_END)
            },
            entries,
        })
    }

    fn list_entries(&mut self, ino: Ino, offset: Ino) -> IoResult<Entries> {
        let parent_name = ino.api_name().unwrap();

        if offset < Ino::from_raw(FOLDER_TYPE_END) {
            let entries = self.list_dirs(ino, offset, parent_name, 128)?;
            if !entries.entries.is_empty() {
                return Ok(entries);
            }
        }

        const { assert!(FOLDER_TYPE < FILE_TYPE) };

        if offset < Ino::from_raw(FILE_TYPE_END) {
            self.list_files(ino, offset, parent_name, 128)
        } else {
            Ok(Entries {
                next_offset: offset,
                entries: Vec::new(),
            })
        }
    }

    fn list_orphaned_files(&mut self, offset: Ino) -> IoResult<Entries> {
        let offset = offset.file_key().unwrap_or(0);
        let url = format!("{}/nice2/rest/entities/2.0/Resource", self.base_url);
        let files: Resources = self
            .http_tocco
            .get(url)
            .query(&[
                (
                    "_where",
                    &format!(
                        r#"not exists(relFolder) and not exists(relDomain) and pk > {offset}"#
                    )[..],
                ),
                ("_limit", "100"),
                (
                    "_paths",
                    "label,relContent.data,relContent.update_timestamp",
                ),
                ("_sort", "pk"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    warn!("offset: {offset}: failed to list orphaned files: {e}");
                    libc::EIO
                }
            })?;

        let has_more = files.has_more();
        let entries: Vec<_> = files
            .resources
            .into_iter()
            .map(|file| {
                Attr::new(
                    file.key(),
                    FILE_TYPE,
                    file.version(),
                    file.name_lost_and_found_file(),
                    file.size(),
                    Some(file.mtime()),
                )
            })
            .collect();

        Ok(Entries {
            next_offset: if has_more {
                entries.last().unwrap().ino()
            } else {
                Ino::from_raw(FILE_TYPE_END)
            },
            entries,
        })
    }

    fn list_orphaned_dirs(&mut self, offset: Ino) -> IoResult<Entries> {
        let offset = offset.dir_key().unwrap_or(0);
        let url = format!("{}/nice2/rest/entities/2.0/Folder", self.base_url);
        let folders: Folders = self
            .http_tocco
            .get(url)
            .query(&[
                (
                    "_where",
                    &format!(
                        r#"not exists(relFolder) and not exists(relDomain) and pk > {offset}"#
                    )[..],
                ),
                ("_limit", "100"),
                ("_paths", "label,update_timestamp"),
                ("_sort", "pk"),
            ])
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    warn!("offset: {offset}: failed to list orphaned directories: {e}");
                    libc::EIO
                }
            })?;

        let has_more = folders.has_more();
        let entries: Vec<_> = folders
            .folders
            .into_iter()
            .map(|dir| {
                Attr::new(
                    dir.key(),
                    FOLDER_TYPE,
                    dir.version(),
                    dir.name_lost_and_found_folder(),
                    0,
                    Some(dir.mtime()),
                )
            })
            .collect();

        Ok(Entries {
            next_offset: if has_more {
                entries.last().unwrap().ino()
            } else {
                Ino::from_raw(FOLDER_TYPE_END)
            },
            entries,
        })
    }

    fn list_orphaned_entries(&mut self, offset: Ino) -> IoResult<Entries> {
        if offset < Ino::from_raw(FOLDER_TYPE_END) {
            let entries = self.list_orphaned_dirs(offset)?;
            if !entries.entries.is_empty() {
                return Ok(entries);
            }
        }

        if offset < Ino::from_raw(FILE_TYPE_END) {
            self.list_orphaned_files(offset)
        } else {
            Ok(Entries {
                next_offset: offset,
                entries: Vec::new(),
            })
        }
    }

    fn domain_metadata(&self, ino: Ino) -> IoResult<SystemTime> {
        let key = ino.domain_key().expect("inode not a domain");

        let url = format!(
            "{}/nice2/rest/entities/2.0/Domain/{key}?_paths=label,update_timestamp",
            self.base_url
        );
        let domain: Domain = self
            .http_tocco
            .get(url)
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                debug!("{ino:?}: failed to fetch domain metadata: {e}");
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    EIO
                }
            })?;

        Ok(domain.mtime())
    }

    fn bytes_metadata(&self, ino: Ino) -> IoResult<FileMetadata> {
        let key = ino.file_key().expect("inode not a file");

        let url = format!(
            "{}/nice2/rest/entities/2.0/Resource/{key}/relContent?_paths=data,update_timestamp",
            self.base_url
        );
        let binary: Contents = self
            .http_tocco
            .get(url)
            .send()
            .and_then(|resp| resp.error_for_status())
            .and_then(|resp| resp.json())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else {
                    warn!("{ino:?}: failed to fetch file content metadata: {e}");
                    EIO
                }
            })?;
        debug_assert!(binary.contents.len() < 2);
        binary
            .contents
            .into_iter()
            .next()
            .map(|content| FileMetadata {
                size: content.size(),
                mtime: content.mtime(),
                url: content.into_url(),
            })
            .ok_or_else(|| {
                warn!("{ino:?}: file found but it has no content.");
                ENOENT
            })
    }

    fn get_bytes(&mut self, ino: Ino, offset: u64, size: u32) -> IoResult<bytes::Bytes> {
        self.clear_expired_cache_entries();
        let (client, file_size, url, cache_hit) = self
            .binary_url_cache
            .get(&ino)
            .map(|entry| {
                trace!("{ino:?}: URL resolution: cache hit.");
                Ok::<_, c_int>((
                    // This is an S3 storage URL: use client that doesn't send
                    // Tocco credentials.
                    &self.http_unauth,
                    entry.size,
                    entry.final_url.clone(),
                    true,
                ))
            })
            .unwrap_or_else(|| {
                let metadata = self.bytes_metadata(ino)?;
                Ok((&self.http_tocco, metadata.size, metadata.url, false))
            })?;

        if file_size <= offset {
            trace!("{ino:?}, offset: {offset}: read on exhausted stream (EOF).");
            return Ok(bytes::Bytes::new());
        }

        let resp = client
            .get(&url)
            .header(
                "Range",
                &format!("bytes={offset}-{}", offset + u64::from(size) - 1),
            )
            .send()
            .and_then(|resp| resp.error_for_status())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    debug!("{ino:?}, file has vanished retrieving content.");
                    ENOENT
                } else {
                    debug!("{ino:?}, offset: {offset}: failed to fetch file content: {e}");
                    EIO
                }
            })?;

        if !cache_hit {
            self.binary_url_cache.insert(
                ino,
                BinaryCacheEntry {
                    cache_time: Instant::now(),
                    size: file_size,
                    // Final URL, after redirects.
                    final_url: resp.url().to_string(),
                },
            );
        }

        let status = resp.status();
        match status {
            StatusCode::PARTIAL_CONTENT => { /* range request supported */ }
            StatusCode::OK if offset == 0 => {
                // Range request not supported but we need the beginning of
                // the file anyway. We can just truncate the body to the right
                // size as needed.
                debug!(
                    "{ino:?}, unexpected OK status code for range request: range request not supported."
                );
            }
            StatusCode::OK => {
                // Some files like sitemaps aren't stored on S3 and don't support range
                // requests. Because range requests aren't supported and the offset
                // requested is non-zero, we would need to fetch the whole file and
                // then find the range ourselves.
                //
                // This is so rare that this hasn't been implemented.
                warn!("{ino:?}, unexpected OK status code for range request with non-zero offset.");
                return Err(EIO);
            }
            status => {
                error!("{ino:?}, unexpected success status code: {status}");
            }
        }

        let mut bytes = resp.bytes().map_err(|e| {
            debug!("{ino:?}, offset: {offset}: failed to fetch file content: {e}");
            EIO
        })?;

        if bytes.len() > size as usize {
            // This should only ever happen when range requests aren't
            // supported.
            debug_assert!(
                status == StatusCode::OK,
                "unexpected status in combination with too large body: {status}"
            );

            debug!("{ino:?}, response too large, truncating ....");
            bytes.truncate(size as usize);
        }
        Ok(bytes)
    }

    fn delete_dir(&mut self, ino: Ino) -> IoResult<()> {
        if !self.is_dir_empty(ino)? {
            return Err(ENOTEMPTY);
        }
        self.generic_delete(ino)
    }

    fn delete_file(&mut self, ino: Ino) -> IoResult<()> {
        self.generic_delete(ino)
    }

    fn generic_delete(&mut self, ino: Ino) -> IoResult<()> {
        let key = ino.key().unwrap();
        let api_name = ino.api_name().unwrap();

        let url = format!("{}/nice2/rest/entities/2.0/{api_name}/{key}", self.base_url);
        self.http_tocco
            .delete(&url)
            .send()
            .and_then(|resp| resp.error_for_status())
            .map_err(|e| {
                if e.status() == Some(StatusCode::NOT_FOUND) {
                    ENOENT
                } else if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    debug!("{api_name} {key}: removal failed: {e}.");
                    EIO
                }
            })?;

        let head = self.http_tocco.head(url).send().map_err(|e| {
            debug!("{api_name} {key}: verifying removal failed: {e}.");
            EIO
        })?;

        // Status code is wrong on delete. Checking if file exists after
        // removal instead.
        // TODO fix API
        match head.status() {
            StatusCode::NOT_FOUND => Ok(()),
            e @ StatusCode::INTERNAL_SERVER_ERROR => {
                // TODO fix API (happens for folder and resource)
                debug!(
                    "{api_name} {key}: interpreting {} - {:?} as sign that file removal succeeded",
                    e.as_u16(),
                    e.canonical_reason().unwrap_or("<unknown>"),
                );
                Ok(())
            }
            StatusCode::OK => {
                warn!("{api_name} {key}: still exists after removal");
                Err(EACCES)
            }
            e => {
                warn!("{api_name} {key}: failed verify removal: {e}");
                Err(EIO)
            }
        }?;

        // TODO too expensive
        self.lookup_cache.remove_by_inode(ino);

        Ok(())
    }

    fn rename_internal(
        &mut self,
        old_parent: Ino,
        old_name: &FileName,
        new_parent: Ino,
        new_name: &FileName,
    ) -> IoResult<()> {
        debug_assert!(!old_parent.is_file());
        debug_assert!(!new_parent.is_file());

        if (old_parent.is_root() && new_parent.is_root())
            || ((old_parent.is_dir() || old_parent.is_domain())
                && (new_parent.is_dir() || new_parent.is_domain()))
        {
            let old_attr = self.lookup_internal(old_parent, old_name)?;
            match self.lookup_internal(new_parent, new_name) {
                Ok(attr) if attr.ino().is_dir() || attr.ino().is_domain() => return Err(EISDIR),
                Ok(attr) => {
                    debug!(
                        "Target found during rename, removing {:?}, name: {new_name:?} first.",
                        attr.ino()
                    );
                    self.delete_file(attr.ino())?;
                }
                Err(e) if e == ENOENT => (),
                Err(e) => return Err(e),
            }

            if old_name.tocco_file_name() != new_name.tocco_file_name() {
                self.simple_rename(old_parent, &old_attr, old_name, new_name)?;
            }
            if old_parent != new_parent {
                self.r#move(&old_attr, old_parent, new_parent, new_name)?;
            }
            Ok(())
        } else {
            debug!(
                "Attempted rename between incompatible parents: {old_parent:?} incompatible with {new_parent:?}."
            );
            Err(EPERM)
        }
    }

    fn r#move(
        &mut self,
        file: &Attr,
        old_parent: Ino,
        new_parent: Ino,
        name: &FileName,
    ) -> IoResult<()> {
        #[derive(Serialize)]
        struct Move {
            #[serde(rename = "selectedEntityKeys")]
            source: [String; 1],

            #[serde(rename = "targetEntityKey")]
            target: String,
        }

        let url = format!("{}/nice2/rest/documents/move", self.base_url);
        let data = Move {
            source: [format!(
                "{}/{}",
                file.ino().api_name().unwrap(),
                file.ino().key().unwrap()
            )],
            target: format!(
                "{}/{}",
                new_parent.api_name().unwrap(),
                new_parent.key().unwrap()
            ),
        };

        self.http_tocco
            .post(url)
            .json(&data)
            .send()
            .and_then(|r| r.error_for_status())
            .map_err(|e| {
                debug!(
                    "Move of {:?} from {old_parent:?} to {new_parent:?} failed: {e}.",
                    file.ino()
                );
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        self.lookup_cache.remove(old_parent, name);

        Ok(())
    }

    /// Rename without moving to a different directory
    fn simple_rename(
        &mut self,
        parent: Ino,
        attr: &Attr,
        old_name: &FileName,
        new_name: &FileName,
    ) -> IoResult<()> {
        #[derive(Serialize)]
        struct Request<'a> {
            paths: Paths<'a>,
            version: u64,
        }

        #[derive(Serialize)]
        struct Paths<'a> {
            label: &'a str,
        }

        let key = attr.ino().key().expect("entry without key");
        let url = format!(
            "{}/nice2/rest/entities/2.0/{}/{key}",
            self.base_url,
            attr.ino().api_name().unwrap()
        );

        let data = Request {
            paths: Paths {
                label: new_name.tocco_file_name(),
            },
            version: attr.version(),
        };

        self.http_tocco
            .patch(url)
            .json(&data)
            .send()
            .and_then(|r| r.error_for_status())
            .map_err(|e| {
                debug!("Rename from {:?}, name {old_name:?} to <unchanged>, name {new_name:?} failed: {e}.", attr.ino());
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        self.lookup_cache.remove(parent, old_name);

        Ok(())
    }

    fn is_dir_empty(&mut self, ino: Ino) -> IoResult<bool> {
        let api_name = ino.api_name().unwrap();
        let dirs = self.list_dirs(ino, Ino::from_raw(0), api_name, 1)?;
        debug_assert!(dirs.entries.len() < 2);
        if !dirs.entries.is_empty() {
            return Ok(false);
        }

        let files = self.list_files(ino, Ino::from_raw(0), api_name, 1)?;
        debug_assert!(files.entries.len() < 2);
        if files.entries.is_empty() {
            Ok(true)
        } else {
            Ok(false)
        }
    }

    fn unlink_internal(
        &mut self,
        _req: &Request<'_>,
        parent: Ino,
        name: &FileName,
    ) -> IoResult<()> {
        let attr = self.lookup_internal(parent, name)?;
        let ino = attr.ino();
        if ino.is_file() {
            self.delete_file(ino)
        } else if ino.is_dir() || ino.is_domain() {
            Err(EISDIR)
        } else if ino.is_reserved() {
            Err(EPERM)
        } else {
            unreachable!()
        }
    }

    fn rmdir_internal(&mut self, _req: &Request<'_>, parent: Ino, name: &FileName) -> IoResult<()> {
        let attr = self.lookup_internal(parent, name)?;
        let ino = attr.ino();
        if ino.is_file() {
            Err(ENOTDIR)
        } else if ino.is_dir() || ino.is_domain() {
            self.delete_dir(ino)
        } else if ino.is_reserved() {
            Err(EPERM)
        } else {
            unreachable!()
        }
    }

    fn mkdir_internal(&mut self, parent: Ino, name: &FileName) -> IoResult<Attr> {
        debug_assert!(!parent.is_file());
        if parent.is_root() {
            self.mkdir_domain(name)
        } else if parent.is_dir() || parent.is_domain() {
            self.mkdir_folder(parent, name)
        } else {
            debug!("Mkdir on unsupported parent {parent:?}, name: {name:?}.");
            Err(EPERM)
        }
    }

    fn mkdir_domain(&mut self, name: &FileName) -> IoResult<Attr> {
        #[derive(Serialize)]
        struct DomainCreation<'a> {
            paths: Paths<'a>,
        }

        #[derive(Serialize)]
        struct Paths<'a> {
            label: &'a str,

            #[serde(rename = "relCorrespondence_language")]
            rel_lang: UniqueIdField<'a>,

            #[serde(rename = "relDomain_type")]
            rel_domain_type: UniqueIdField<'a>,
        }

        #[derive(Serialize)]
        struct UniqueIdField<'a> {
            unique_id: &'a str,
        }

        let data = DomainCreation {
            paths: Paths {
                label: name.tocco_file_name(),
                rel_lang: UniqueIdField {
                    unique_id: &self.create_domain_lang,
                },
                rel_domain_type: UniqueIdField {
                    unique_id: self.create_domain_type.unique_id(),
                },
            },
        };

        let url = format!("{}/nice2/rest/entities/2.0/Domain", self.base_url);
        let resp = self
            .http_tocco
            .post(url)
            .json(&data)
            .send()
            .and_then(|r| r.error_for_status())
            .map_err(|e| {
                debug!("Creating domain {name:?} failed: {e}");
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        let url = resp
            .headers()
            .get("Location")
            .expect("location of new folder missing")
            .to_str()
            .expect("non-UTF8 URL");
        let folder: Domain = self
            .http_tocco
            .get(url)
            .query(&[("_paths", "label,update_timestamp")])
            .send()
            .and_then(|r| r.error_for_status())
            .and_then(|r| r.json())
            .map_err(|e| {
                debug!("name: {name:?}: failed to fetch domain metadata after creation: {e}.");
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        Ok(Attr::new(
            folder.key(),
            FOLDER_TYPE,
            folder.version(),
            folder.name(),
            0,
            Some(folder.mtime()),
        ))
    }

    fn mkdir_folder(&mut self, parent: Ino, name: &FileName) -> IoResult<Attr> {
        #[derive(Serialize)]
        struct FolderCreation<'a> {
            paths: Paths<'a>,
        }

        #[derive(Serialize)]
        struct Paths<'a> {
            label: &'a str,

            #[serde(rename = "relDomain")]
            rel_domain: Option<FolderRelation>,

            #[serde(rename = "relFolder")]
            rel_folder: Option<FolderRelation>,
        }

        #[derive(Serialize)]
        struct FolderRelation {
            key: String,
        }

        let domain_key = parent.domain_key().map(|key| FolderRelation {
            key: key.to_string(),
        });
        let folder_key = parent.dir_key().map(|key| FolderRelation {
            key: key.to_string(),
        });
        let data = FolderCreation {
            paths: Paths {
                label: name.tocco_file_name(),
                rel_folder: folder_key,
                rel_domain: domain_key,
            },
        };

        let url = format!("{}/nice2/rest/entities/2.0/Folder", self.base_url);
        let resp = self
            .http_tocco
            .post(url)
            .json(&data)
            .send()
            .and_then(|r| r.error_for_status())
            .map_err(|e| {
                debug!("Creating directory {name:?} in {parent:?} failed: {e}");
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        let url = resp
            .headers()
            .get("Location")
            .expect("location of new folder missing")
            .to_str()
            .expect("non-UTF URL");
        let folder: Folder = self
            .http_tocco
            .get(url)
            .query(&[("_paths", "label,update_timestamp")])
            .send()
            .and_then(|r| r.error_for_status())
            .and_then(|r| r.json())
            .map_err(|e| {
                debug!("{parent:?}, name: {name:?}: failed to fetch directory metadata after creation: {e}.");
                if e.status() == Some(StatusCode::UNAUTHORIZED) {
                    EACCES
                } else {
                    EIO
                }
            })?;

        Ok(Attr::new(
            folder.key(),
            FOLDER_TYPE,
            folder.version(),
            folder.name(),
            0,
            Some(folder.mtime()),
        ))
    }

    fn lookup_internal(&mut self, parent: Ino, name: &FileName) -> IoResult<Attr> {
        if parent.is_root() && name.fs_file_name() == LOST_AND_FOUND_NAME {
            return Ok(Attr::new_reserved(
                LOST_AND_FOUND_ID,
                FileType::Directory,
                FileName::from_fs_name(Ino::from_raw(ROOT_ID), LOST_AND_FOUND_NAME)
                    .expect("name of lost+found is invalid"),
                0,
                None,
            ));
        }

        self.clear_expired_cache_entries();
        let cached_entry = self.lookup_cache.get(parent, name);
        if cached_entry.is_some() {
            trace!("{parent:?} LOOKUP name: {name:?}: cache hit");
        }

        match cached_entry {
            Some(attr) => Ok(attr.inner.clone()),
            None => {
                if parent.is_root() {
                    self.lookup_in_root(name)
                } else {
                    self.lookup_in_folder(parent, name)
                }
            }
        }
    }

    fn getattr_internal(&mut self, _req: &Request, ino: Ino, _fh: Option<u64>) -> IoResult<Attr> {
        if ino.is_root() {
            Ok(Attr::new_reserved(
                ROOT_ID,
                FileType::Directory,
                // TODO
                FileName::from_fs_name(Ino::from_raw(0), "<invalid>").unwrap(),
                0,
                None,
            ))
        } else if ino.raw() == LOST_AND_FOUND_ID {
            Ok(Attr::new_reserved(
                LOST_AND_FOUND_ID,
                FileType::Directory,
                // TODO
                FileName::from_fs_name(Ino::from_raw(0), "<invalid>").unwrap(),
                0,
                None,
            ))
        } else if let Some(key) = ino.file_key() {
            let metadata = self.bytes_metadata(ino)?;
            Ok(Attr::new(
                key,
                FILE_TYPE,
                0, // TODO
                // TODO
                FileName::from_fs_name(Ino::from_raw(0), "<invalid>").unwrap(),
                metadata.size,
                Some(metadata.mtime),
            ))
        } else if let Some(key) = ino.dir_key() {
            // TODO resolve folder rather than returning a placeholder
            Ok(Attr::new(
                key,
                FOLDER_TYPE,
                0, // TODO
                // TODO
                FileName::from_fs_name(Ino::from_raw(0), "<invalid>").unwrap(),
                0,
                None,
            ))
        } else if let Some(key) = ino.domain_key() {
            let mtime = self.domain_metadata(ino)?;
            Ok(Attr::new(
                key,
                DOMAIN_TYPE,
                0, // TODO
                // TODO
                FileName::from_fs_name(Ino::from_raw(0), "<invalid>").unwrap(),
                0,
                Some(mtime),
            ))
        } else {
            unreachable!();
        }
    }

    fn getxattr_admin_url(&self, ino: Ino) -> Option<Vec<u8>> {
        ino.admin_url(&self.base_url).map(|url| url.into())
    }

    fn getxattr_entity_api_url(&self, ino: Ino) -> Option<Vec<u8>> {
        ino.entity_api_url(&self.base_url).map(|url| url.into())
    }

    fn getxattr_internal(&self, _req: &Request, ino: Ino, name: &str) -> Option<Vec<u8>> {
        match name {
            "user.admin_url" => self.getxattr_admin_url(ino),
            "user.entity_api_url" => self.getxattr_entity_api_url(ino),
            _ => None,
        }
    }

    fn listxattr_internal(&self, _req: &Request<'_>, ino: Ino) -> Vec<&'static str> {
        let mut result = Vec::new();
        if self.getxattr_admin_url(ino).is_some() {
            result.push("user.admin_url");
        }
        if self.getxattr_entity_api_url(ino).is_some() {
            result.push("user.entity_api_url");
        }
        result
    }

    fn readdir_internal(
        &mut self,
        _req: &Request,
        ino: Ino,
        _fh: u64,
        offset: Ino,
        reply: &mut ReplyDirectory,
    ) -> IoResult<()> {
        let mut entries = if ino.is_root() {
            if offset.raw() == DOMAIN_TYPE_END {
                return Ok(());
            }

            if offset.raw() < DOT_ID && reply.add(ROOT_ID, DOT_ID as i64, FileType::Directory, ".")
            {
                return Ok(());
            }
            const { assert!(DOT_ID < DOT_DOT_ID) };
            if offset.raw() < DOT_DOT_ID
                && reply.add(ROOT_ID, DOT_DOT_ID as i64, FileType::Directory, "..")
            {
                return Ok(());
            }
            const { assert!(DOT_DOT_ID < LOST_AND_FOUND_ID) };
            if offset.raw() < LOST_AND_FOUND_ID
                && reply.add(
                    ROOT_ID,
                    LOST_AND_FOUND_ID as i64,
                    FileType::Directory,
                    LOST_AND_FOUND_NAME,
                )
            {
                return Ok(());
            }

            self.domains(offset)?
        } else if ino.raw() == LOST_AND_FOUND_ID {
            self.list_orphaned_entries(offset)?
        } else {
            self.list_entries(ino, offset)?
        };

        debug_assert!(entries.entries.is_sorted_by_key(|entry| entry.ino()));

        for attr in &entries.entries {
            self.lookup_cache
                .insert(ino, attr.name(), CachedAttr::new(attr));
        }

        let last = entries.entries.pop();
        for attr in entries.entries {
            let raw_ino = attr.ino().raw();
            if offset < attr.ino()
                && reply.add(
                    raw_ino,
                    raw_ino as i64,
                    attr.ino().fuse_type(),
                    attr.name().fs_file_name(),
                )
            {
                return Ok(());
            }
        }

        if let Some(attr) = last
            && reply.add(
                attr.ino().raw(),
                entries.next_offset.raw() as i64,
                attr.ino().fuse_type(),
                attr.name().fs_file_name(),
            )
        {
            return Ok(());
        }

        Ok(())
    }
}

impl Filesystem for Fs {
    fn init(&mut self, _req: &Request<'_>, config: &mut KernelConfig) -> IoResult<()> {
        let wanted_value = 512 * 1024;
        match config.set_max_readahead(wanted_value) {
            Ok(_) => (),
            Err(max_permissible) => {
                config.set_max_readahead(max_permissible).unwrap();
            }
        }
        Ok(())
    }

    fn lookup(&mut self, req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
        let parent = Ino::from_raw(parent);
        let Some(name) = FileName::from_fs_name(parent, name) else {
            reply.error(ENOENT);
            return;
        };
        debug!(
            "FUSE({}) lookup() parent: {parent:?}, name: {name:?}.",
            req.unique()
        );
        match self.lookup_internal(parent, &name) {
            Ok(file) => reply.entry(&self.cache_timeout, file.file_attr(), 0),
            Err(e) => reply.error(e),
        }
    }

    fn getattr(&mut self, req: &Request, ino: u64, fh: Option<u64>, reply: ReplyAttr) {
        let ino = Ino::from_raw(ino);
        debug!("FUSE({}) getattr() {ino:?}.", req.unique());
        match self.getattr_internal(req, ino, fh) {
            Ok(attrs) => reply.attr(&self.cache_timeout, attrs.file_attr()),
            Err(e) => reply.error(e),
        }
    }

    fn getxattr(
        &mut self,
        req: &Request<'_>,
        ino: u64,
        name: &OsStr,
        size: u32,
        reply: ReplyXattr,
    ) {
        let ino = Ino::from_raw(ino);
        debug!(
            "FUSE({}) getxattr() {ino:?}, name: {name:?}, size: {size:?}.",
            req.unique()
        );

        if let Ok(name) = <&str>::try_from(name) {
            match self.getxattr_internal(req, ino, name) {
                Some(value) => {
                    if size == 0 {
                        reply.size(value.len() as u32);
                    } else if size < value.len() as u32 {
                        reply.error(libc::ERANGE);
                    } else {
                        reply.data(&value);
                    }
                }
                None => reply.error(libc::ENODATA),
            }
        } else {
            reply.error(libc::ENODATA);
        }
    }

    fn listxattr(&mut self, req: &Request<'_>, ino: u64, size: u32, reply: ReplyXattr) {
        let ino = Ino::from_raw(ino);
        debug!(
            "FUSE({}) listxattr() {ino:?}, size: {size:?}.",
            req.unique()
        );
        let names = self.listxattr_internal(req, ino);
        let len: usize = names.iter().map(|name| name.len() + 1).sum();
        if size == 0 {
            reply.size(len as u32);
        } else if size < len as u32 {
            reply.error(libc::ERANGE);
        } else {
            let mut data = Vec::with_capacity(len);
            for name in names {
                data.extend(name.as_bytes());
                data.push(b'\0');
            }
            reply.data(&data);
        }
    }

    fn read(
        &mut self,
        req: &Request,
        ino: u64,
        _fh: u64,
        offset: i64,
        size: u32,
        _flags: i32,
        _lock: Option<u64>,
        reply: ReplyData,
    ) {
        let ino = Ino::from_raw(ino);
        debug!(
            "FUSE({}) read() {ino:?}, offset: {offset}, size: {size}.",
            req.unique()
        );
        debug_assert!(offset >= 0);
        match self.get_bytes(ino, offset as u64, size) {
            Ok(data) => reply.data(&data),
            Err(e) => reply.error(e),
        }
    }

    fn readdir(
        &mut self,
        req: &Request,
        ino: u64,
        fh: u64,
        offset: i64,
        mut reply: ReplyDirectory,
    ) {
        debug_assert!(offset >= 0);
        let offset = Ino::from_raw(offset as u64);
        let ino = Ino::from_raw(ino);
        debug!(
            "FUSE({}) readdir() {ino:?}, offset: {offset:?}.",
            req.unique()
        );

        match self.readdir_internal(req, ino, fh, offset, &mut reply) {
            Ok(()) => reply.ok(),
            Err(e) => reply.error(e),
        }
    }

    fn rename(
        &mut self,
        req: &Request<'_>,
        parent: u64,
        name: &OsStr,
        new_parent: u64,
        new_name: &OsStr,
        _flags: u32, // TODO
        reply: ReplyEmpty,
    ) {
        let parent = Ino::from_raw(parent);
        let new_parent = Ino::from_raw(new_parent);
        let Some(name) = FileName::from_fs_name(parent, name) else {
            reply.error(ENOENT);
            return;
        };
        let Some(new_name) = FileName::from_fs_name(new_parent, new_name) else {
            reply.error(ENOENT);
            return;
        };
        debug!(
            "FUSE({}) rename() from parent: {parent:?}, name: {name:?} to new parent: {new_parent:?}, new name: {new_name:?}.",
            req.unique()
        );

        match self.rename_internal(parent, &name, new_parent, &new_name) {
            Ok(()) => reply.ok(),
            Err(e) => reply.error(e),
        }
    }

    fn unlink(&mut self, req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
        let parent = Ino::from_raw(parent);
        let Some(name) = FileName::from_fs_name(parent, name) else {
            reply.error(ENOENT);
            return;
        };
        debug!(
            "FUSE({}) unlink() parent: {parent:?}, name: {name:?}.",
            req.unique()
        );
        match self.unlink_internal(req, parent, &name) {
            Ok(()) => reply.ok(),
            Err(e) => reply.error(e),
        }
    }

    fn rmdir(&mut self, req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
        let parent = Ino::from_raw(parent);
        let Some(name) = FileName::from_fs_name(parent, name) else {
            reply.error(ENOENT);
            return;
        };
        debug!(
            "FUSE({}) rmdir() parent: {parent:?}, name: {name:?}.",
            req.unique()
        );
        match self.rmdir_internal(req, parent, &name) {
            Ok(()) => reply.ok(),
            Err(e) => reply.error(e),
        }
    }

    fn mkdir(
        &mut self,
        req: &Request<'_>,
        parent: u64,
        name: &OsStr,
        _mode: u32,
        _umask: u32,
        reply: ReplyEntry,
    ) {
        let parent = Ino::from_raw(parent);
        let Some(name) = FileName::from_fs_name(parent, name) else {
            reply.error(EINVAL);
            return;
        };
        debug!(
            "FUSE({}) mkdir() parent: {parent:?}, name: {name:?}.",
            req.unique()
        );
        match self.mkdir_internal(parent, &name) {
            Ok(attr) => reply.entry(&self.cache_timeout, attr.file_attr(), 0),
            Err(e) => reply.error(e),
        }
    }
}
