#!/usr/bin/env python3
#--------------------------------------------
# Description: Lite Widget - Desktop Overlay
# GTK4 desktop information widget for Linux Lite
# Authors: Jerry Bezencon
# Website: https://www.linuxliteos.com
# License: GPLv2
#--------------------------------------------

import gi
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf

import gettext as _gt, locale as _loc
TEXTDOMAIN = "lite-widget"
try:
    _loc.setlocale(_loc.LC_ALL, "")
except _loc.Error:
    pass
_gt.bindtextdomain(TEXTDOMAIN, "/usr/share/locale")
_gt.textdomain(TEXTDOMAIN)
_ = _gt.translation(TEXTDOMAIN, "/usr/share/locale", fallback=True).gettext


import subprocess
import os
import re
import gzip
import socket
from datetime import datetime
from pathlib import Path

LOGO_PATH = '/etc/lite-widget/logo.png'
LLVER_PATH = '/etc/llver'
APT_LOG_DIR = Path('/var/log/apt/')
APT_LOG_FILE = 'history.log'

CSS = b"""
window.overlay-widget {
    background-color: transparent;
}

.widget-frame {
    background-color: rgba(0, 0, 0, 0.6);
    border-radius: 25px;
    padding: 20px 24px;
    min-width: 280px;
}

.widget-title {
    color: #ffe082;
    font-family: "Noto Sans";
    font-size: 18px;
    font-weight: bold;
}

.widget-section-title {
    color: #ffe082;
    font-family: "Noto Sans";
    font-size: 15px;
    font-weight: bold;
}

.widget-text {
    color: white;
    font-family: "Noto Sans";
    font-size: 13px;
}

.widget-date {
    color: white;
    font-family: "Noto Sans";
    font-size: 13px;
}

.widget-separator {
    color: #708090;
    min-height: 2px;
    margin-top: 6px;
    margin-bottom: 6px;
}

.status-green {
    color: #9fee62;
}

.status-yellow {
    color: #eae11c;
}

.status-red {
    color: #ff4343;
}
"""


