using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;

public class MapWindow : IDisposable
{
    private const float FRONT_Z    = 1.0f;
    private const float BACK_Z     = -1.0f;
    private const float RIGHT_Z    = 0.5f;
    private const float LEFT_Z     = -0.5f;
    private const float Z_TOLERANCE = 0.1f;

    // ══════════════════════════════════════════════════════════════════
    //  TUNABLES — per (jacket size, view). Absolute values, subtracted.
    //  Paste a CalibrationWindow dump into the tuner to overwrite these,
    //  then "Dump seed" to bake the result back into SeedDefaults().
    // ══════════════════════════════════════════════════════════════════

    public class Tune
    {
        public double OffsetX   = 25.0;   // circle cluster shift (SUBTRACTED)
        public double OffsetY   = 50.0;
        public double SizeScale = 1.0;    // circle diameter multiplier
        public double Cluster   = 1.0;    // 1.0 = no clustering
        public double MapZoom   = 1.0;    // see MapFit mode
        public double MapX      = 0.0;    // map nudge, body pixels
        public double MapY      = 0.0;
        public double MapAlpha  = 0.7;
        public double FitMargin = 40.0;   // Form2: panel − 40 (front: −100)
        // private MapFit _mapFit = MapFit.FitBody;
        // cursor calibration 
        public double MarginX   = 0.0;
        public double MarginY   = 0.0;

        public Tune Clone() { return (Tune)MemberwiseClone(); }

    }

    /// How the MAP is sized over the body.
    ///   Native546  — Form2 behaviour: 546x546 drawn at native size, centred.
    ///                MapZoom multiplies that. MapZoom 1.0 == today's output.
    ///   FitBody    — CalibrationWindow behaviour: raw map scaled to fit the
    ///                body, then x MapZoom. Use this to verify a dump.
    public enum MapFit { Native546, FitBody }

    private readonly Dictionary<string, Tune> _tunes = new Dictionary<string, Tune>();
    // private MapFit _mapFit = MapFit.Native546;
    private MapFit _mapFit = MapFit.FitBody;

    private string TuneKey { get { return JacketSize + ":" + _view; } }

    private Tune T
    {
        get
        {
            string k = TuneKey;
            if (!_tunes.ContainsKey(k)) _tunes[k] = new Tune();
            return _tunes[k];
        }
    }

    private void SeedDefaults()
    {
        // ── L ────────────────────────────────────────────────────────
        Seed("L:front",  9.0,   34.0,   2.75, 1.10,  95, 1.060, -4.0, -14.0, 0.70);
        Seed("L:back",  -2.0,   10.0,   1.95, 0.90,  40, 1.090, 2.0, -7.0, 0.70);
        Seed("L:right", 16.0,  -106.0,  1.00, 0.50,  40, 1.010, -32.0, 71.0, 0.70);
        Seed("L:left", -14.0,  -110.0,  0.80, 0.40,  15, 1.050, 0.0, 5.0, 0.70);

        // ── XL ── (converted from the old XL_* deltas: L_offset − delta)
        Seed("XL:front",  7.0,   17.0,  2.30, 1.05, 50, 1.060, -4.0, -14.0, 0.70);
        Seed("XL:back",   5.0,   10.0,  1.95, 0.90,  40, 1.090, 2.0, -7.0, 0.70);
        Seed("XL:right",  10.0, -108.0,  0.80, 0.40,  40, 1.010, -32.0, 71.0, 0.70);
        Seed("XL:left", -4.0, -101.0,  0.80, 0.40,  40, 1.050, 0.0, 5.0, 0.70);
    }

    private void Seed(string key, double ox, double oy, double size, double cluster,
                      double fitMargin, double zoom, double mx, double my, double alpha)
    {
        _tunes[key] = new Tune
        {
            OffsetX = ox, OffsetY = oy, SizeScale = size, Cluster = cluster,
            FitMargin = fitMargin, MapZoom = zoom, MapX = mx, MapY = my, MapAlpha = alpha
        };
    }

    private const int CircleDiameter = 50;
    private const int MapSize = 546;     // Form2: new Bitmap(mapImage, 546, 546)

    // ── glade widgets ──
    [UI] private DrawingArea  MapArea           = null;
    [UI] private Label        MapTitleLabel     = null;
    [UI] private Label        MapCaseLabel      = null;
    [UI] private Button       MapBackButton     = null;
    [UI] private RadioButton  BodySkeletonRadio = null;
    [UI] private RadioButton  BodyJacketRadio   = null;
    [UI] private ComboBoxText JacketSizeCombo   = null;
    [UI] private RadioButton  ViewFrontRadio    = null;
    [UI] private RadioButton  ViewBackRadio     = null;
    [UI] private RadioButton  ViewRightRadio    = null;
    [UI] private RadioButton  ViewLeftRadio     = null;
    [UI] private CheckButton  HideCircleCheck   = null;

    private readonly Language    _lang;
    private readonly SoundLooper _sound = new SoundLooper();

    // ── state ──
    private Gdk.Pixbuf _body;        // background, native size
    private Gdk.Pixbuf _map;         // 546x546, black keyed — audio reference
    private Gdk.Pixbuf _mapRaw;      // keyed, ORIGINAL size — used by FitBody mode
    private CaseDefinition _case;
    private string _view = "front";
    private readonly ArduinoConnection _arduino;

    // ── live Arduino position ──
    private float _currentX, _currentY, _currentZ;
    private bool  _hasPosition;

    private static readonly Regex FullRx = new Regex(
        @"(\d+(?:\.\d+)?)\(([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?)\)",
        RegexOptions.Compiled);
    private static readonly Regex AvgRx = new Regex(
        @"Avg(\d+(?:\.\d+)?)\(([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?),([+-]?\d+(?:\.\d+)?)\)",
        RegexOptions.Compiled);
    private string _bodyPath = "(none)";
    private string _mapPath  = "(none)";

    public event EventHandler BackRequested;

    private JacketData _jacket;
    private List<float> _highlighted = new List<float>();
    private static bool _listed;

