//! Rendering of [`super::PropList`]s to text.
//!
//! To use this module, call [`super::PropList::display_indented`] or
//! [`super::PropList::display_unindented`].
//!
//! ## Rust rewrite notes
//!
//! This should work well enough for writing Window Maker state to disk, but it
//! is not a perfect reimplementation of the behavior of classic WUtils PropList
//! serialization. In particular, the WUtils hashtable has a different key
//! iteration order than Rust's `HashMap`, so this library may write dictionary
//! entries to disk in a different order.

use super::{parser, Node};

use std::{
    fmt::{self, Write},
    ops::Deref,
};

/// Maximum width for a chunk of text being rendered with `Inline::Soft` before
/// falling back to `Inline::No`.
const SOFT_LINEBREAK_WIDTH: usize = 77;

/// Describes the line-breaking strategy for rendering a `Node`.
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum Inline {
    /// Do not attempt to render with no linebreaks.
    No,
    /// Attempt to render with no linebreaks, but fall back to `Inline::No` if
    /// the result is too wide.
    Soft,
    /// Render with no linebreaks, regardless of output width.
    Hard,
}

/// Bundles a reference to a `Node` with rendering instructions.
pub(crate) struct Display<N: Deref<Target = Node>> {
    /// Line-breaking strategy.
    pub(crate) inline: Inline,
    /// Whether to render `node` as if it is the first item on a new line.
    pub(crate) clear_left: bool,
    /// Indentation level to render `node` at.
    pub(crate) indentation: u32,
    /// Amount by which `indentation` should increase when rendering children of
    /// `node`.
    pub(crate) increment: u32,
    /// The `Node` to render.
    pub(crate) node: N,
}

impl<N: Deref<Target = Node>> Display<N> {
    /// Writes whitespace to `f` for the current level of indentation.
    fn write_indent(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for _ in 0..self.indentation {
            f.write_char(' ')?;
        }
        Ok(())
    }
}

