//! Canvas view: zoom and pan.
//
// Ported from xfig src/w_indpanel.c (`inc_zoom`, `dec_zoom`, `show_zoom`)
// (xfig licence, see LICENSE-xfig).

use rustfig_geom::{Affine, Point, Rect};

/// Screen pixels per Fig unit at zoom 1 (80 pixels per inch, like xfig).
pub const BASE_PX_PER_FIG: f64 = 80.0 / 1200.0;
pub const MIN_ZOOM: f64 = 0.01;
pub const MAX_ZOOM: f64 = 500.0;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct View {
    /// Zoom factor (1 = 80 px per inch).
    pub zoom: f64,
    /// Fig coordinates shown at the top-left of the canvas.
    pub origin: Point,
    /// Canvas size in pixels (set by the UI).
    pub size: (f64, f64),
    /// xfig's "Integer zoom" (`integral_zoom`): zoom values above 1 stay whole
    /// numbers when set from the zoom pop-up or by Fit.
    pub integer_zoom: bool,
}

impl Default for View {
    fn default() -> Self {
        View {
            zoom: 1.0,
            origin: Point::new(-600.0, -600.0),
            size: (1000.0, 700.0),
            integer_zoom: false,
        }
    }
}

impl View {
    pub fn px_per_fig(&self) -> f64 {
        self.zoom * BASE_PX_PER_FIG
    }

    /// Fig → canvas pixels.
    pub fn transform(&self) -> Affine {
        let s = self.px_per_fig();
        Affine::translate(-self.origin.x, -self.origin.y).then(&Affine::scale(s, s))
    }

    pub fn to_screen(&self, p: Point) -> Point {
        self.transform().apply(p)
    }

    pub fn to_fig(&self, p: Point) -> Point {
        let s = self.px_per_fig();
        Point::new(self.origin.x + p.x / s, self.origin.y + p.y / s)
    }

    /// Visible region in Fig units.
    pub fn visible(&self) -> Rect {
        Rect::new(
            self.to_fig(Point::ZERO),
            self.to_fig(Point::new(self.size.0, self.size.1)),
        )
    }

