// Package runfile manages running.json — the PID + port handshake the Electron
// main process uses to discover, health-check, and reap the daemon. The daemon
// writes it on startup and removes it on graceful shutdown. On startup the
// daemon also checks for a stale entry left by a crashed predecessor so it can
// fail fast instead of fighting over the port.
package runfile

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/aoagents/agent-orchestrator/backend/internal/processalive"
)

// Info is the on-disk handshake payload.
type Info struct {
	// PID is the daemon process id.
	PID int `json:"pid"`
	// Port is the loopback port the daemon bound.
	Port int `json:"port"`
	// StartedAt is when the daemon came up (RFC 3339).
	StartedAt time.Time `json:"startedAt"`
	// Owner is "app" when the desktop Electron app spawned this daemon; empty
	// for a headless `ao start` daemon. Used by the app to decide whether to
	// hold a supervisor link on attach (app-owned: re-link; headless: skip so
	// the daemon stays persistent across app quit).
	Owner string `json:"owner,omitempty"`
}

// Write atomically writes running.json at path, creating parent directories
// as needed. It writes to a temp file in the same directory and then calls
// atomicReplace — POSIX rename(2) on Unix, MoveFileEx with
// MOVEFILE_REPLACE_EXISTING on Windows — so a reader never observes a
// partial file and a stale running.json from a crashed predecessor is
// overwritten without an intermediate "no file" window.
func Write(path string, info Info) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
		return fmt.Errorf("create run-file dir: %w", err)
	}
	data, err := json.MarshalIndent(info, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal run-file: %w", err)
	}
	data = append(data, '\n')

	tmp, err := os.CreateTemp(filepath.Dir(path), ".running-*.json")
	if err != nil {
		return fmt.Errorf("create temp run-file: %w", err)
	}
	tmpName := tmp.Name()
	defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds

	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("write temp run-file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temp run-file: %w", err)
	}
	if err := atomicReplace(tmpName, path); err != nil {
		return fmt.Errorf("replace run-file: %w", err)
	}
	return nil
}

// Read loads running.json. A missing file returns (nil, nil) — that is the
// normal "no daemon recorded" state, not an error.
func Read(path string) (*Info, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("read run-file: %w", err)
	}
	var info Info
	if err := json.Unmarshal(data, &info); err != nil {
		return nil, fmt.Errorf("parse run-file: %w", err)
	}
	return &info, nil
}

// Remove deletes running.json. A missing file is not an error — graceful
// shutdown should be idempotent.
func Remove(path string) error {
	if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("remove run-file: %w", err)
	}
	return nil
}

// RemoveIfOwned deletes running.json only if it still belongs to ownerPID. This
// prevents a shutting-down daemon from removing a successor's freshly written
// handshake after an overlapping restart.
func RemoveIfOwned(path string, ownerPID int) error {
	info, err := Read(path)
	if err != nil {
		return err
	}
	if info == nil || info.PID != ownerPID {
		return nil
	}
	return Remove(path)
}

// CheckStale inspects an existing run-file before the new daemon binds. It
// returns:
//
//   - (nil, nil)        no run-file, or one left by a dead process (safe to
//     proceed; the caller should overwrite it);
//   - (*Info, nil)      a run-file whose recorded PID is still alive — a live
//     daemon already owns the port, so the caller should fail fast.
//
// A run-file pointing at a dead PID is treated as stale and reported safe; the
// fresh Write will overwrite it.
func CheckStale(path string) (*Info, error) {
	info, err := Read(path)
	if err != nil {
		return nil, err
	}
	if info == nil || info.PID <= 0 {
		return nil, nil
	}
	if processalive.Alive(info.PID) {
		return info, nil
	}
	return nil, nil
}