class SystemInfo:
    """Gathers system information for the widget display."""

    _prev_idle = None
    _prev_total = None

    @staticmethod
    def get_cpu_usage():
        try:
            with open('/proc/stat') as f:
                line = f.readline()
            parts = line.split()
            idle = int(parts[4])
            total = sum(int(p) for p in parts[1:])

            if SystemInfo._prev_idle is None:
                SystemInfo._prev_idle = idle
                SystemInfo._prev_total = total
                return 0

            d_idle = idle - SystemInfo._prev_idle
            d_total = total - SystemInfo._prev_total
            SystemInfo._prev_idle = idle
            SystemInfo._prev_total = total

            if d_total == 0:
                return 0
            return round((1.0 - d_idle / d_total) * 100)
        except Exception:
            return 0

    @staticmethod
    def get_memory_info():
        try:
            with open('/proc/meminfo') as f:
                lines = f.readlines()
            info = {}
            for line in lines:
                parts = line.split()
                key = parts[0].rstrip(':')
                info[key] = int(parts[1])  # in kB

            total = info.get('MemTotal', 0)
            available = info.get('MemAvailable', 0)
            used = total - available

            def fmt(kb):
                if kb >= 1048576:
                    return f"{kb / 1048576:.1f} GiB"
                elif kb >= 1024:
                    return f"{kb / 1024:.0f} MiB"
                return f"{kb} KiB"

            return fmt(total), fmt(used)
        except Exception:
            return "N/A", "N/A"

    @staticmethod
    def get_username():
        return os.environ.get('USER', 'unknown')

    @staticmethod
    def get_firewall_status():
        try:
            result = subprocess.run(
                ['systemctl', 'is-active', 'firewalld'],
                capture_output=True, text=True, timeout=5
            )
            if result.stdout.strip() == 'active':
                return 'enabled', 'green'
            else:
                return 'disabled', 'red'
        except Exception:
            return 'unknown', 'red'

    @staticmethod
    def get_disk_usage():
        try:
            st = os.statvfs('/')
            total = st.f_blocks * st.f_frsize
            used = (st.f_blocks - st.f_bfree) * st.f_frsize

            def fmt(b):
                if b >= 1073741824:
                    return f"{b / 1073741824:.1f} GiB"
                elif b >= 1048576:
                    return f"{b / 1048576:.0f} MiB"
                return f"{b} B"

            return f"{fmt(used)} / {fmt(total)}"
        except Exception:
            return "N/A"

    @staticmethod
    def get_uptime():
        try:
            with open('/proc/uptime') as f:
                seconds = int(float(f.read().split()[0]))
            days, remainder = divmod(seconds, 86400)
            hours, remainder = divmod(remainder, 3600)
            minutes = remainder // 60
            if days > 0:
                return f"{days}d {hours}h {minutes}m"
            elif hours > 0:
                return f"{hours}h {minutes}m"
            return f"{minutes}m"
        except Exception:
            return "N/A"

    @staticmethod
    def get_network_info():
        try:
            with open('/proc/net/route') as f:
                for line in f:
                    parts = line.split()
                    if parts[1] == '00000000':
                        iface = parts[0]
                        break
                else:
                    return _("Not connected"), "red"
            # Get IP address for the default interface
            import fcntl
            import struct
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            ip = socket.inet_ntoa(fcntl.ioctl(
                sock.fileno(), 0x8915,
                struct.pack('256s', iface[:15].encode())
            )[20:24])
            sock.close()
            return f"{iface} ({ip})", "green"
        except Exception:
            return _("Not connected"), "red"

    @staticmethod
    def get_weather():
        try:
            import urllib.request
            req = urllib.request.Request(
                'https://wttr.in/?format=%l:+%C+%t',
                headers={'User-Agent': 'LiteWidget/1.0'}
            )
            with urllib.request.urlopen(req, timeout=5) as resp:
                data = resp.read().decode('utf-8').strip()
            # data is like "City: Partly cloudy +22°C"
            if ':' in data:
                location, conditions = data.split(':', 1)
                loc = ', '.join(
                    p.upper() if len(p) <= 3 else p.title()
                    for p in location.strip().split(', ')
                )
                return loc, conditions.strip()
            return "", data
        except Exception:
            return "", _("Unavailable")

    @staticmethod
    def get_battery_info():
        try:
            ps_path = Path('/sys/class/power_supply')
            if not ps_path.exists():
                return None
            has_battery = any(
                d.name.startswith('BAT')
                and (d / 'type').read_text().strip().lower() == 'battery'
                for d in ps_path.iterdir() if (d / 'type').exists()
            )
            if not has_battery:
                return None
            result = subprocess.run(
                ['acpi'], capture_output=True, text=True, timeout=5
            )
            output = result.stdout.strip()
            if output:
                parts = output.split(': ', 1)
                if len(parts) > 1:
                    return parts[1]
            return None
        except Exception:
            return None

    @staticmethod
    def get_os_version():
        try:
            with open(LLVER_PATH) as f:
                return f.read().strip()
        except Exception:
            return "Linux Lite"

    @staticmethod
    def get_update_status():
        """Check when the system was last updated via APT logs."""
        updated_at = None
        log_path = APT_LOG_DIR / APT_LOG_FILE

        # Check main log file
        try:
            if log_path.exists():
                content = log_path.read_text()
                blocks = content.split('\n\n')
                for block in blocks:
                    if 'Upgrade:' in block:
                        match = re.search(
                            r'End-Date:\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})',
                            block
                        )
                        if match:
                            dt = datetime.strptime(
                                match.group(1), '%Y-%m-%d  %H:%M:%S'
                            )
                            if updated_at is None or dt > updated_at:
                                updated_at = dt
        except Exception:
            pass

        # Check rotated log files if needed
        if updated_at is None:
            n = 1
            while True:
                gz_path = APT_LOG_DIR / f"{APT_LOG_FILE}.{n}.gz"
                if not gz_path.exists():
                    break
                try:
                    with gzip.open(gz_path, 'rt') as f:
                        content = f.read()
                    blocks = content.split('\n\n')
                    for block in blocks:
                        if 'Upgrade:' in block:
                            match = re.search(
                                r'End-Date:\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})',
                                block
                            )
                            if match:
                                dt = datetime.strptime(
                                    match.group(1), '%Y-%m-%d  %H:%M:%S'
                                )
                                if updated_at is None or dt > updated_at:
                                    updated_at = dt
                except Exception:
                    pass
                n += 1

        if updated_at is None:
            return _("System never updated"), "red"

        now = datetime.now()
        diff = now - updated_at
        hours = int(diff.total_seconds() / 3600)

        if hours < 1:
            return _("Your system was recently updated"), "green"
        elif hours < 25:
            unit = "hour" if hours == 1 else "hrs"
            return f"{hours} {unit} since your last update", "green"
        else:
            days = hours // 24
            unit = "day" if days == 1 else "days"
            msg = f"{days} {unit} since your last update"
            if days < 4:
                return msg, "green"
            elif days < 8:
                return msg, "yellow"
            else:
                return msg, "red"