    /// Zoom to `zoom`, keeping the screen point `anchor` fixed.
    pub fn zoom_about(&mut self, zoom: f64, anchor: Point) {
        if !zoom.is_finite() || !anchor.is_finite() {
            return;
        }
        let fixed = self.to_fig(anchor);
        self.zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM);
        let s = self.px_per_fig();
        self.origin = Point::new(fixed.x - anchor.x / s, fixed.y - anchor.y / s);
    }

    /// One Zoom In (`up`) or Zoom Out step as xfig's `inc_zoom`/`dec_zoom`
    /// followed by `show_zoom`'s rounding (Ported from xfig w_indpanel.c):
    /// 1, 1.41, 2, 2.83, 4 ... with the same corrections of rounding errors.
    pub fn zoom_step(zoom: f64, up: bool) -> f64 {
        const STEP: f32 = std::f32::consts::SQRT_2; // xfig's ZOOM_STEP 1.41421356f
        let z = zoom as f32;
        if !z.is_finite() {
            return zoom;
        }
        let n = if up {
            if z >= MAX_ZOOM as f32 {
                z
            } else if z == 1.41 {
                2.0
            } else if z == 45.3 {
                64.0
            } else if z == 0.35 {
                0.5
            } else if z == 0.06 {
                0.09
            } else if z == 0.01 {
                0.02
            } else {
                z * STEP
            }
        } else if z <= MIN_ZOOM as f32 {
            z
        } else if z == 11.3 {
            8.0
        } else {
            z / STEP
        };
        let n = n.clamp(MIN_ZOOM as f32, MAX_ZOOM as f32);
        let r = if n < 10.0 {
            (n * 100.0 + 0.5).trunc() / 100.0
        } else if n < 100.0 {
            (n * 10.0 + 0.5).trunc() / 10.0
        } else {
            (n + 0.5).trunc()
        };
        f64::from(r)
    }

    /// Fit `r` into the canvas with a small border.
    pub fn fit(&mut self, r: &Rect) {
        if r.is_empty() {
            return;
        }
        let (w, h) = (self.size.0.max(10.0), self.size.1.max(10.0));
        let rw = r.width().max(100.0);
        let rh = r.height().max(100.0);
        let zoom = ((w * 0.9) / (rw * BASE_PX_PER_FIG)).min((h * 0.9) / (rh * BASE_PX_PER_FIG));
        self.zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM);
        let s = self.px_per_fig();
        let c = r.center();
        self.origin = Point::new(c.x - w / 2.0 / s, c.y - h / 2.0 / s);
    }

    /// With Integer zoom on, make the zoom a whole number about the canvas
    /// centre: rounded above 1.99 after the pop-up sets it, truncated above 1
    /// after Fit (xfig's `show_zoom` and `zoom_to_fit`).
    pub fn apply_integer_zoom(&mut self, truncate: bool) {
        if !self.integer_zoom || !self.zoom.is_finite() {
            return;
        }
        let z = self.zoom;
        let n = if truncate {
            if z > 1.0 {
                z.trunc()
            } else {
                z
            }
        } else if z > 1.99 {
            z.round()
        } else {
            z
        };
        if n != z {
            let c = Point::new(self.size.0 / 2.0, self.size.1 / 2.0);
            self.zoom_about(n, c);
        }
    }

    /// Pan by a screen-pixel delta.
    pub fn pan_pixels(&mut self, dx: f64, dy: f64) {
        if !(dx.is_finite() && dy.is_finite()) {
            return;
        }
        let s = self.px_per_fig();
        self.origin = Point::new(self.origin.x - dx / s, self.origin.y - dy / s);
    }
}

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

    #[test]
    fn roundtrip_and_zoom() {
        let mut v = View::default();
        let p = Point::new(1234.0, -55.0);
        assert!(v.to_fig(v.to_screen(p)).dist(p) < 1e-6);
        let anchor = Point::new(300.0, 200.0);
        let before = v.to_fig(anchor);
        v.zoom_about(4.0, anchor);
        assert!(v.to_fig(anchor).dist(before) < 1e-6);
        v.zoom_about(f64::NAN, anchor);
        assert_eq!(v.zoom, 4.0);
    }

    #[test]
    fn zoom_steps_follow_xfig() {
        let mut z = 1.0;
        let mut up = vec![];
        for _ in 0..7 {
            z = View::zoom_step(z, true);
            up.push(z);
        }
        for (got, want) in up.iter().zip([1.41, 2.0, 2.83, 4.0, 5.66, 8.0, 11.3]) {
            assert!((got - want).abs() < 0.011, "{got} vs {want}");
        }
        assert_eq!(View::zoom_step(1.41, true), 2.0);
        assert_eq!(View::zoom_step(11.3, false), 8.0);
        assert!((View::zoom_step(1.0, false) - 0.71).abs() < 1e-6);
        assert_eq!(View::zoom_step(MAX_ZOOM, true), MAX_ZOOM);
    }
}

/// How rulers and the units box show lengths (xfig's Unit/Scale panel,
/// `w_rulers.c`): inches as fractions or decimals, and an optional figure
/// unit with a scale factor ("1 cm = 2.00 m").
#[derive(Debug, Clone, PartialEq)]
pub struct UnitScale {
    /// Imperial rulers in tenths rather than sixteenths of an inch.
    pub decimal_inches: bool,
    /// Figure units per ruler unit.
    pub factor: f64,
    /// Name of the figure unit; empty means the ruler unit.
    pub unit: String,
}

impl Default for UnitScale {
    fn default() -> Self {
        UnitScale {
            decimal_inches: false,
            factor: 1.0,
            unit: String::new(),
        }
    }
}

/// Longest figure unit name accepted.
pub const MAX_UNIT_NAME: usize = 16;

// ---- length strings (w_msgpanel.c `make_dimension_string`) -----------------