    // ── tuner state ──
    private Window   _tuner;
    private TextView _tunerOut;
    private Label    _tunerInfo, _tunerKey;
    private readonly Dictionary<string, SpinButton> _spins = new Dictionary<string, SpinButton>();
    private CheckButton _tShowBody, _tShowMap, _tShowCircles, _tGuides;
    private ComboBoxText _tFitCombo;
    private bool _building;
    private bool _keysHooked;

    private bool ShowBody    { get { return _tShowBody    == null || _tShowBody.Active; } }
    private bool ShowMap     { get { return _tShowMap     == null || _tShowMap.Active; } }
    private bool ShowCircles { get { return (_tShowCircles == null || _tShowCircles.Active)
                                            && !HideCircleCheck.Active; } }
    private bool ShowGuides  { get { return _tGuides != null && _tGuides.Active; } }

    // ══════════════════════════════════════════════════════════════════

    public MapWindow(Builder builder, ArduinoConnection arduino, Language language)
    {
        builder.Autoconnect(this);
        _arduino = arduino;


        SeedDefaults();
        LoadCalibFile(false);           // calib.txt overrides seeds if present

        _jacket = JacketData.Load();
        Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded);

        if (MapArea == null || MapTitleLabel == null || MapBackButton == null)
            throw new InvalidOperationException(
                "Glade id mismatch — MapArea=" + (MapArea != null) +
                " MapTitleLabel=" + (MapTitleLabel != null) +
                " MapBackButton=" + (MapBackButton != null));

        _lang = language;

        MapArea.Drawn         += OnMapDrawn;
        MapBackButton.Clicked += OnBackClicked;

        BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); };
        BodyJacketRadio.Toggled   += (s, e) => { if (BodyJacketRadio.Active)   ReloadBody(); };
        JacketSizeCombo.Changed   += (s, e) => { ReloadBody(); SyncSpins(); MapArea.QueueDraw(); };

        HookView(ViewFrontRadio, "front");
        HookView(ViewBackRadio,  "back");
        HookView(ViewRightRadio, "right");
        HookView(ViewLeftRadio,  "left");

        HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw();

        // Ctrl+T anywhere, or Ctrl+double-click on the map, opens the tuner.
        MapArea.Realized += (s, e) => HookKeys();
        MapArea.AddEvents((int)Gdk.EventMask.ButtonPressMask);
        MapArea.ButtonPressEvent += (o, a) =>
        {
            if (a.Event.Type == Gdk.EventType.TwoButtonPress &&
                (a.Event.State & Gdk.ModifierType.ControlMask) != 0)
                ToggleTuner();
        };

        _sound.Log += msg => Logger.Write("sound", msg);
        // NEW — live position feed
        _arduino.LineReceived      += OnArduinoLine;
        _arduino.ConnectionChanged += OnArduinoConnectionChanged;
    }

    private void HookKeys()
    {
        if (_keysHooked) return;
        var top = MapArea.Toplevel as Window;
        if (top == null) return;
        _keysHooked = true;

        top.KeyPressEvent += (o, a) =>
        {
            if ((a.Event.State & Gdk.ModifierType.ControlMask) == 0) return;
            if (a.Event.Key == Gdk.Key.t || a.Event.Key == Gdk.Key.T)
                ToggleTuner();
        };
    }

    private void HookView(RadioButton rb, string view)
    {
        if (rb == null) return;
        rb.Toggled += (s, e) => { if (rb.Active) SetView(view); };
    }

    // ══════════════════════════════════════════════════════════════════
    //  ARDUINO POSITION
    // ══════════════════════════════════════════════════════════════════

   private void OnArduinoLine(string line)
    {
        var fullMatches = FullRx.Matches(line);
        var avgMatch    = AvgRx.Match(line);

        float avgChipNumber = -1f;
        float x = 0, y = 0, z = 0;
        bool havePosition = false;

        _highlighted.Clear();

         Logger.Write("chip", "raw='" + line + "'  fullMatches=" + fullMatches.Count +   // NEW
                          "  avgMatch=" + avgMatch.Success);             

        if (avgMatch.Success)
        {
            avgChipNumber = ParseF(avgMatch.Groups[1].Value);
            x = ParseF(avgMatch.Groups[2].Value);
            y = ParseF(avgMatch.Groups[3].Value);
            z = ParseF(avgMatch.Groups[4].Value);
            havePosition = true;
        }

        if (fullMatches.Count > 0)
        {
            foreach (Match m in fullMatches)
            {
                float chipNumber = ParseF(m.Groups[1].Value);
                if (Math.Abs(chipNumber - avgChipNumber) < 0.001f) continue; // that's the Avg entry itself
                _highlighted.Add(chipNumber);   // feeds the existing yellow-fill in DrawOneCircle
            }

            if (!havePosition)
            {
                var first = fullMatches[0];
                x = ParseF(first.Groups[2].Value);
                y = ParseF(first.Groups[3].Value);
                z = ParseF(first.Groups[4].Value);
                havePosition = true;
            }
        }

        if (!havePosition) return;   // status/mode line, not a coordinate line

        _currentX = x;
        _currentY = y;
        _currentZ = z;
        _hasPosition = _highlighted.Count > 0;   // NEW — marker only shows when a chip is actually on the target

        string newView = DetermineViewFromZ(z);
        if (newView != _view)
            SwitchViewFromArduino(newView);

        // Logger.Write("chip", "highlighted=[" + string.Join(",", _highlighted) + "]  " +   // NEW
        //                   "view=" + _view + "  size=" + JacketSize +                  // NEW
        //                   "  circlesInView=" + (_jacket != null ? _jacket.Get(JacketSize, _view).Count : -1)); // NEW


        MapArea.QueueDraw();
    }  

    private static float ParseF(string s)
    {
        return float.Parse(s, CultureInfo.InvariantCulture);
    }

    private void OnArduinoConnectionChanged(bool connected)
    {
        if (!connected)
        {
            _hasPosition = false;
            MapArea.QueueDraw();
        }
    }

    /// Maps the live Arduino (x,y) into BODY PIXELS using the same
    /// bounding-box scale/offset DrawCircles uses, so the cursor tracks
    /// the same coordinate frame as the numbered circles.
    private bool TryGetCursorBodyPoint(out double bx, out double by)
    {
        bx = by = 0;
        if (!_hasPosition || _jacket == null || _body == null) return false;

        var circles = _jacket.Get(JacketSize, _view);
        if (circles.Count == 0) return false;

        var t = T;
        int panelW = _body.Width;
        int panelH = _body.Height;

        float minX = float.MaxValue, maxX = float.MinValue;
        float minY = float.MaxValue, maxY = float.MinValue;
        foreach (var c in circles)
        {
            if (c.X < minX) minX = c.X;
            if (c.X > maxX) maxX = c.X;
            if (c.Y < minY) minY = c.Y;
            if (c.Y > maxY) maxY = c.Y;
        }

        double availW = panelW - t.FitMargin, availH = panelH - t.FitMargin;
        const double circleRadius = 2.1 / 2;
        double dataW = (maxX - minX) + circleRadius * 2;
        double dataH = (maxY - minY) + circleRadius * 2;
        if (dataW <= 0 || dataH <= 0) return false;

        double scale = Math.Min(availW / dataW, availH / dataH) * 0.9;

        double offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
        double offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale;

         // NEW — same centroid DrawCircles computes, needed so clustering below
        // pulls the cursor toward the exact same point the circles cluster around.
        double avgX = 0, avgY = 0;
        foreach (var c in circles)
        {
            avgX += c.X * scale + offsetX - t.OffsetX;
            avgY += c.Y * scale + offsetY - t.OffsetY;
        }
        avgX /= circles.Count;
        avgY /= circles.Count;

        // CHANGED — subtract t.OffsetX/OffsetY, exactly like DrawOneCircle does.
        // This was the main misalignment: circles were shifted by this offset,
        // the cursor never was.
        bx = (_currentX * 50 + t.MarginX) * scale + offsetX - t.OffsetX;
        by = (_currentY * 50 + t.MarginY) * scale + offsetY - t.OffsetY;

        if (JacketSize == "XL" && (_view == "front" || _view == "right" || _view == "left"))
        {
            double centerX = panelW / 2.0;
            double offsetFromCenter = bx - centerX;
            bx = centerX - offsetFromCenter;
        }

        return true;
    }

    private void DrawCursor(Cairo.Context cr, double x, double y)
    {
        const double size = 14;

        cr.NewPath();
        // cr.SetSourceRGBA(1.0, 0.0, 0.0, 0.9); //red not that visible
        cr.SetSourceRGBA(1.0, 0.85, 0.0, 0.95); //yellow
        cr.LineWidth = 2.5;
        cr.MoveTo(x - size, y);
        cr.LineTo(x + size, y);
        cr.MoveTo(x, y - size);
        cr.LineTo(x, y + size);
        cr.Stroke();

        cr.NewPath();
        cr.Arc(x, y, 5, 0, 2 * Math.PI);
        cr.Stroke();
    }

    private void DrawCoordHud(Cairo.Context cr, int w, int h)
    {
        string text = string.Format(CultureInfo.InvariantCulture,
            "X={0:F2}  Y={1:F2}  Z={2:F2}  view={3}", _currentX, _currentY, _currentZ, _view);

        cr.SelectFontFace("Monospace", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
        cr.SetFontSize(13);
        var ext = cr.TextExtents(text);

        double pad = 6, bx = 8, by = 8;
        double bw = ext.Width + pad * 2, bh = ext.Height + pad * 2;

        cr.NewPath();
        cr.Rectangle(bx, by, bw, bh);
        cr.SetSourceRGBA(0, 0, 0, 0.55);
        cr.Fill();

        cr.MoveTo(bx + pad - ext.XBearing, by + pad + ext.Height);
        cr.SetSourceRGBA(1, 1, 1, 1);
        cr.ShowText(text);
    }

    // ── public API ─────────────────────────────────────────────────────

    public void ShowCase(CaseDefinition def, string title)
    {
        _case = def;
        _view = "front";
        if (ViewFrontRadio != null) ViewFrontRadio.Active = true;

        MapTitleLabel.Text = title ?? "";
        MapCaseLabel.Text  = title ?? "-";

        DisposePixbufs();
        _body = LoadBody();
        _map  = LoadMap(MapPathForView());

        MapArea.QueueDraw();
        UpdateTunerInfo();
        _sound.Start(SoundWindow.AppFile(def.SoundPath));

        Logger.Write("case", "open: " + title + " | map=" + MapPathForView());
    }

    public void StopSession()
    {
        _sound.Stop();
        Logger.Write("case", "closed");
    }

    /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited.
    public Gdk.Pixbuf AudioReference { get { return _map; } }

    /// Body-pixel point -> pixel index inside the MAP that is drawn there.
    /// Stays correct in both fit modes, so audio sampling tracks the display.
    public bool BodyToMapPixel(double bx, double by, out int px, out int py)
    {
        px = py = 0;
        double mx, my, mw, mh;
        if (!MapRect(out mx, out my, out mw, out mh)) return false;
        if (mw <= 0 || mh <= 0) return false;

        double u = (bx - mx) / mw, v = (by - my) / mh;
        if (u < 0 || u >= 1 || v < 0 || v >= 1) return false;

        Gdk.Pixbuf src = (_mapFit == MapFit.FitBody && _mapRaw != null) ? _mapRaw : _map;
        if (src == null) return false;

        px = (int)(u * src.Width);
        py = (int)(v * src.Height);
        return true;
    }

    // ── the paint ──────────────────────────────────────────────────────

    /// Where the MAP lands, in BODY PIXELS. Single source of truth for both
    /// the draw and BodyToMapPixel.
    private bool MapRect(out double mx, out double my, out double mw, out double mh)
    {
        mx = my = mw = mh = 0;
        if (_body == null) return false;

        var t = T;

        if (_mapFit == MapFit.FitBody)
        {
            if (_mapRaw == null) return false;
            double ms = Math.Min((double)_body.Width  / _mapRaw.Width,
                                 (double)_body.Height / _mapRaw.Height) * t.MapZoom;
            mw = _mapRaw.Width * ms;
            mh = _mapRaw.Height * ms;
        }
        else
        {
            if (_map == null) return false;
            mw = _map.Width  * t.MapZoom;
            mh = _map.Height * t.MapZoom;
        }

        mx = (_body.Width  - mw) / 2.0 + t.MapX;
        my = (_body.Height - mh) / 2.0 + t.MapY;
        return true;
    }

    private void OnMapDrawn(object o, DrawnArgs args)
    {
        var cr = args.Cr;
        int w  = MapArea.AllocatedWidth;
        int h  = MapArea.AllocatedHeight;

        cr.SetSourceRGB(1, 1, 1);
        cr.Rectangle(0, 0, w, h);
        cr.Fill();

        if (_body == null) { args.RetVal = true; return; }

        // ONE scale factor, derived from the body — this is what keeps the
        // overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom.
        double s  = Math.Min((double)w / _body.Width, (double)h / _body.Height);
        double ox = (w - _body.Width  * s) / 2.0;
        double oy = (h - _body.Height * s) / 2.0;

        cr.Save();
        cr.Translate(ox, oy);
        cr.Scale(s, s);                 // everything below is in BODY PIXELS

        if (ShowBody)
        {
            Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0);
            cr.Paint();
        }

        double mx, my, mw, mh;
        bool haveMap = MapRect(out mx, out my, out mw, out mh);

        if (haveMap && ShowMap)
        {
            Gdk.Pixbuf src = (_mapFit == MapFit.FitBody && _mapRaw != null) ? _mapRaw : _map;
            double sx = mw / src.Width, sy = mh / src.Height;

            cr.Save();
            cr.Translate(mx, my);
            cr.Scale(sx, sy);
            Gdk.CairoHelper.SetSourcePixbuf(cr, src, 0, 0);
            cr.PaintWithAlpha(T.MapAlpha);
            cr.Restore();
        }

        if (ShowCircles)
            DrawCircles(cr);            // body-pixel coords; transform does the rest

        double cx, cy;
        if (TryGetCursorBodyPoint(out cx, out cy))
            DrawCursor(cr, cx, cy);

        if (ShowGuides)
        {
            if (haveMap)
            {
                cr.NewPath();
                cr.SetSourceRGBA(1, 0, 0, 0.6);
                cr.LineWidth = 1.5;
                cr.Rectangle(mx, my, mw, mh);
                cr.Stroke();
            }

            cr.NewPath();
            cr.SetSourceRGBA(0, 0, 1, 0.35);
            cr.LineWidth = 1.0;
            cr.MoveTo(_body.Width / 2.0, 0);
            cr.LineTo(_body.Width / 2.0, _body.Height);
            cr.MoveTo(0, _body.Height / 2.0);
            cr.LineTo(_body.Width, _body.Height / 2.0);
            cr.Stroke();
        }

        cr.Restore();
        // NEW — screen-space readout, unaffected by the body scale/translate above
        if (_hasPosition)
            DrawCoordHud(cr, w, h); //show the coordinate on top of the image

        args.RetVal = true;
    }

    private void DrawCircles(Cairo.Context cr)
    {
        if (_jacket == null || _body == null) return;

        var circles = _jacket.Get(JacketSize, _view);
        if (circles.Count == 0) return;

        var t = T;

        // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546
        int panelW = _body.Width;
        int panelH = _body.Height;

        float minX = float.MaxValue, maxX = float.MinValue;
        float minY = float.MaxValue, maxY = float.MinValue;
        foreach (var c in circles)
        {
            if (c.X < minX) minX = c.X;
            if (c.X > maxX) maxX = c.X;
            if (c.Y < minY) minY = c.Y;
            if (c.Y > maxY) maxY = c.Y;
        }

        double availW = panelW - t.FitMargin, availH = panelH - t.FitMargin;
        const double circleRadius = 2.1 / 2;
        double dataW = (maxX - minX) + circleRadius * 2;
        double dataH = (maxY - minY) + circleRadius * 2;
        if (dataW <= 0 || dataH <= 0) return;

        double scale = Math.Min(availW / dataW, availH / dataH) * 0.9;

        double offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale;
        double offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale;

        // cluster centroid, in already-offset space
        double avgX = 0, avgY = 0;
        foreach (var c in circles)
        {
            avgX += c.X * scale + offsetX - t.OffsetX;
            avgY += c.Y * scale + offsetY - t.OffsetY;
        }
        avgX /= circles.Count;
        avgY /= circles.Count;

        cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);

        foreach (var c in circles)
            DrawOneCircle(cr, c, t, scale, offsetX, offsetY, avgX, avgY);
    }

    private void DrawOneCircle(Cairo.Context cr, Circle c, Tune t,
                               double scale, double ox, double oy,
                               double avgX, double avgY)
    {
        double x = c.X * scale + ox - t.OffsetX;
        double y = c.Y * scale + oy - t.OffsetY;

        if (Math.Abs(t.Cluster - 1.0) > 0.001)
        {
            x = avgX + (x - avgX) * t.Cluster;
            y = avgY + (y - avgY) * t.Cluster;
        }

        double d = CircleDiameter * scale * t.SizeScale;
        double r = d / 2;

        bool hot = false;
        foreach (float n in _highlighted)
            if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; }

        cr.NewPath();
        cr.Arc(x, y, r, 0, 2 * Math.PI);

        if (hot)
        {
            cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0);   // Color.FromArgb(255,235,59)
            cr.FillPreserve();
        }

        cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0);           // BorderColor = Gray
        cr.LineWidth = Math.Max(1.0, scale);
        cr.Stroke();

        string text = (c.Number % 1 == 0)
            ? ((int)c.Number).ToString()
            : c.Number.ToString("F1", CultureInfo.InvariantCulture);

        cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4)));
        var ext = cr.TextExtents(text);
        cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2);
        cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0);           // TextColor
        cr.ShowText(text);
        cr.NewPath();
    }

    // ══════════════════════════════════════════════════════════════════
    //  TUNER
    // ══════════════════════════════════════════════════════════════════

    public void ToggleTuner()
    {
        if (_tuner != null) { _tuner.Destroy(); _tuner = null; return; }
        BuildTuner();
    }

    private void BuildTuner()
    {
        _tuner = new Window("MapWindow tuner  —  Ctrl+T to close");
        _tuner.SetDefaultSize(430, 760);
        _tuner.DeleteEvent += (o, a) => { _tuner = null; };

        var scroll = new ScrolledWindow();
        var col = new Box(Orientation.Vertical, 4);
        col.MarginLeft = col.MarginRight = col.MarginTop = col.MarginBottom = 8;

        _tunerKey = new Label(); _tunerKey.Xalign = 0;
        col.PackStart(_tunerKey, false, false, 0);

        _tunerInfo = new Label(); _tunerInfo.Xalign = 0;
        _tunerInfo.Selectable = true;
        col.PackStart(_tunerInfo, false, false, 0);

        col.PackStart(Header("Sound map"), false, false, 0);

        _tFitCombo = new ComboBoxText();
        _tFitCombo.AppendText("Native546 (Form2)");
        _tFitCombo.AppendText("FitBody (calibrator)");
        _tFitCombo.Active = (_mapFit == MapFit.FitBody) ? 1 : 0;
        _tFitCombo.Changed += (s, e) =>
        {
            if (_building) return;
            _mapFit = _tFitCombo.Active == 1 ? MapFit.FitBody : MapFit.Native546;
            UpdateTunerInfo();
            MapArea.QueueDraw();
        };
        col.PackStart(Row("Map fit mode", _tFitCombo), false, false, 0);

        AddSpin(col, "MapZoom",  "Zoom",          0.10, 3.00, 0.01, 2);
        AddSpin(col, "MapX",     "Offset X (px)", -600, 600, 1, 0);
        AddSpin(col, "MapY",     "Offset Y (px)", -600, 600, 1, 0);
        AddSpin(col, "MapAlpha", "Opacity",       0.00, 1.00, 0.05, 2);

        col.PackStart(Header("Circle cluster"), false, false, 0);
        AddSpin(col, "OffsetX",   "Offset X (px)",  -600, 600, 1, 0);
        AddSpin(col, "OffsetY",   "Offset Y (px)",  -600, 600, 1, 0);
        AddSpin(col, "SizeScale", "Circle size",    0.05, 4.00, 0.05, 2);
        AddSpin(col, "Cluster",   "Cluster factor", 0.05, 2.00, 0.05, 2);
        AddSpin(col, "FitMargin", "Fit margin (px)", 0, 400, 5, 0);

        col.PackStart(Header("Cursor (Arduino)"), false, false, 0);
        AddSpin(col, "MarginX", "Margin X (raw units)", -2000, 2000, 1, 0);
        AddSpin(col, "MarginY", "Margin Y (raw units)", -2000, 2000, 1, 0);

        col.PackStart(Header("Display"), false, false, 0);
        _tShowBody    = Chk(col, "Show body",    true);
        _tShowMap     = Chk(col, "Show map",     true);
        _tShowCircles = Chk(col, "Show circles", true);
        _tGuides      = Chk(col, "Show guides",  false);

        col.PackStart(Header("Images"), false, false, 0);
        var imgRow = new Box(Orientation.Horizontal, 4);
        imgRow.PackStart(Btn("Reload images", () =>
        {
            ReloadBody();
            if (_map != null) { _map.Dispose(); _map = null; }
            if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; }
            _map = LoadMap(MapPathForView());
            UpdateTunerInfo();
            MapArea.QueueDraw();
        }), true, true, 0);
        imgRow.PackStart(Btn("Cycle view", () =>
        {
            string[] v = { "front", "back", "right", "left" };
            int i = Array.IndexOf(v, _view);
            string next = v[(i + 1) % v.Length];
            RadioButton rb = next == "back" ? ViewBackRadio
                           : next == "right" ? ViewRightRadio
                           : next == "left" ? ViewLeftRadio : ViewFrontRadio;
            if (rb != null) rb.Active = true; else SetView(next);
        }), true, true, 0);
        col.PackStart(imgRow, false, false, 0);

        col.PackStart(Header("Constants"), false, false, 0);

        var r1 = new Box(Orientation.Horizontal, 4);
        r1.PackStart(Btn("Apply pasted C#", ApplyPastedCSharp), true, true, 0);
        r1.PackStart(Btn("Dump C# (calib)", () => Emit(DumpCalibStyle())), true, true, 0);
        col.PackStart(r1, false, false, 0);

        var r2 = new Box(Orientation.Horizontal, 4);
        r2.PackStart(Btn("Dump seed", () => Emit(DumpSeed())), true, true, 0);
        r2.PackStart(Btn("Reset view", () =>
        {
            _tunes.Remove(TuneKey);
            var fresh = new Dictionary<string, Tune>(_tunes);
            SeedDefaults();
            foreach (var kv in fresh) if (kv.Key != TuneKey) _tunes[kv.Key] = kv.Value;
            SyncSpins(); MapArea.QueueDraw();
        }), true, true, 0);
        col.PackStart(r2, false, false, 0);

        var r3 = new Box(Orientation.Horizontal, 4);
        r3.PackStart(Btn("Load calib.txt", () => { LoadCalibFile(true); SyncSpins(); MapArea.QueueDraw(); }), true, true, 0);
        r3.PackStart(Btn("Save calib.txt", SaveCalibFile), true, true, 0);
        col.PackStart(r3, false, false, 0);

        _tunerOut = new TextView();
        _tunerOut.OverrideFont(Pango.FontDescription.FromString("Monospace 9"));
        _tunerOut.WrapMode = WrapMode.None;
        var outScroll = new ScrolledWindow();
        outScroll.HeightRequest = 220;
        outScroll.Add(_tunerOut);
        col.PackStart(outScroll, true, true, 0);

        col.PackStart(new Label(
            "Paste a CalibrationWindow dump above, then Apply pasted C#.") { Xalign = 0 },
            false, false, 0);

        scroll.Add(col);
        _tuner.Add(scroll);
        _tuner.ShowAll();

        SyncSpins();
        UpdateTunerInfo();
    }

    // ── tuner widget helpers ──

    private Label Header(string text)
    {
        var l = new Label();
        l.Markup = "<b>" + text + "</b>";
        l.Xalign = 0;
        l.MarginTop = 8;
        return l;
    }

    private Box Row(string label, Widget w)
    {
        var box = new Box(Orientation.Horizontal, 6);
        var l = new Label(label);
        l.Xalign = 0;
        l.WidthRequest = 130;
        box.PackStart(l, false, false, 0);
        box.PackStart(w, true, true, 0);
        return box;
    }

    private Button Btn(string label, System.Action onClick)
    {
        var b = new Button(label);
        b.Clicked += (s, e) => onClick();
        return b;
    }

    private CheckButton Chk(Box parent, string label, bool active)
    {
        var c = new CheckButton(label);
        c.Active = active;
        c.Toggled += (s, e) => MapArea.QueueDraw();
        parent.PackStart(c, false, false, 0);
        return c;
    }

    private void AddSpin(Box parent, string field, string label,
                         double min, double max, double step, uint digits)
    {
        var sb = new SpinButton(min, max, step);
        sb.Digits = digits;
        sb.Numeric = true;
        sb.ValueChanged += (s, e) =>
        {
            if (_building) return;
            SetField(field, sb.Value);
            UpdateTunerInfo();
            MapArea.QueueDraw();
        };
        _spins[field] = sb;
        parent.PackStart(Row(label, sb), false, false, 0);
    }

    private void SetField(string field, double v)
    {
        var t = T;
        switch (field)
        {
            case "OffsetX":   t.OffsetX   = v; break;
            case "OffsetY":   t.OffsetY   = v; break;
            case "SizeScale": t.SizeScale = v; break;
            case "Cluster":   t.Cluster   = v; break;
            case "MapZoom":   t.MapZoom   = v; break;
            case "MapX":      t.MapX      = v; break;
            case "MapY":      t.MapY      = v; break;
            case "MapAlpha":  t.MapAlpha  = v; break;
            case "FitMargin": t.FitMargin = v; break;
            case "MarginX": t.MarginX = v; break;   
            case "MarginY": t.MarginY = v; break;
        }
    }

    private double GetField(string field)
    {
        var t = T;
        switch (field)
        {
            case "OffsetX":   return t.OffsetX;
            case "OffsetY":   return t.OffsetY;
            case "SizeScale": return t.SizeScale;
            case "Cluster":   return t.Cluster;
            case "MapZoom":   return t.MapZoom;
            case "MapX":      return t.MapX;
            case "MapY":      return t.MapY;
            case "MapAlpha":  return t.MapAlpha;
            case "FitMargin": return t.FitMargin;
            case "MarginX": return t.MarginX;
            case "MarginY": return t.MarginY;
        }
        return 0;
    }

    private void SyncSpins()
    {
        if (_tuner == null) return;
        _building = true;
        foreach (var kv in _spins) kv.Value.Value = GetField(kv.Key);
        if (_tunerKey != null) _tunerKey.Markup = "<b>Editing: " + TuneKey + "</b>";
        _building = false;
    }

    private void UpdateTunerInfo()
    {
        if (_tunerInfo == null) return;

        var sb = new StringBuilder();
        sb.Append("body: ").Append(_bodyPath);
        if (_body != null) sb.Append("  [").Append(_body.Width).Append("x").Append(_body.Height).Append("]");
        else sb.Append("  [MISSING]");
        sb.AppendLine();

        sb.Append("map:  ").Append(_mapPath);
        if (_mapRaw != null) sb.Append("  raw[").Append(_mapRaw.Width).Append("x").Append(_mapRaw.Height).Append("]");
        if (_map != null) sb.Append("  used[").Append(_map.Width).Append("x").Append(_map.Height).Append("]");
        else sb.Append("  [MISSING]");
        sb.AppendLine();

        double mx, my, mw, mh;
        if (MapRect(out mx, out my, out mw, out mh))
            sb.AppendFormat(CultureInfo.InvariantCulture,
                "rect: x={0:F1} y={1:F1} w={2:F1} h={3:F1}\n", mx, my, mw, mh);

        if (_mapFit == MapFit.FitBody)
            sb.AppendLine("NOTE: FitBody changes display geometry; AudioReference\n" +
                          "      is still the 546 pixbuf — sample via BodyToMapPixel.");

        _tunerInfo.Text = sb.ToString();
    }

    private void Emit(string msg)
    {
        if (_tunerOut == null) { Logger.Write("tune", msg); return; }
        _tunerOut.Buffer.Text = msg + "\n\n" + _tunerOut.Buffer.Text;
    }

    // ── C# constant round-trip ──

    private static readonly Regex ConstRx = new Regex(
        @"(?<xl>XL_)?(?<view>FRONT|BACK|RIGHT|LEFT)_" +
        @"(?<field>OFFSET_X|OFFSET_Y|SIZE|CLUSTER|FIT_MARGIN|MAP_ZOOM|MAP_X|MAP_Y|MAP_ALPHA)" +
        @"\s*=\s*(?<val>-?[0-9]*\.?[0-9]+)",
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    /// Parses a CalibrationWindow "Dump" block pasted into the text view.
    private void ApplyPastedCSharp()
    {
        if (_tunerOut == null) return;
        string text = _tunerOut.Buffer.Text;

        var inv = CultureInfo.InvariantCulture;
        int applied = 0;
        var touched = new List<string>();

        foreach (Match m in ConstRx.Matches(text))
        {
            string size = m.Groups["xl"].Success ? "XL" : "L";
            string view = m.Groups["view"].Value.ToLower();
            string key  = size + ":" + view;

            double v;
            if (!double.TryParse(m.Groups["val"].Value, NumberStyles.Float, inv, out v)) continue;

            if (!_tunes.ContainsKey(key)) _tunes[key] = new Tune();
            var t = _tunes[key];

            switch (m.Groups["field"].Value.ToUpper())
            {
                case "OFFSET_X":   t.OffsetX   = v; break;
                case "OFFSET_Y":   t.OffsetY   = v; break;
                case "SIZE":       t.SizeScale = v; break;
                case "CLUSTER":    t.Cluster   = v; break;
                case "FIT_MARGIN": t.FitMargin = v; break;
                case "MAP_ZOOM":   t.MapZoom   = v; break;
                case "MAP_X":      t.MapX      = v; break;
                case "MAP_Y":      t.MapY      = v; break;
                case "MAP_ALPHA":  t.MapAlpha  = v; break;
                default: continue;
            }
            applied++;
            if (!touched.Contains(key)) touched.Add(key);
        }

        SyncSpins();
        UpdateTunerInfo();
        MapArea.QueueDraw();

        if (applied == 0)
            Emit("no constants recognised — expected lines like\n" +
                 "  private const float FRONT_OFFSET_X = 25.0f;");
        else
            Emit("applied " + applied + " values to: " + string.Join(", ", touched.ToArray()) +
                 "\nIf MAP_ZOOM came from the calibrator, switch fit mode to FitBody.");
    }

    /// Same format CalibrationWindow emits — round-trips through Apply.
    private string DumpCalibStyle()
    {
        var inv = CultureInfo.InvariantCulture;
        var sb = new StringBuilder();
        sb.AppendLine("// MapWindow tuner " + DateTime.Now.ToString("yyyy-MM-dd HH:mm") +
                      "  fit=" + _mapFit);

        foreach (string size in new[] { "L", "XL" })
        {
            sb.AppendLine("// ---- " + size + " ----");
            foreach (string view in new[] { "front", "back", "right", "left" })
            {
                string k = size + ":" + view;
                if (!_tunes.ContainsKey(k)) continue;
                var t = _tunes[k];
                string p = (size == "XL" ? "XL_" : "") + view.ToUpper();

                sb.AppendLine("private const float " + p + "_OFFSET_X   = " + t.OffsetX.ToString("F1", inv) + "f;");
                sb.AppendLine("private const float " + p + "_OFFSET_Y   = " + t.OffsetY.ToString("F1", inv) + "f;");
                sb.AppendLine("private const float " + p + "_SIZE       = " + t.SizeScale.ToString("F2", inv) + "f;");
                sb.AppendLine("private const float " + p + "_CLUSTER    = " + t.Cluster.ToString("F2", inv) + "f;");
                sb.AppendLine("private const float " + p + "_FIT_MARGIN = " + t.FitMargin.ToString("F0", inv) + "f;");
                sb.AppendLine("private const double " + p + "_MAP_ZOOM  = " + t.MapZoom.ToString("F3", inv) + ";");
                sb.AppendLine("private const double " + p + "_MAP_X     = " + t.MapX.ToString("F1", inv) + ";");
                sb.AppendLine("private const double " + p + "_MAP_Y     = " + t.MapY.ToString("F1", inv) + ";");
                sb.AppendLine("private const double " + p + "_MAP_ALPHA = " + t.MapAlpha.ToString("F2", inv) + ";");
                sb.AppendLine();
            }
        }
        return sb.ToString();
    }

    /// Paste this straight over SeedDefaults() to bake values into the build.
    private string DumpSeed()
    {
        var inv = CultureInfo.InvariantCulture;
        var sb = new StringBuilder();
        sb.AppendLine("private void SeedDefaults()");
        sb.AppendLine("{");
        foreach (string size in new[] { "L", "XL" })
            foreach (string view in new[] { "front", "back", "right", "left" })
            {
                string k = size + ":" + view;
                if (!_tunes.ContainsKey(k)) continue;
                var t = _tunes[k];
                sb.AppendFormat(inv,
                    "    Seed(\"{0}\", {1:F1}, {2:F1}, {3:F2}, {4:F2}, {5:F0}, {6:F3}, {7:F1}, {8:F1}, {9:F2});\n",
                    k, t.OffsetX, t.OffsetY, t.SizeScale, t.Cluster,
                    t.FitMargin, t.MapZoom, t.MapX, t.MapY, t.MapAlpha);
            }
        sb.AppendLine("}");
        sb.AppendLine("// _mapFit = MapFit." + _mapFit + ";");
        return sb.ToString();
    }

    // ── calib.txt (same tab format CalibrationWindow writes) ──

    private static string CalibPath { get { return SoundWindow.AppFile("calib.txt"); } }

    private void LoadCalibFile(bool verbose)
    {
        string p = CalibPath;
        if (!File.Exists(p))
        {
            if (verbose) Emit("calib.txt not found at " + p);
            return;
        }

        var inv = CultureInfo.InvariantCulture;
        int n = 0;
        foreach (string line in File.ReadAllLines(p))
        {
            if (line.StartsWith("#") || line.Trim().Length == 0) continue;
            string[] f = line.Split('\t');
            if (f.Length < 10) continue;

            try
            {
                var t = new Tune();
                t.OffsetX   = double.Parse(f[1], inv);
                t.OffsetY   = double.Parse(f[2], inv);
                t.SizeScale = double.Parse(f[3], inv);
                t.Cluster   = double.Parse(f[4], inv);
                t.MapZoom   = double.Parse(f[5], inv);
                t.MapX      = double.Parse(f[6], inv);
                t.MapY      = double.Parse(f[7], inv);
                t.MapAlpha  = double.Parse(f[8], inv);
                t.FitMargin = double.Parse(f[9], inv);
                _tunes[f[0]] = t;
                n++;
            }
            catch { }
        }

        Logger.Write("tune", "loaded " + n + " entries from " + p);
        if (verbose) Emit("loaded " + n + " entries from " + p +
                          "\ncalib.txt zoom values assume FitBody mode.");
    }

    private void SaveCalibFile()
    {
        var inv = CultureInfo.InvariantCulture;
        var sb = new StringBuilder();
        sb.AppendLine("# size:view offsetX offsetY sizeScale cluster mapZoom mapX mapY mapAlpha fitMargin");
        foreach (var kv in _tunes)
        {
            var t = kv.Value;
            sb.AppendLine(string.Join("\t", new[]
            {
                kv.Key,
                t.OffsetX.ToString(inv),   t.OffsetY.ToString(inv),
                t.SizeScale.ToString(inv), t.Cluster.ToString(inv),
                t.MapZoom.ToString(inv),   t.MapX.ToString(inv),
                t.MapY.ToString(inv),      t.MapAlpha.ToString(inv),
                t.FitMargin.ToString(inv)
            }));
        }

        try
        {
            File.WriteAllText(CalibPath, sb.ToString());
            Emit("saved " + CalibPath);
        }
        catch (Exception ex) { Emit("save failed: " + ex.Message); }
    }

    // ── loading ────────────────────────────────────────────────────────

    private void SetView(string view)
    {
        if (_view == view) return;
        _view = view;

        ReloadBody();

        if (_map != null)    { _map.Dispose();    _map = null; }
        if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; }
        _map = LoadMap(MapPathForView());

        SyncSpins();
        UpdateTunerInfo();
        MapArea.QueueDraw();
        Logger.Write("view", "-> " + view);
    }

    private void ReloadBody()
    {
        if (_body != null) { _body.Dispose(); _body = null; }
        _body = LoadBody();
        UpdateTunerInfo();
        MapArea.QueueDraw();
    }

    private Gdk.Pixbuf LoadBody()
    {
        string stem = BodyStem();
        string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png"));
        _bodyPath = "map/" + stem + ".png";

        if (!File.Exists(full))
        {
            Logger.Write("body", "not found: map/" + stem + ".png");
            ListFolderOnce("map");
            return null;
        }

        return Load(full);
    }

    private static void ListFolderOnce(string rel)
    {
        if (_listed) return;
        _listed = true;

        string dir = SoundWindow.AppFile(rel);
        if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; }

        foreach (string f in Directory.GetFiles(dir))
            Logger.Write("body", "found: " + Path.GetFileName(f));
    }

    private string BodyStem()
    {
        bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active);

        if (skeleton)
        {
            switch (_view)
            {
                case "back":  return "jacketBackBody";
                case "right": return "jacketRightBody";
                case "left":  return "jacketLeftBody";
                default:      return "jacketFrontBody";
            }
        }

        switch (_view)
        {
            case "back":  return "jacketBack";
            case "right": return "jacketRight";
            case "left":  return "jacketLeft";
            default:      return "jacketFront";
        }
    }

    private string MapPathForView()
    {
        if (_case == null) return null;

        string rel;
        switch (_view)
        {
            case "back":  rel = _case.MapBack;  break;
            case "right": rel = _case.MapRight; break;
            case "left":  rel = _case.MapLeft;  break;
            default:      rel = _case.MapFront; break;
        }

        // Form2 falls back to a mirrored front map when there's no dedicated back.
        if (string.IsNullOrEmpty(rel)) rel = _case.MapFront;

        return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel);
    }

    private static Gdk.Pixbuf Load(string path)
    {
        if (string.IsNullOrEmpty(path) || !File.Exists(path))
        {
            Logger.Write("map", "image not found: " + path);
            return null;
        }

        try { return new Gdk.Pixbuf(path); }
        catch (Exception ex)
        {
            Logger.Write("map", "load failed " + path + ": " + ex.Message);
            return null;
        }
    }

    private Gdk.Pixbuf LoadMap(string path)
    {
        _mapPath = path ?? "(none)";

        var raw = Load(path);
        if (raw == null) return null;

        // Key out black FIRST, then scale. Scaling first leaves near-black
        // edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo.
        var keyed = raw.AddAlpha(true, 0, 0, 0);
        raw.Dispose();

        // Keep the keyed original for FitBody mode — the calibrator never
        // resized to 546, so its zoom numbers refer to THIS pixbuf.
        _mapRaw = keyed.Copy();

        var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear);
        keyed.Dispose();
        return sized;
    }

    // ── plumbing ───────────────────────────────────────────────────────

    private void OnBackClicked(object sender, EventArgs e)
    {
        StopSession();
        var h = BackRequested;
        if (h != null) h(this, EventArgs.Empty);
    }

    private void DisposePixbufs()
    {
        if (_body != null)   { _body.Dispose();   _body = null; }
        if (_map != null)    { _map.Dispose();    _map = null; }
        if (_mapRaw != null) { _mapRaw.Dispose(); _mapRaw = null; }
    }

    public void Dispose()
    {
        _arduino.LineReceived      -= OnArduinoLine;
        _arduino.ConnectionChanged -= OnArduinoConnectionChanged;
        _sound.Dispose();
        DisposePixbufs();
        if (_tuner != null) { _tuner.Destroy(); _tuner = null; }
    }

    private string JacketSize
    {
        get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; }
    }

    private string DetermineViewFromZ(float z)
    {
        float frontDiff = Math.Abs(z - FRONT_Z);
        float backDiff  = Math.Abs(z - BACK_Z);
        float rightDiff = Math.Abs(z - RIGHT_Z);
        float leftDiff  = Math.Abs(z - LEFT_Z);

        float minDiff = Math.Min(Math.Min(frontDiff, backDiff), Math.Min(rightDiff, leftDiff));
        if (minDiff > Z_TOLERANCE) return _view;   // no clean match — stay put

        if (minDiff == frontDiff) return "front";
        if (minDiff == backDiff)  return "back";
        if (minDiff == rightDiff) return "right";
        return "left";
    }

    /// Drives the same radio buttons a manual click would, so the UI and
    /// _view stay in sync (SetView no-ops if it's already the active view).
    private void SwitchViewFromArduino(string view)
    {
        RadioButton rb = view == "back"  ? ViewBackRadio
                        : view == "right" ? ViewRightRadio
                        : view == "left"  ? ViewLeftRadio
                        : ViewFrontRadio;
        if (rb != null) rb.Active = true;
        else SetView(view);
    }
}