/// Writes `s` to `f`, backslash-escaping special characters as appropriate for
/// a quoted property list string.
fn write_escaped_string(s: &str, f: &mut fmt::Formatter) -> fmt::Result {
    for c in s.chars() {
        match c {
            '\x07' => f.write_str(r#"\a"#)?,
            '\x08' => f.write_str(r#"\b"#)?,
            '\t' => f.write_str(r#"\t"#)?,
            '\n' => f.write_str(r#"\n"#)?,
            '\x0B' => f.write_str(r#"\v"#)?,
            '\x0D' => f.write_str(r#"\f"#)?,
            '\\' => f.write_str(r#"\\"#)?,
            '"' => f.write_str(r#"\\""#)?,
            c => f.write_char(c)?,
        }
    }
    Ok(())
}

impl<N: Deref<Target = Node>> fmt::Display for Display<N> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        if self.clear_left {
            writeln!(f, "")?;
            self.write_indent(f)?;
        }
        match &*self.node {
            Node::Array(items) if self.inline != Inline::No => {
                // Try to fit everything in one line.
                let mut buf = String::new();
                buf.push('(');
                let mut items = items.iter();
                if let Some(first) = items.next() {
                    write!(
                        &mut buf,
                        "{}",
                        Display {
                            inline: self.inline,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: first.0.borrow(),
                        }
                    )?;
                }
                for next in items {
                    write!(
                        &mut buf,
                        ", {}",
                        Display {
                            inline: self.inline,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: next.0.borrow(),
                        }
                    )?;
                }
                buf.push(')');
                if self.inline == Inline::Soft && buf.chars().count() > SOFT_LINEBREAK_WIDTH {
                    write!(
                        f,
                        "{}",
                        Display {
                            inline: Inline::No,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: &*self.node,
                        }
                    )?;
                } else {
                    f.write_str(&buf)?;
                }
            }
            Node::Array(items) if items.is_empty() => f.write_str("()")?,
            Node::Array(items) => {
                f.write_char('(')?;
                let mut items = items.iter();
                if let Some(first) = items.next() {
                    write!(
                        f,
                        "{}",
                        Display {
                            inline: Inline::Soft,
                            clear_left: true,
                            indentation: self.indentation + self.increment,
                            increment: self.increment,
                            node: first.0.borrow(),
                        }
                    )?;
                }
                for next in items {
                    f.write_char(',')?;
                    write!(
                        f,
                        "{}",
                        Display {
                            inline: Inline::Soft,
                            clear_left: true,
                            indentation: self.indentation + self.increment,
                            increment: self.increment,
                            node: next.0.borrow(),
                        }
                    )?;
                }
                writeln!(f, "")?;
                self.write_indent(f)?;
                f.write_char(')')?;
            }
            Node::Dictionary(items) if items.is_empty() => write!(f, "{{}}")?,
            Node::Dictionary(items) if self.inline != Inline::No => {
                // Try to fit everything in one line.
                let mut buf = String::new();
                buf.push('{');
                for (k, v) in items {
                    write!(
                        &mut buf,
                        " {} = {};",
                        Display {
                            inline: self.inline,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: k.0.borrow(),
                        },
                        Display {
                            inline: self.inline,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: v.0.borrow(),
                        }
                    )?;
                }
                write!(&mut buf, " }}")?;
                if self.inline == Inline::Soft && buf.chars().count() > SOFT_LINEBREAK_WIDTH {
                    write!(
                        f,
                        "{}",
                        Display {
                            inline: Inline::No,
                            clear_left: false,
                            indentation: self.indentation,
                            increment: self.increment,
                            node: &*self.node,
                        }
                    )?;
                } else {
                    f.write_str(&buf)?;
                }
            }
            Node::Dictionary(items) => {
                write!(f, "{{")?;
                for (k, v) in items {
                    write!(
                        f,
                        "{} = {};",
                        Display {
                            inline: Inline::Soft,
                            clear_left: true,
                            indentation: self.indentation + self.increment,
                            increment: self.increment,
                            node: k.0.borrow(),
                        },
                        Display {
                            inline: Inline::Soft,
                            clear_left: false,
                            indentation: self.indentation + self.increment,
                            increment: self.increment,
                            node: v.0.borrow(),
                        }
                    )?;
                }
                writeln!(f, "")?;
                self.write_indent(f)?;
                f.write_char('}')?;
            }
            Node::String(s) => {
                let mut unquoted = !s.is_empty();
                for c in s.as_bytes() {
                    if !parser::is_unquoted_char(*c as char) {
                        unquoted = false;
                        break;
                    }
                }
                if unquoted {
                    write!(f, "{}", s.to_string_lossy())?;
                } else {
                    f.write_char('"')?;
                    write_escaped_string(&s.to_string_lossy(), f)?;
                    f.write_char('"')?;
                }
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod test {
    use crate::prop_list::{Node, PropList};

    use std::ffi::CString;

    fn pl_array(xs: Vec<PropList>) -> PropList {
        PropList::new(Node::Array(xs))
    }

    fn pl_string(s: &str) -> PropList {
        PropList::new(Node::String(CString::new(s.as_bytes()).unwrap()))
    }

    #[test]
    fn write_empty_string() {
        let serialized = format!("{:?}", pl_string(""));
        assert_eq!(serialized, r#""""#);
    }

    #[test]
    fn write_unquoted_string() {
        let serialized = format!("{:?}", pl_string("hello"));
        assert_eq!(serialized, r#"hello"#);
    }

    #[test]
    fn write_quoted_string() {
        let serialized = format!("{:?}", pl_string("hello, world!"));
        assert_eq!(serialized, r#""hello, world!""#);
    }

    #[test]
    fn write_flat_array() {
        let serialized = format!(
            "{:?}",
            pl_array(vec![pl_string("hello"), pl_string("world")])
        );
        assert_eq!(serialized, r#"(hello, world)"#);
    }

    #[test]
    fn write_empty_array() {
        let serialized = format!("{:?}", pl_array(vec![]));
        assert_eq!(serialized, r#"()"#);
    }

    #[test]
    fn write_nested_array() {
        let serialized = format!(
            "{:?}",
            pl_array(vec![
                pl_array(vec![pl_string("hello"), pl_string("world")]),
                pl_string("baz"),
                pl_string("quux plugh")
            ])
        );
        assert_eq!(serialized, r#"((hello, world), baz, "quux plugh")"#);
    }

    #[test]
    fn write_long_nested_array() {
        let serialized = format!(
            "{:?}",
            pl_array(vec![
                pl_array(vec![pl_string("hello"), pl_string("world")]),
                pl_string("baz"),
                pl_string("quux plugh"),
                pl_string("etc"),
                pl_array(vec![
                    pl_string("lots"),
                    pl_string("of"),
                    pl_string("words"),
                    pl_string("go"),
                    pl_array(vec![
                        pl_string("here"),
                        pl_string("and"),
                        pl_string("stuff"),
                        pl_string("blah"),
                        pl_string("blah"),
                        pl_string("blah"),
                    ])
                ])
            ])
        );
        assert_eq!(
            serialized,
            r#"(
  (hello, world),
  baz,
  "quux plugh",
  etc,
  (lots, of, words, go, (here, and, stuff, blah, blah, blah))
)"#
        );
    }

    #[test]
    fn roundtrip_wm_state() {
        let original = r#"{
  Dock = {
    AutoRaiseLower = No;
    Applications = (
      {
        Forced = No;
        Name = Logo.WMDock;
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,0";
        Lock = Yes;
        Command = "/usr/bin/WPrefs";
      },
      {
        Forced = No;
        Name = wmweather.wmweather;
        DropCommand = "wmweather -s KBOS %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,4";
        Lock = Yes;
        PasteCommand = "wmweather -s KBOS %s";
        Command = "wmweather -s KBOS";
      },
      {
        Forced = No;
        Name = wmclock.WMClock;
        DropCommand = "wmclock %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,3";
        Lock = Yes;
        PasteCommand = "wmclock %s";
        Command = wmclock;
      },
      {
        Forced = No;
        Name = emacs.Emacs;
        DropCommand = "emacsclient -c %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,2";
        Lock = Yes;
        PasteCommand = "emacsclient -c %s";
        Command = "emacsclient -";
      },
      {
        Forced = No;
        Name = wmfire.wmfire;
        DropCommand = "wmfire -m %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,5";
        Lock = Yes;
        PasteCommand = "wmfire -m %s";
        Command = "wmfire -m";
      },
      {
        Forced = No;
        Name = wmnet.WMNET;
        DropCommand = "wmnet -W enp0s31f6 %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,7";
        Lock = Yes;
        PasteCommand = "wmnet -W enp0s31f6 %s";
        Command = "wmnet -W enp0s31f6";
      },
      {
        Forced = No;
        Name = wmtemp.DockApp;
        DropCommand = "wmtemp -f %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,6";
        Lock = Yes;
        PasteCommand = "wmtemp -f %s";
        Command = "wmtemp -f";
      },
      {
        Forced = No;
        Name = firefox.Firefox;
        DropCommand = "firefox %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,1";
        Lock = Yes;
        PasteCommand = "firefox %s";
        Command = firefox;
      },
      {
        Forced = No;
        Name = "org\\.gnome\\.Weather.org\\.gnome\\.Weather";
        DropCommand = "org.gnome.Weather %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,8";
        Lock = Yes;
        PasteCommand = "org.gnome.Weather %s";
        Command = "/usr/bin/gnome-weather";
      },
      {
        Forced = No;
        Name = Zotero.Zotero;
        DropCommand = "/home/stu/bin/zotero %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,9";
        Lock = Yes;
        PasteCommand = "/home/stu/bin/zotero %s";
        Command = "/home/stu/bin/zotero";
      }
    );
    Lowered = Yes;
    Position = "2496,0";
    Applications1440 = (
      {
        Forced = No;
        Name = Logo.WMDock;
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,0";
        Lock = Yes;
        Command = "/usr/bin/WPrefs";
      },
      {
        Forced = No;
        Name = wmweather.wmweather;
        DropCommand = "wmweather -s KBOS %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,4";
        Lock = Yes;
        PasteCommand = "wmweather -s KBOS %s";
        Command = "wmweather -s KBOS";
      },
      {
        Forced = No;
        Name = wmclock.WMClock;
        DropCommand = "wmclock %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,3";
        Lock = Yes;
        PasteCommand = "wmclock %s";
        Command = wmclock;
      },
      {
        Forced = No;
        Name = emacs.Emacs;
        DropCommand = "emacsclient -c %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,2";
        Lock = Yes;
        PasteCommand = "emacsclient -c %s";
        Command = "emacsclient -";
      },
      {
        Forced = No;
        Name = wmfire.wmfire;
        DropCommand = "wmfire -m %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,5";
        Lock = Yes;
        PasteCommand = "wmfire -m %s";
        Command = "wmfire -m";
      },
      {
        Forced = No;
        Name = wmnet.WMNET;
        DropCommand = "wmnet -W enp0s31f6 %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,7";
        Lock = Yes;
        PasteCommand = "wmnet -W enp0s31f6 %s";
        Command = "wmnet -W enp0s31f6";
      },
      {
        Forced = No;
        Name = wmtemp.DockApp;
        DropCommand = "wmtemp -f %d";
        BuggyApplication = No;
        AutoLaunch = Yes;
        Position = "0,6";
        Lock = Yes;
        PasteCommand = "wmtemp -f %s";
        Command = "wmtemp -f";
      },
      {
        Forced = No;
        Name = firefox.Firefox;
        DropCommand = "firefox %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,1";
        Lock = Yes;
        PasteCommand = "firefox %s";
        Command = firefox;
      },
      {
        Forced = No;
        Name = "org\\.gnome\\.Weather.org\\.gnome\\.Weather";
        DropCommand = "org.gnome.Weather %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,8";
        Lock = Yes;
        PasteCommand = "org.gnome.Weather %s";
        Command = "/usr/bin/gnome-weather";
      },
      {
        Forced = No;
        Name = Zotero.Zotero;
        DropCommand = "/home/stu/bin/zotero %d";
        BuggyApplication = No;
        AutoLaunch = No;
        Position = "0,9";
        Lock = Yes;
        PasteCommand = "/home/stu/bin/zotero %s";
        Command = "/home/stu/bin/zotero";
      }
    );
  };
  Clip = {
    Forced = No;
    Name = Logo.WMClip;
    DropCommand = "wmsetbg -u -t %d";
    BuggyApplication = No;
    AutoLaunch = No;
    Position = "2496,1376";
    Lock = No;
    Command = "-";
  };
  Drawers = ();
  Workspaces = (
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = (
          {
            Forced = No;
            Name = xsnow.Xsnow;
            DropCommand = "xsnow %d";
            BuggyApplication = No;
            AutoLaunch = No;
            Position = "-2,0";
            Lock = No;
            PasteCommand = "xsnow %s";
            Command = xsnow;
            Omnipresent = No;
          },
          {
            Forced = No;
            Name = "thunderbird.Thunderbird-default";
            DropCommand = "thunderbird %d";
            BuggyApplication = No;
            AutoLaunch = No;
            Position = "-1,0";
            Lock = No;
            PasteCommand = "thunderbird %s";
            Command = thunderbird;
            Omnipresent = No;
          }
        );
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Mail;
    },
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = ();
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Plans;
    },
    {
      Clip = {
        AutoRaiseLower = No;
        AutoCollapse = No;
        Applications = ();
        Collapsed = No;
        AutoAttractIcons = No;
        Lowered = Yes;
      };
      Name = Misc;
    },
  );
}"#;

        // We do (original serialized) -> plist -> (serialized) -> plist and
        // compare the two plists because Rust's hashtable ordering (which is
        // reflected in the serialized output) does not match the original
        // WUtils hashtable ordering.
        let original_plist = super::parser::from_str(original).unwrap();
        let serialized = format!("{}", original_plist.display_indented());
        let deserialized = super::parser::from_str(&serialized).unwrap();
        assert_eq!(original_plist, deserialized);
    }
}
