#!/usr/bin/env python3
"""Watery Autocommand: a visible home for Lighthouse automation."""

from __future__ import annotations

import json
import os
import pathlib
import signal
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from typing import Any

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk  # noqa: E402


CODEX = "/usr/lib/chatgpt/resources/codex"
CODEX_HOME = pathlib.Path("/home/io/.codex")
WORKING_DIR = pathlib.Path("/home/io/.local/share/lighthouse")
LIGHTHOUSE_STATE = pathlib.Path("/home/io/.local/state/lighthouse/state.json")
ROOT = pathlib.Path("/home/io/.local/state/lighthouse/autocommand")
REQUESTS = ROOT / "requests"
RESULTS = ROOT / "results"
PRESENCE = ROOT / "online.json"
THREAD_STATE = ROOT / "thread.json"
TRANSCRIPT = ROOT / "transcript.log"


def load_json(path: pathlib.Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return default


def atomic_json(path: pathlib.Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as stream:
            json.dump(value, stream, separators=(",", ":"), sort_keys=True)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        os.chmod(temporary, 0o600)
        os.replace(temporary, path)
    finally:
        try:
            os.unlink(temporary)
        except FileNotFoundError:
            pass


def walk_values(value: Any):
    yield value
    if isinstance(value, dict):
        for child in value.values():
            yield from walk_values(child)
    elif isinstance(value, list):
        for child in value:
            yield from walk_values(child)


def extract_thread_id(value: Any) -> str | None:
    for item in walk_values(value):
        if not isinstance(item, dict):
            continue
        for key in ("thread_id", "threadId", "session_id", "sessionId"):
            identifier = item.get(key)
            if isinstance(identifier, str) and len(identifier) >= 16:
                return identifier
    return None


def extract_tokens(value: Any) -> dict[str, Any] | None:
    latest = None
    for item in walk_values(value):
        if isinstance(item, dict) and any(key in item for key in ("input_tokens", "output_tokens", "total_tokens")):
            latest = {
                key: item.get(key)
                for key in ("input_tokens", "cached_input_tokens", "output_tokens", "total_tokens")
                if key in item
            }
    return latest


def countdown(epoch: Any) -> str:
    try:
        seconds = max(0, int(epoch) - int(time.time()))
    except (TypeError, ValueError):
        return "--:--"
    hours, remainder = divmod(seconds, 3600)
    minutes, _ = divmod(remainder, 60)
    return f"{hours}:{minutes:02d}"


class Autocommand(Gtk.Application):
    def __init__(self) -> None:
        super().__init__(application_id="io.watery.Autocommand")
        self.window: Gtk.ApplicationWindow | None = None
        self.transcript_buffer: Gtk.TextBuffer | None = None
        self.running = False
        self.labels: dict[str, Gtk.Label] = {}
        ROOT.mkdir(parents=True, exist_ok=True)
        REQUESTS.mkdir(parents=True, exist_ok=True)
        RESULTS.mkdir(parents=True, exist_ok=True)
        os.chmod(ROOT, 0o700)

    def do_activate(self) -> None:
        if self.window:
            self.window.present()
            return
        self._install_css()
        self.window = Gtk.ApplicationWindow(application=self)
        self.window.set_title("Watery Autocommand")
        self.window.set_default_size(1180, 720)
        self.window.set_icon_name("system-run-symbolic")
        self.window.connect("delete-event", self._on_close)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        outer.get_style_context().add_class("autocommand-root")
        self.window.add(outer)

        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        header.get_style_context().add_class("autocommand-header")
        title = Gtk.Label(label="WATERY AUTOCOMMAND")
        title.set_xalign(0)
        title.get_style_context().add_class("autocommand-title")
        subtitle = Gtk.Label(label="visible automation · one persistent Codex thread")
        subtitle.set_xalign(1)
        subtitle.get_style_context().add_class("autocommand-subtitle")
        header.pack_start(title, True, True, 0)
        header.pack_end(subtitle, False, False, 0)
        outer.pack_start(header, False, False, 0)

        panes = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
        panes.set_position(410)
        outer.pack_start(panes, True, True, 0)
        panes.pack1(self._build_status_panel(), resize=False, shrink=False)
        panes.pack2(self._build_transcript_panel(), resize=True, shrink=False)

        self._load_transcript()
        self._refresh_status()
        self._write_presence()
        # Presence is only a liveness hint, not a precision clock. A 90-second
        # heartbeat keeps Lighthouse informed without forcing the filesystem
        # journal to commit a tiny JSON file every two seconds.
        GLib.timeout_add_seconds(90, self._write_presence)
        GLib.timeout_add_seconds(3, self._refresh_status)
        GLib.timeout_add(750, self._poll_requests)
        self.window.maximize()
        self.window.show_all()

    def _install_css(self) -> None:
        css = b"""
        .autocommand-root { background: #07131f; color: #f4fbff; }
        .autocommand-header { background: #10243a; border-bottom: 3px solid #27e5d1; padding: 16px 20px; }
        .autocommand-title { color: #7fffee; font: italic bold 25px 'Ubuntu Sans'; }
        .autocommand-subtitle { color: #ff8ddd; font: bold 16px 'Ubuntu Sans'; }
        .status-panel { background: #0a1928; border-right: 2px solid #355775; padding: 20px; }
        .panel-heading { color: #ffcf5a; font: italic bold 22px 'Ubuntu Sans'; margin-bottom: 12px; }
        .status-name { color: #90aeca; font: bold 15px 'Ubuntu Sans'; margin-top: 10px; }
        .status-value { color: #ffffff; font: italic bold 24px 'Ubuntu Sans'; }
        .status-good { color: #73f5bd; }
        .status-accent { color: #63dcff; }
        .status-pink { color: #ff79cf; }
        .status-detail { color: #d2e2ef; font: bold 16px 'Ubuntu Sans'; margin-top: 12px; }
        .transcript-panel { background: #050d16; padding: 16px; }
        .transcript-heading { color: #63dcff; font: italic bold 22px 'Ubuntu Sans'; margin-bottom: 10px; }
        textview, textview text { background: #050d16; color: #eaf7ff; font: 16px 'Ubuntu Mono'; }
        scrollbar slider { min-width: 14px; min-height: 14px; background: #3e718b; }
        """
        provider = Gtk.CssProvider()
        provider.load_from_data(css)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
        )

    def _build_status_panel(self) -> Gtk.Widget:
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        box.get_style_context().add_class("status-panel")
        heading = Gtk.Label(label="AUTOMATION STATUS")
        heading.set_xalign(0)
        heading.get_style_context().add_class("panel-heading")
        box.pack_start(heading, False, False, 0)
        for key, name, style in (
            ("online", "WORKER", "status-good"),
            ("five", "5-HOUR CAPACITY", "status-accent"),
            ("weekly", "WEEKLY CAPACITY", "status-pink"),
            ("next", "NEXT HEARTBEAT", "status-good"),
            ("thread", "CODEX THREAD", "status-accent"),
        ):
            label = Gtk.Label(label=name)
            label.set_xalign(0)
            label.get_style_context().add_class("status-name")
            value = Gtk.Label(label="--")
            value.set_xalign(0)
            value.set_line_wrap(True)
            value.get_style_context().add_class("status-value")
            value.get_style_context().add_class(style)
            box.pack_start(label, False, False, 0)
            box.pack_start(value, False, False, 0)
            self.labels[key] = value
        detail = Gtk.Label(label="Waiting for Lighthouse…")
        detail.set_xalign(0)
        detail.set_yalign(0)
        detail.set_line_wrap(True)
        detail.get_style_context().add_class("status-detail")
        box.pack_start(detail, True, True, 0)
        self.labels["detail"] = detail
        return box

    def _build_transcript_panel(self) -> Gtk.Widget:
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        box.get_style_context().add_class("transcript-panel")
        heading = Gtk.Label(label="LIVE AUTOCOMMAND TRANSCRIPT")
        heading.set_xalign(0)
        heading.get_style_context().add_class("transcript-heading")
        box.pack_start(heading, False, False, 0)
        scroll = Gtk.ScrolledWindow()
        scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        view = Gtk.TextView()
        view.set_editable(False)
        view.set_cursor_visible(False)
        view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        view.set_left_margin(10)
        view.set_right_margin(10)
        view.set_top_margin(10)
        view.set_bottom_margin(10)
        self.transcript_buffer = view.get_buffer()
        scroll.add(view)
        box.pack_start(scroll, True, True, 0)
        return box

    def _load_transcript(self) -> None:
        try:
            lines = TRANSCRIPT.read_text(encoding="utf-8", errors="replace").splitlines()[-300:]
            text = "\n".join(lines)
        except OSError:
            text = "Watery Autocommand is online.\nWaiting for Lighthouse."
        if self.transcript_buffer:
            self.transcript_buffer.set_text(text + "\n")

    def _append(self, message: str) -> None:
        stamp = datetime.now().strftime("%H:%M:%S")
        line = f"[{stamp}] {message}"
        try:
            with TRANSCRIPT.open("a", encoding="utf-8") as stream:
                stream.write(line + "\n")
            lines = TRANSCRIPT.read_text(encoding="utf-8", errors="replace").splitlines()[-500:]
            TRANSCRIPT.write_text("\n".join(lines) + "\n", encoding="utf-8")
        except OSError:
            pass
        if self.transcript_buffer:
            end = self.transcript_buffer.get_end_iter()
            self.transcript_buffer.insert(end, line + "\n")

    def _write_presence(self) -> bool:
        thread = load_json(THREAD_STATE, {})
        atomic_json(PRESENCE, {"at": int(time.time()), "pid": os.getpid(), "thread_id": thread.get("thread_id")})
        return True

    def _refresh_status(self) -> bool:
        state = load_json(LIGHTHOUSE_STATE, {})
        usage = state.get("usage") if isinstance(state.get("usage"), dict) else {}
        five = usage.get("five_hour") if isinstance(usage.get("five_hour"), dict) else {}
        weekly = usage.get("weekly") if isinstance(usage.get("weekly"), dict) else {}
        thread = load_json(THREAD_STATE, {})
        self.labels["online"].set_text("ONLINE · VISIBLE")
        self.labels["five"].set_text(f"{round(float(five.get('remaining_percent') or 0))}%  ·  {countdown(five.get('resets_at'))}")
        self.labels["weekly"].set_text(f"{round(float(weekly.get('remaining_percent') or 0))}%  ·  {countdown(weekly.get('resets_at'))}")
        last_success = int(state.get("last_successful_ping_at") or 0)
        self.labels["next"].set_text(datetime.fromtimestamp(last_success + 5400).strftime("%-I:%M %p") if last_success else "READY")
        thread_id = str(thread.get("thread_id") or "not created yet")
        turns = int(thread.get("turns") or 0)
        self.labels["thread"].set_text(f"{thread_id[:13]}…  ·  {turns} turns" if thread.get("thread_id") else thread_id)
        self.labels["detail"].set_text(str(state.get("last_decision") or "Waiting for Lighthouse."))
        return True

    def _poll_requests(self) -> bool:
        if self.running:
            return True
        pending = sorted(REQUESTS.glob("*.json"), key=lambda path: path.stat().st_mtime)
        if not pending:
            return True
        path = pending[0]
        working = path.with_suffix(".working")
        try:
            os.replace(path, working)
            request = load_json(working, {})
        except OSError as error:
            self._append(f"QUEUE ERROR · {error}")
            return True
        self.running = True
        threading.Thread(target=self._execute_request, args=(request, working), daemon=True).start()
        return True

    def _execute_request(self, request: dict[str, Any], working: pathlib.Path) -> None:
        request_id = str(request.get("id") or working.stem)
        prompt = str(request.get("prompt") or "Say okay, just okay")
        reason = str(request.get("reason") or "scheduled automation")
        model = str(request.get("model") or "gpt-5.6-luna")
        effort = str(request.get("effort") or "low")
        thread = load_json(THREAD_STATE, {})
        thread_id = thread.get("thread_id") if isinstance(thread.get("thread_id"), str) else None
        reply_fd, reply_name = tempfile.mkstemp(prefix="autocommand-reply-", suffix=".txt", dir=ROOT)
        os.close(reply_fd)
        reply_path = pathlib.Path(reply_name)
        started = time.monotonic()
        GLib.idle_add(self._append, f"REQUEST · {reason} · Luna {effort}")
        GLib.idle_add(self._append, f"YOU > {prompt}")
        if thread_id:
            command = [
                CODEX, "exec", "resume", "--json", "--skip-git-repo-check",
                "--model", model, "--config", f'model_reasoning_effort="{effort}"',
                "--output-last-message", str(reply_path), thread_id, prompt,
            ]
        else:
            command = [
                CODEX, "exec", "--json", "--color", "never", "--skip-git-repo-check",
                "--model", model, "--config", f'model_reasoning_effort="{effort}"',
                "--sandbox", "read-only", "-C", str(WORKING_DIR),
                "--output-last-message", str(reply_path), prompt,
            ]
        found_thread = thread_id
        tokens = None
        returncode = 1
        error = None
        try:
            completed = subprocess.run(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                env={**os.environ, "CODEX_HOME": str(CODEX_HOME)},
                start_new_session=True,
                timeout=45,
                check=False,
            )
            for line in completed.stdout.splitlines():
                try:
                    event = json.loads(line)
                except ValueError:
                    continue
                found_thread = found_thread or extract_thread_id(event)
                tokens = extract_tokens(event) or tokens
            returncode = completed.returncode
            reply = reply_path.read_text(encoding="utf-8", errors="replace").strip()[:500]
            success = returncode == 0 and bool(reply)
            if not success:
                error = f"Codex exited {returncode}; reply={reply!r}"
        except Exception as exc:
            reply = ""
            success = False
            error = str(exc)
        duration = round(time.monotonic() - started, 2)
        if found_thread:
            atomic_json(THREAD_STATE, {
                "thread_id": found_thread,
                "turns": int(thread.get("turns") or 0) + (1 if success else 0),
                "updated_at": int(time.time()),
            })
        result = {
            "id": request_id,
            "success": success,
            "reply": reply,
            "tokens": tokens,
            "returncode": returncode,
            "duration_seconds": duration,
            "error": error,
            "thread_id": found_thread,
        }
        atomic_json(RESULTS / f"{request_id}.json", result)
        working.unlink(missing_ok=True)
        reply_path.unlink(missing_ok=True)
        GLib.idle_add(self._append, f"CODEX > {reply or error or '(no answer)'}")
        token_text = "unknown"
        if isinstance(tokens, dict):
            token_text = f"{tokens.get('input_tokens', '?')} in · {tokens.get('cached_input_tokens', '?')} cached · {tokens.get('output_tokens', '?')} out"
        GLib.idle_add(self._append, f"RESULT · {'DELIVERED' if success else 'FAILED'} · {duration:.1f}s · {token_text}")
        GLib.idle_add(self._append, "────────────────────────────────────────────────────────")
        self.running = False
        GLib.idle_add(self._write_presence)
        GLib.idle_add(self._refresh_status)

    def _on_close(self, *_args) -> bool:
        PRESENCE.unlink(missing_ok=True)
        return False


if __name__ == "__main__":
    raise SystemExit(Autocommand().run(None))
