// Per-app window-geometry persistence (eydos issue #7).
//
// Goal: when an app is launched a second time, restore the rect (and
// maximized flag) the user last left it at instead of the cascaded
// default. The file lives under the XDG state dir so it survives across
// sessions but is treated as scratch (corrupting it doesn't break the
// session — we just fall back to cascade).
//
// On-disk format is a JSON object keyed by Wayland `app_id`:
//
//     { "org.gnome.Console": { "x": 100, "y": 50, "w": 1024, "h": 720,
//                              "maximized": false }, ... }
//
// Writes are atomic (temp file + rename) so a crash mid-save can never
// leave a half-written file. Reads tolerate any parse error — bad file
// in, empty store out, the same as a missing file.

use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use smithay::utils::{Logical, Point, Rectangle, Size};
use tracing::{debug, warn};

// Below this a saved rect is degenerate junk (e.g. a client's hidden 0x0
// toplevel), never a real user-left geometry; matches the interactive floor.
const MIN_USABLE_W: i32 = 100;
const MIN_USABLE_H: i32 = 80;

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SavedGeo {
    pub x:         i32,
    pub y:         i32,
    pub w:         i32,
    pub h:         i32,
    #[serde(default)]
    pub maximized: bool,
}

impl SavedGeo {
    fn is_usable(&self) -> bool {
        self.w >= MIN_USABLE_W && self.h >= MIN_USABLE_H
    }

    /// Build a `SavedGeo` from a logical rect + maximized flag.
    pub fn from_rect(rect: Rectangle<i32, Logical>, maximized: bool) -> Self {
        Self {
            x: rect.loc.x, y: rect.loc.y,
            w: rect.size.w, h: rect.size.h,
            maximized,
        }
    }

    /// Convert back to a logical rect, clamping to `avail` so a saved
    /// geometry from a different output (4K → laptop screen) cannot
    /// place a window entirely off-screen. The minimum size is left
    /// alone; the caller decides whether to honour it or fall back to
    /// the cascade default.
    pub fn to_rect_clamped(&self, avail: Rectangle<i32, Logical>) -> Rectangle<i32, Logical> {
        let w = self.w.clamp(1, avail.size.w);
        let h = self.h.clamp(1, avail.size.h);
        let x = self.x.clamp(avail.loc.x, avail.loc.x + avail.size.w - w);
        let y = self.y.clamp(avail.loc.y, avail.loc.y + avail.size.h - h);
        Rectangle::new(Point::from((x, y)), Size::from((w, h)))
    }
}

#[derive(Debug)]
pub struct GeometryStore {
    path:    PathBuf,
    entries: HashMap<String, SavedGeo>,
}

impl GeometryStore {
    /// Load from `$XDG_STATE_HOME/forge/window-geometry.json` (or the
    /// platform fallback). A missing or unparseable file is not an
    /// error — the store starts empty and the next save creates the
    /// file fresh.
    pub fn load() -> Self {
        let path = Self::default_path();
        let entries = match std::fs::read_to_string(&path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
                warn!("geometry_store: ignoring corrupt {}: {e}", path.display());
                HashMap::new()
            }),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
            Err(e) => {
                warn!("geometry_store: read {}: {e}", path.display());
                HashMap::new()
            }
        };
        Self { path, entries }
    }

    fn default_path() -> PathBuf {
        dirs::state_dir()
            .unwrap_or_else(|| dirs::data_dir().unwrap_or_default())
            .join("forge")
            .join("window-geometry.json")
    }

    pub fn lookup(&self, app_id: &str) -> Option<SavedGeo> {
        if app_id.is_empty() { return None; }
        self.entries.get(app_id).copied().filter(SavedGeo::is_usable)
    }

    /// Record (and persist) the latest geometry for `app_id`. Empty
    /// app_id is ignored — without an identifier we have no way to
    /// match the entry on a subsequent launch.
    pub fn record(&mut self, app_id: &str, rect: Rectangle<i32, Logical>, maximized: bool) {
        if app_id.is_empty() { return; }
        let geo = SavedGeo::from_rect(rect, maximized);
        if !geo.is_usable() { return; }
        let previous = self.entries.insert(app_id.to_string(), geo);
        // Skip the disk write when nothing actually changed — avoids
        // hammering the FS during a noisy drag tail where the same
        // final rect lands multiple times via different code paths.
        if previous.map(|p| p.x == geo.x && p.y == geo.y
                          && p.w == geo.w && p.h == geo.h
                          && p.maximized == geo.maximized).unwrap_or(false) {
            return;
        }
        self.persist();
    }

    /// Atomically replace the on-disk file. Failures are logged and
    /// swallowed: the in-memory store is the source of truth for the
    /// running session, persistence is best-effort.
    fn persist(&self) {
        let Some(parent) = self.path.parent() else { return };
        if let Err(e) = std::fs::create_dir_all(parent) {
            warn!("geometry_store: mkdir {}: {e}", parent.display());
            return;
        }
        let json = match serde_json::to_string_pretty(&self.entries) {
            Ok(s) => s,
            Err(e) => { warn!("geometry_store: serialize: {e}"); return; }
        };
        let tmp = self.path.with_extension("json.tmp");
        let write_then_rename = (|| -> std::io::Result<()> {
            let mut f = std::fs::File::create(&tmp)?;
            f.write_all(json.as_bytes())?;
            f.sync_all()?;
            drop(f);
            std::fs::rename(&tmp, &self.path)?;
            Ok(())
        })();
        if let Err(e) = write_then_rename {
            warn!("geometry_store: write {}: {e}", self.path.display());
            let _ = std::fs::remove_file(&tmp);
        } else {
            debug!("geometry_store: wrote {} ({} entries)",
                self.path.display(), self.entries.len());
        }
    }
}

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

    fn avail() -> Rectangle<i32, Logical> {
        Rectangle::new(Point::from((0, 0)), Size::from((1920, 1080)))
    }

    #[test]
    fn clamp_keeps_in_bounds_rect_untouched() {
        let g = SavedGeo { x: 100, y: 50, w: 800, h: 600, maximized: false };
        let r = g.to_rect_clamped(avail());
        assert_eq!(r.loc.x, 100);
        assert_eq!(r.loc.y, 50);
        assert_eq!(r.size.w, 800);
        assert_eq!(r.size.h, 600);
    }

    #[test]
    fn clamp_pulls_oversize_rect_into_avail() {
        // 4K-sized saved rect on a 1080p output.
        let g = SavedGeo { x: 0, y: 0, w: 3840, h: 2160, maximized: false };
        let r = g.to_rect_clamped(avail());
        assert!(r.size.w <= avail().size.w);
        assert!(r.size.h <= avail().size.h);
        assert!(r.loc.x >= 0 && r.loc.y >= 0);
    }

    #[test]
    fn clamp_pulls_offscreen_origin_back() {
        // Same width as avail but placed past the right edge.
        let g = SavedGeo { x: 5000, y: 5000, w: 400, h: 300, maximized: false };
        let r = g.to_rect_clamped(avail());
        assert!(r.loc.x + r.size.w <= avail().size.w);
        assert!(r.loc.y + r.size.h <= avail().size.h);
    }

    #[test]
    fn record_ignores_empty_app_id() {
        let mut s = GeometryStore { path: PathBuf::from("/dev/null"), entries: HashMap::new() };
        s.record("", Rectangle::new(Point::from((0, 0)), Size::from((100, 100))), false);
        assert!(s.entries.is_empty());
    }
}
