// EventSaver - shopfloor event-advert screensaver.
// Two source modes, set in EventSaver.ini next to the .scr (no recompile):
//   url=https://.../shopdb/api/slides/feed?surface=shopfloor  -> pull from shopdb over
//       HTTP, cache images locally, rotate the cache (no file share needed).
//   folder=\\server\share\path                      -> read an SMB/local folder.
// url wins if both set. Strict order + per-slide seconds via order.txt (or the
// API's slides[].seconds). Cache survives a network blip (keeps last-good).
//
// Screensaver arg contract:
//   /s          show (fullscreen)
//   /p <hwnd>   preview (we no-op - keeps Windows happy)
//   /c          config (points user at the ini)
//
// Build (in-box .NET Framework, no SDK):
//   C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
//     /target:winexe /out:EventSaver.scr ^
//     /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^
//     EventSaver.cs

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Web.Script.Serialization;
using System.Windows.Forms;

namespace EventSaver
{
    internal static class Program
    {
        [STAThread]
        private static void Main(string[] args)
        {
            string mode = "/s";
            if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim();
            // strip a trailing ":hwnd" some callers append (e.g. /p:12345)
            if (mode.StartsWith("/p")) mode = "/p";
            if (mode.StartsWith("/c")) mode = "/c";
            if (mode.StartsWith("/s")) mode = "/s";

            if (mode == "/test")
            {
                // headless self-test: print the resolved playlist order and exit.
                // lets CI / a display-less VM verify folder-read + order.txt + sort.
                // winexe has no console in session 0, so write results to a
                // file next to the exe (and Console too, for interactive runs).
                Config tc = Config.Load();
                string tf = tc.SourceFolder(true);   // http mode: sync cache first
                List<Slide> pl = Playlist.Build(tf, tc.Shuffle);
                List<string> lines = new List<string>();
                lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + "  cache=" : "folder=") + tf);
                lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle);
                lines.Add("count=" + pl.Count);
                for (int i = 0; i < pl.Count; i++)
                    lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds));
                foreach (string l in lines) Console.WriteLine(l);
                try
                {
                    string outDir = Path.GetDirectoryName(Application.ExecutablePath);
                    File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray());
                }
                catch { }
                return;
            }
            if (mode == "/render")
            {
                // headless render check: draw the first slide onto a 1280x720
                // black canvas with the same fit logic as OnPaint, save a PNG.
                // Lets a display-less VM prove decode + letterbox actually work.
                Config rc = Config.Load();
                List<Slide> rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle);
                string outPng = args.Length > 1 ? args[1] : Path.Combine(
                    Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png");
                using (Bitmap canvas = new Bitmap(1280, 720))
                using (Graphics g = Graphics.FromImage(canvas))
                {
                    g.Clear(Color.Black);
                    if (rpl.Count > 0)
                    {
                        using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                        using (Image img = Image.FromStream(fs))
                        {
                            g.InterpolationMode = InterpolationMode.HighQualityBicubic;
                            Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size);
                            g.DrawImage(img, r);
                        }
                    }
                    canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png);
                }
                return;
            }
            if (mode == "/c")
            {
                MessageBox.Show(
                    "Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.",
                    "EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information);
                return;
            }
            if (mode == "/p")
            {
                // preview pane - do nothing, exit clean
                return;
            }

            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            Config cfg = Config.Load();

            // one form per screen: primary shows slideshow, others stay black
            List<Form> forms = new List<Form>();
            Screen primary = Screen.PrimaryScreen;
            foreach (Screen scr in Screen.AllScreens)
            {
                bool isPrimary = scr.Equals(primary);
                SaverForm f = new SaverForm(scr, isPrimary ? cfg : null);
                forms.Add(f);
            }
            foreach (Form f in forms) f.Show();

            // Keep the monitor awake while the screensaver shows, so a shorter
            // monitor-sleep policy can't blank the ads out from under us. Held
            // for the life of the message loop, released on exit.
            SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
            Application.Run(forms[0]);
            SetThreadExecutionState(ES_CONTINUOUS);
        }

        [DllImport("kernel32.dll")]
        private static extern uint SetThreadExecutionState(uint esFlags);
        private const uint ES_CONTINUOUS = 0x80000000;
        private const uint ES_DISPLAY_REQUIRED = 0x00000002;
        private const uint ES_SYSTEM_REQUIRED = 0x00000001;
    }

    // ------------------------------------------------------------------ config
    internal sealed class Config
    {
        // No default: a share path belongs to a site, not to this program
        // (ADR-015). Folder mode is the fallback for a site with no HTTP
        // reach to ShopDB, and it must name its own path in EventSaver.ini.
        public string Folder = "";
        public string Url = "";            // set -> HTTP mode (pull from shopdb)
        public string CacheDir = "";       // local cache for HTTP mode (computed)
        public int IntervalSeconds = 10;
        public bool Shuffle = false;
        public int FadeMs = 600;

        public static Config Load()
        {
            Config c = new Config();
            try
            {
                string dir = Path.GetDirectoryName(Application.ExecutablePath);
                string ini = Path.Combine(dir, "EventSaver.ini");
                if (!File.Exists(ini)) return c;

                foreach (string raw in File.ReadAllLines(ini))
                {
                    string line = raw.Trim();
                    if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
                    int eq = line.IndexOf('=');
                    if (eq <= 0) continue;
                    string key = line.Substring(0, eq).Trim().ToLowerInvariant();
                    string val = line.Substring(eq + 1).Trim();

                    if (key == "url" && val.Length > 0) c.Url = val;
                    else if (key == "folder" && val.Length > 0) c.Folder = val;
                    else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; }
                    else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true");
                    else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; }
                }
            }
            catch { /* bad ini - fall back to defaults */ }
            return c;
        }

        // Folder the playlist reads: the local cache in HTTP mode (synced first
        // when sync=true), else the configured share/folder. HTTP failures leave
        // the last-good cache in place.
        public string SourceFolder(bool sync)
        {
            if (Url.Length == 0) return Folder;
            if (CacheDir.Length == 0)
                CacheDir = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                    "EventSaver", "cache");
            if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } }
            return CacheDir;
        }
    }

    // ------------------------------------------------------------- http sync
    // Pull the slide list from shopdb (/api/slides/feed), download images into a
    // local cache dir, and write order.txt there so the normal Playlist logic
    // reads the cache exactly like a folder. Idempotent: only downloads images
    // not already cached, prunes ones no longer listed, keeps last-good on error.
    internal static class HttpSync
    {
        public static void Sync(string apiUrl, string cacheDir)
        {
            try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { }
            if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir);

            string json;
            using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); }

            JavaScriptSerializer js = new JavaScriptSerializer();
            IDictionary<string, object> root = js.DeserializeObject(json) as IDictionary<string, object>;
            if (root == null) return;
            object ok;
            if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return;
            string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : "";
            object slidesObj;
            if (!root.TryGetValue("slides", out slidesObj)) return;
            object[] arr = slidesObj as object[];
            if (arr == null) return;

            Uri apiUri = new Uri(apiUrl);
            // The feed's basepath is host-absolute (/api/slides/img/...) and omits
            // the app's mount (e.g. /shopdb) - the web client adds it via withBase,
            // so we must too, else images resolve to the host root and 404. Derive
            // the mount from the feed URL's path (everything before "/api/").
            string mount = "";
            int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase);
            if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx);
            HashSet<string> keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            List<string> order = new List<string>();
            order.Add("# generated by EventSaver from " + apiUrl);

            foreach (object o in arr)
            {
                IDictionary<string, object> s = o as IDictionary<string, object>;
                if (s == null) continue;
                string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null;
                if (string.IsNullOrEmpty(fn)) continue;
                string safe = Path.GetFileName(fn);            // strip any path component
                if (safe.Length == 0) continue;
                int secs = 0;
                if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; }

                string local = Path.Combine(cacheDir, safe);
                if (!File.Exists(local))
                {
                    try
                    {
                        // Host-absolute basepath -> prepend the mount; a full URL passes through.
                        string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath;
                        Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe));
                        using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); }
                    }
                    catch { continue; }   // couldn't fetch this one - skip it this round
                }
                keep.Add(safe);
                order.Add(secs > 0 ? safe + "|" + secs : safe);
            }

            try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { }

            // prune cache images no longer referenced
            foreach (string f in Directory.GetFiles(cacheDir))
            {
                string n = Path.GetFileName(f);
                if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue;
                if (!keep.Contains(n)) { try { File.Delete(f); } catch { } }
            }
        }
    }

    // ------------------------------------------------------------- playlist
    // Builds ordered file list. order.txt wins (strict sequence, one name per
    // line, optional "name|seconds" per-slide duration). Else sort by name.
    internal sealed class Slide
    {
        public string Path;
        public int Seconds; // 0 = use default interval
    }

    internal static class Playlist
    {
        private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };

        public static List<Slide> Build(string folder, bool shuffle)
        {
            List<Slide> list = new List<Slide>();
            try
            {
                if (!Directory.Exists(folder)) return list;

                string orderFile = Path.Combine(folder, "order.txt");
                if (File.Exists(orderFile))
                {
                    // strict sequence from order.txt
                    foreach (string raw in File.ReadAllLines(orderFile))
                    {
                        string line = raw.Trim();
                        if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
                        int secs = 0;
                        string name = line;
                        int bar = line.IndexOf('|');
                        if (bar > 0)
                        {
                            name = line.Substring(0, bar).Trim();
                            int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n;
                        }
                        string full = Path.Combine(folder, name);
                        if (IsImage(full) && File.Exists(full))
                            list.Add(new Slide { Path = full, Seconds = secs });
                    }
                    return list; // order.txt is authoritative - do not append extras
                }

                // no order.txt - all images, sorted by filename
                List<string> files = new List<string>();
                foreach (string f in Directory.GetFiles(folder))
                    if (IsImage(f)) files.Add(f);
                files.Sort(StringComparer.OrdinalIgnoreCase);
                if (shuffle) Shuf(files);
                foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 });
            }
            catch { /* share unreachable - return what we have (maybe empty) */ }
            return list;
        }

        private static bool IsImage(string path)
        {
            string e = Path.GetExtension(path).ToLowerInvariant();
            foreach (string x in Exts) if (x == e) return true;
            return false;
        }

        // deterministic-enough shuffle; screensaver so exact randomness irrelevant
        private static void Shuf(List<string> l)
        {
            Random r = new Random();
            for (int i = l.Count - 1; i > 0; i--)
            {
                int j = r.Next(i + 1);
                string t = l[i]; l[i] = l[j]; l[j] = t;
            }
        }
    }

    // ------------------------------------------------------------- saver form
    internal sealed class SaverForm : Form
    {
        private readonly Config cfg;       // null on non-primary screens (black only)
        private readonly Timer timer;
        private List<Slide> slides = new List<Slide>();
        private int idx = -1;
        private Image current;
        private Point lastMouse = Point.Empty;
        private bool mouseSeen = false;
        private DateTime lastScan = DateTime.MinValue;

        public SaverForm(Screen screen, Config c)
        {
            cfg = c;
            FormBorderStyle = FormBorderStyle.None;
            Bounds = screen.Bounds;
            StartPosition = FormStartPosition.Manual;
            BackColor = Color.Black;
            TopMost = true;
            ShowInTaskbar = false;
            DoubleBuffered = true;
            Cursor.Hide();

            KeyPreview = true;
            // Left/Right step through the slides by hand; ANY other key still
            // wakes the machine, which is what a screensaver must do. Without
            // that exception an operator tapping an arrow to get back to work
            // would be stuck watching slides.
            KeyDown += (s, e) =>
            {
                if (e.KeyCode == Keys.Left)  { e.Handled = true; Step(-1); return; }
                if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1);  return; }
                Quit();
            };
            MouseDown += (s, e) => Quit();
            MouseMove += OnMove;

            if (cfg != null)
            {
                cfg.SourceFolder(true);   // HTTP mode: initial sync + set CacheDir
                Rescan();
                timer = new Timer();
                timer.Interval = 1000; // tick every second; advance when slide's time is up
                timer.Tick += OnTick;
                timer.Start();
                Advance(); // show first immediately
            }
        }

        private int slideElapsed = 0;
        private DateTime lastSync = DateTime.Now;   // ctor already did the first sync
        private void OnTick(object sender, EventArgs e)
        {
            // HTTP mode: re-pull from shopdb every 60s so manager edits propagate.
            if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60)
            {
                lastSync = DateTime.Now;
                try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { }
                lastScan = DateTime.MinValue;   // force the rescan below
            }
            // periodic rescan so edits appear without restarting the saver
            if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan();

            slideElapsed++;
            int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0)
                ? slides[idx].Seconds : cfg.IntervalSeconds;
            if (slideElapsed >= want) Advance();
        }

        private void Rescan()
        {
            lastScan = DateTime.Now;
            List<Slide> fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle);
            slides = fresh;
            if (idx >= slides.Count) idx = -1;
        }

        private void Advance()
        {
            Step(1);
        }

        // delta of +1 is the timer advancing, -1 is the operator going back.
        // Resets the dwell timer either way: stepping by hand and then having it
        // move again a moment later, because the tick was nearly up, reads as
        // the screensaver ignoring the keypress.
        private void Step(int delta)
        {
            slideElapsed = 0;
            if (slides.Count == 0) { SetImage(null); return; }
            if (idx < 0) idx = (delta < 0) ? 0 : -1;   // first Step lands on slide 0
            idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count;
            try
            {
                // load without locking the file on the share
                Image img;
                using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                    img = Image.FromStream(fs);
                SetImage(img);
            }
            catch { SetImage(null); }
        }

        private void SetImage(Image img)
        {
            Image old = current;
            current = img;
            if (old != null) old.Dispose();
            Invalidate();
        }

        protected override void OnPaint(PaintEventArgs e)
        {
            e.Graphics.Clear(Color.Black);
            if (current == null) return;
            e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
            Rectangle r = FitZoom(current.Size, ClientSize);
            e.Graphics.DrawImage(current, r);
        }

        // test hook - same math as OnPaint, exposed for the /render self-check.
        public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); }

        // scale image to fit while preserving aspect (letterbox)
        private static Rectangle FitZoom(Size img, Size box)
        {
            if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height);
            double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height);
            int w = (int)(img.Width * s);
            int h = (int)(img.Height * s);
            return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h);
        }

        private void OnMove(object sender, MouseEventArgs e)
        {
            // ignore the first synthetic move; require real movement to exit
            if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; }
            if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit();
        }

        private void Quit()
        {
            try { Cursor.Show(); } catch { }
            Application.Exit();
        }
    }
}