/// A length in Fig units as xfig's `make_dimension_string` writes it: scaled
/// by the unit/scale setting, in the figure unit name (or the ruler's),
/// with `precision` decimals. Inch drawings show whole numbers and fractions
/// of 1/64 inch ("1-1/2 in", "2 ft 3-1/2 in") unless the unit/scale setting
/// asks for decimal inches.
pub fn length_string(
    length: f64,
    units: rustfig_model::Units,
    scale: &UnitScale,
    ruler: &str,
    precision: u32,
) -> String {
    let (per_unit, inches) = match units {
        rustfig_model::Units::Inches => (1200.0, true),
        rustfig_model::Units::Metric => (450.0, false),
    };
    let factor = if scale.factor.is_finite() && scale.factor > 0.0 {
        scale.factor
    } else {
        1.0
    };
    let unit = if scale.unit.is_empty() {
        ruler
    } else {
        scale.unit.as_str()
    };
    let ulen = length / per_unit * factor;
    if inches && !scale.decimal_inches && ulen.is_finite() && ulen.abs() < 1e12 {
        inch_fraction(ulen, unit, precision as usize)
    } else {
        format!("{:.*} {unit}", precision as usize, ulen)
    }
}

/// Inches as a whole number and a fraction of 1/64, with feet first when
/// the unit is feet (the fractional branch of `make_dimension_string`).
fn inch_fraction(mut ulen: f64, unit: &str, prec: usize) -> String {
    let mut ilen = ulen as i64;
    if ilen as f64 == ulen {
        return format!("{ilen} {unit}");
    }
    let mut tmp = String::new();
    let mut units = unit;
    let mut ifeet = 0_i64;
    if matches!(unit, "f" | "ft" | "feet") {
        ifeet = ulen as i64;
        if ifeet.abs() >= 1 {
            tmp = format!("{ifeet} {unit} ");
        }
        ulen = (ulen - ifeet as f64) * 12.0;
        ilen = ulen as i64;
        units = "in";
    }
    let mut ifract = ((ulen - ilen as f64) * 64.0) as i64;
    if ifract == 0 {
        if ifeet < 0 {
            ilen = ilen.abs();
        }
        return format!("{tmp}{ilen} {units}");
    }
    if ilen as f64 + ifract as f64 / 64.0 == ulen {
        let mut iexp = 64_i64;
        while iexp >= 2 && ifract % 2 == 0 {
            ifract /= 2;
            iexp /= 2;
        }
        format!("{tmp}{}-{}/{iexp} {units}", ilen.abs(), ifract.abs())
    } else {
        format!("{tmp}{ulen:.prec$} {units}")
    }
}

#[cfg(test)]
mod length_tests {
    use super::*;
    use rustfig_model::Units;

    fn inch(len_inch: f64, unit: &str) -> String {
        let sc = UnitScale {
            unit: unit.into(),
            ..UnitScale::default()
        };
        length_string(len_inch * 1200.0, Units::Inches, &sc, "in", 1)
    }

    #[test]
    fn fractions_of_an_inch() {
        assert_eq!(inch(2.0, ""), "2 in");
        assert_eq!(inch(1.5, ""), "1-1/2 in");
        assert_eq!(inch(3.0 / 64.0, ""), "0-3/64 in");
        assert_eq!(inch(1.75, ""), "1-3/4 in");
    }

    #[test]
    fn feet_and_fractions() {
        // As in xfig, a fraction that does not reproduce the value exactly
        // (2 ft 3-1/2 in) falls back to decimals; 2 ft 6-3/4 in is exact.
        assert_eq!(inch(2.5625, "ft"), "2 ft 6-3/4 in");
        assert_eq!(inch(2.25, "ft"), "2 ft 3 in");
        assert_eq!(inch(2.0, "ft"), "2 ft");
    }

    #[test]
    fn decimal_fallbacks() {
        assert_eq!(inch(1.1, ""), "1.1 in");
        let sc = UnitScale {
            decimal_inches: true,
            ..UnitScale::default()
        };
        assert_eq!(length_string(1800.0, Units::Inches, &sc, "in", 1), "1.5 in");
        assert_eq!(
            length_string(450.0, Units::Metric, &UnitScale::default(), "cm", 1),
            "1.0 cm"
        );
    }
}