class WidgetWindow(Gtk.Window):
    def __init__(self, app):
        super().__init__(application=app)
        self.set_title("LiteWidgetOverlay")
        self.set_icon_name("liteicon")
        self.set_decorated(False)
        self.set_resizable(False)
        self.set_default_size(310, -1)
        self.add_css_class('overlay-widget')

        # Main frame with rounded background
        self.frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self.frame.add_css_class('widget-frame')

        self._build_ui()
        self.set_child(self.frame)

        # Periodic updates
        GLib.timeout_add_seconds(1, self._update_fast)
        GLib.timeout_add_seconds(60, self._update_battery)
        GLib.timeout_add_seconds(300, self._update_slow)
        GLib.timeout_add_seconds(900, self._update_weather)

        # Initial updates — one-shot (the periodic versions return True for
        # timeout_add_seconds, which idle_add would interpret as "re-fire on
        # every idle cycle", spawning runaway weather fetches and flickering).
        def _do_initial_updates():
            self._update_slow()
            self._update_battery()
            self._update_weather()
            return False
        GLib.idle_add(_do_initial_updates)

        # Position and set WM hints after window is shown
        self.connect('realize', self._on_realize)

    def _build_ui(self):
        # OS Version title
        version = SystemInfo.get_os_version()
        self.version_label = Gtk.Label(label=version)
        self.version_label.add_css_class('widget-title')
        self.frame.append(self.version_label)

        # Date and time
        self.datetime_label = Gtk.Label()
        self.datetime_label.add_css_class('widget-date')
        self._update_datetime()
        self.frame.append(self.datetime_label)

        # Separator
        sep1 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        sep1.add_css_class('widget-separator')
        self.frame.append(sep1)

        # System Information section
        sys_title = Gtk.Label(label="System Information")
        sys_title.add_css_class('widget-section-title')
        sys_title.set_margin_top(4)
        self.frame.append(sys_title)

        spacer1 = Gtk.Box()
        spacer1.set_size_request(-1, 4)
        self.frame.append(spacer1)

        # Info rows
        self.cpu_row = self._make_info_row(_("CPU Usage:"), "0%")
        self.frame.append(self.cpu_row['box'])

        self.mem_total_row = self._make_info_row(_("Memory Total:"), "")
        self.frame.append(self.mem_total_row['box'])

        self.mem_used_row = self._make_info_row(_("Memory Used:"), "")
        self.frame.append(self.mem_used_row['box'])

        self.user_row = self._make_info_row(
            _("Logged in as:"), SystemInfo.get_username()
        )
        self.frame.append(self.user_row['box'])

        self.fw_row = self._make_info_row(_("Firewall Status:"), _("checking..."))
        self.frame.append(self.fw_row['box'])

        self.disk_row = self._make_info_row(_("Disk Usage:"), "")
        self.frame.append(self.disk_row['box'])

        self.uptime_row = self._make_info_row(_("Uptime:"), "")
        self.frame.append(self.uptime_row['box'])

        self.net_row = self._make_info_row(_("Network:"), _("checking..."))
        self.frame.append(self.net_row['box'])

        # Battery (only shown if available)
        self.battery_label = Gtk.Label()
        self.battery_label.add_css_class('widget-text')
        self.battery_label.set_margin_top(4)
        self.battery_label.set_visible(False)
        self.frame.append(self.battery_label)

        # Separator
        sep2 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        sep2.add_css_class('widget-separator')
        self.frame.append(sep2)

        # Weather section
        self.weather_title = Gtk.Label(label=_("Weather"))
        self.weather_title.add_css_class('widget-section-title')
        self.weather_title.set_margin_top(4)
        self.frame.append(self.weather_title)

        spacer2 = Gtk.Box()
        spacer2.set_size_request(-1, 4)
        self.frame.append(spacer2)

        self.weather_label = Gtk.Label(label=_("Checking..."))
        self.weather_label.add_css_class('widget-text')
        self.weather_label.set_wrap(True)
        self.frame.append(self.weather_label)

        # Separator
        sep3 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        sep3.add_css_class('widget-separator')
        self.frame.append(sep3)

        # Update Status section
        upd_title = Gtk.Label(label=_("Update Status"))
        upd_title.add_css_class('widget-section-title')
        upd_title.set_margin_top(4)
        self.frame.append(upd_title)

        spacer3 = Gtk.Box()
        spacer3.set_size_request(-1, 4)
        self.frame.append(spacer3)

        self.update_label = Gtk.Label(label=_("Checking..."))
        self.update_label.add_css_class('widget-text')
        self.frame.append(self.update_label)

        # Logo (footer branding)
        try:
            if os.path.exists(LOGO_PATH):
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                    LOGO_PATH, 42, 100, True
                )
                # Pixbuf -> Gdk.Texture via PNG bytes (Gdk.Texture.new_for_pixbuf
                # is deprecated in GTK 4.20+; bytes round-trip uses no deprecated API).
                ok, png_data = pixbuf.save_to_bufferv("png", [], [])
                texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(png_data))
                logo = Gtk.Picture.new_for_paintable(texture)
                logo.set_size_request(pixbuf.get_width(), pixbuf.get_height())
                logo.set_content_fit(Gtk.ContentFit.SCALE_DOWN)
                logo.set_margin_top(8)
                logo.set_margin_bottom(4)
                logo.set_halign(Gtk.Align.CENTER)
                logo.set_vexpand(False)
                logo.set_hexpand(False)
                self.frame.append(logo)
        except Exception:
            pass

    def _make_info_row(self, label_text, value_text):
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        box.set_hexpand(True)

        label = Gtk.Label(label=label_text, xalign=0)
        label.add_css_class('widget-text')
        label.set_hexpand(True)

        value = Gtk.Label(label=value_text, xalign=1)
        value.add_css_class('widget-text')

        box.append(label)
        box.append(value)
        return {'box': box, 'label': label, 'value': value}

    def _update_datetime(self):
        now = datetime.now()
        self.datetime_label.set_label(
            now.strftime('%A, %-d %B %Y - %H:%M')
        )

    def _update_fast(self):
        """Update fast-changing info (every second)."""
        self._update_datetime()

        cpu = SystemInfo.get_cpu_usage()
        self.cpu_row['value'].set_label(f"{cpu}%")

        total, used = SystemInfo.get_memory_info()
        self.mem_total_row['value'].set_label(total)
        self.mem_used_row['value'].set_label(used)

        self.uptime_row['value'].set_label(SystemInfo.get_uptime())

        return True  # keep repeating

    def _update_slow(self):
        """Update slow-changing info (every 5 minutes)."""
        # Firewall
        fw_status, fw_color = SystemInfo.get_firewall_status()
        self.fw_row['value'].set_label(fw_status)
        for cls in ('status-green', 'status-red', 'status-yellow'):
            self.fw_row['value'].remove_css_class(cls)
        self.fw_row['value'].add_css_class(f'status-{fw_color}')

        # Disk usage
        self.disk_row['value'].set_label(SystemInfo.get_disk_usage())

        # Network
        net_info, net_color = SystemInfo.get_network_info()
        self.net_row['value'].set_label(net_info)
        for cls in ('status-green', 'status-red', 'status-yellow'):
            self.net_row['value'].remove_css_class(cls)
        self.net_row['value'].add_css_class(f'status-{net_color}')

        # Updates
        upd_msg, upd_color = SystemInfo.get_update_status()
        self.update_label.set_label(upd_msg)
        for cls in ('status-green', 'status-red', 'status-yellow'):
            self.update_label.remove_css_class(cls)
        self.update_label.add_css_class(f'status-{upd_color}')

        return True  # keep repeating

    def _update_battery(self):
        """Update battery info (every 1 minute)."""
        battery = SystemInfo.get_battery_info()
        if battery:
            self.battery_label.set_label(battery)
            self.battery_label.set_visible(True)
        else:
            self.battery_label.set_visible(False)
        return True  # keep repeating

    def _update_weather(self):
        """Update weather info (every 30 minutes) in background thread."""
        import threading

        def fetch():
            location, conditions = SystemInfo.get_weather()
            GLib.idle_add(self._apply_weather, location, conditions)

        thread = threading.Thread(target=fetch, daemon=True)
        thread.start()
        return True  # keep repeating

    def _apply_weather(self, location, conditions):
        if location:
            self.weather_title.set_label(_("Weather — {location}").format(location=location))
        self.weather_label.set_label(conditions)
        return False

    def _on_realize(self, widget):
        # _NET_WM_WINDOW_TYPE_UTILITY set BEFORE map — xfwm4 honours UTILITY
        # for skip_taskbar/skip_pager (panel sees the type at MapNotify, never
        # lists us), and UTILITY sits in its own stacking layer ABOVE the
        # DESKTOP layer. This avoids the trap of sharing DESKTOP with
        # xfdesktop's wallpaper window: when the user clicks on the desktop,
        # xfdesktop re-raises itself within DESKTOP and would bury us if we
        # were also DESKTOP-typed. UTILITY + the `below` state below pins us
        # below normal windows but above the wallpaper.
        surface = self.get_surface()
        try:
            xid = surface.get_xid()
            subprocess.run(
                ['xprop', '-id', str(xid),
                 '-f', '_NET_WM_WINDOW_TYPE', '32a',
                 '-set', '_NET_WM_WINDOW_TYPE', '_NET_WM_WINDOW_TYPE_UTILITY'],
                capture_output=True, timeout=5
            )
        except Exception:
            pass
        self._wm_attempts = 0
        GLib.timeout_add(1000, self._apply_wm_hints)

    def _apply_wm_hints(self):
        title = "LiteWidgetOverlay"
        self._wm_attempts += 1

        try:
            check = subprocess.run(
                ['wmctrl', '-l'], capture_output=True, text=True, timeout=5
            )
            if title not in check.stdout:
                if self._wm_attempts < 10:
                    return True  # retry in 1s
                return False  # give up cleanly — don't position a window we can't address
        except FileNotFoundError:
            return False

        display = Gdk.Display.get_default()
        monitors = display.get_monitors()
        if monitors.get_n_items() > 0:
            monitor = monitors.get_item(0)
            geo = monitor.get_geometry()
            gap_x = 50
            gap_y = 65
            win_w = 310
            win_h = self.get_height() or 400
            x = geo.x + geo.width - win_w - gap_x
            y = geo.y + geo.height - win_h - gap_y

            subprocess.run(
                ['wmctrl', '-r', title, '-e', f'0,{x},{y},-1,-1'],
                capture_output=True, timeout=5
            )

        self._set_wm_state_flags(title)

        # Re-apply state flags every 30s — xfdesktop --reload and xfwm4 restarts
        # can re-stack the window; re-asserting nudges xfwm4 to put it back below.
        GLib.timeout_add_seconds(30, self._reapply_wm_state)
        return False

    def _set_wm_state_flags(self, title):
        subprocess.run(
            ['wmctrl', '-r', title, '-b', 'add,sticky,below,skip_taskbar'],
            capture_output=True, timeout=5
        )
        subprocess.run(
            ['wmctrl', '-r', title, '-b', 'add,skip_pager'],
            capture_output=True, timeout=5
        )

    def _reapply_wm_state(self):
        self._set_wm_state_flags("LiteWidgetOverlay")
        return True


class LiteWidgetOverlayApp(Gtk.Application):
    def __init__(self):
        super().__init__(
            application_id='com.linuxliteos.litewidget.overlay',
            flags=Gio.ApplicationFlags.FLAGS_NONE
        )

    def do_startup(self):
        Gtk.Application.do_startup(self)
        css_provider = Gtk.CssProvider()
        css_provider.load_from_data(CSS)
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(),
            css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

    def do_activate(self):
        win = self.props.active_window
        if not win:
            win = WidgetWindow(self)
        win.present()


def main():
    app = LiteWidgetOverlayApp()
    app.run()


if __name__ == '__main__':
    main()
