// Package applog routes the app's diagnostic output to a rotating log
// file inside the user's data dir. Wails builds with the Windows GUI
// subsystem by default — fmt.Println output is dropped, so launching
// from cmd never showed anything. The file gives us a reliable place to
// inspect what the UDP listener / cluster / CAT layer is doing.
package applog

import (
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"runtime/debug"
	"sync"
	"time"
)

var (
	mu        sync.Mutex
	file      *os.File
	path      string
	written   int64    // bytes in the CURRENT file, for the size check in Printf
	crashFile *os.File // kept open so the runtime can write a crash traceback to it
)

// maxLogBytes is where the file rolls over, at startup AND while running.
// A variable, not a constant, so the rotation can be exercised in a test without
// writing ten megabytes to do it.
var maxLogBytes int64 = 10 * 1024 * 1024

// Init opens (creates) the log file in dataDir. On rotation we truncate
// at startup if the file is too big; for now it's a single file, no
// rolling — the volume is low (a few KB per session).
func Init(dataDir string) (string, error) {
	mu.Lock()
	defer mu.Unlock()

	if file != nil {
		return path, nil
	}
	if dataDir == "" {
		return "", fmt.Errorf("empty data dir")
	}
	if err := os.MkdirAll(dataDir, 0o755); err != nil {
		return "", err
	}
	logPath := filepath.Join(dataDir, "opslog.log")
	// One-shot rename for users coming from the HamLog era.
	if _, err := os.Stat(logPath); os.IsNotExist(err) {
		oldLog := filepath.Join(dataDir, "hamlog.log")
		if _, err := os.Stat(oldLog); err == nil {
			_ = os.Rename(oldLog, logPath)
		}
	}
	// Rotate (don't delete) once the file grows past maxLogBytes: rename it to
	// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
	// to erase exactly the diagnostics we needed when a user reported an issue from
	// the session that just ended. One generation kept — enough, without unbounded growth.
	if fi, err := os.Stat(logPath); err == nil && fi.Size() > maxLogBytes {
		_ = os.Remove(logPath + ".1") // drop the older generation
		if err := os.Rename(logPath, logPath+".1"); err != nil {
			_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
		}
	}
	f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return "", err
	}
	file = f
	path = logPath

	// Capture a full traceback on a FATAL crash (a Go panic that escapes our
	// recover()s, or a runtime-fatal error like a concurrent map write, or a
	// Windows access violation routed through the Go signal handler) into a
	// dedicated file the runtime writes directly — so otherwise-silent process
	// deaths leave a stack we can read.
	if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
		os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
		crashFile = cf
		_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
	}

	// Redirect log.Print* and the standard logger to the file too, so
	// any third-party output stays consistent.
	log.SetOutput(io.MultiWriter(file, os.Stderr))
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)

	fmt.Fprintf(file, "\n────── OpsLog start %s ──────\n", time.Now().Format(time.RFC3339))
	return logPath, nil
}

// Printf writes a formatted line with a timestamp. Caller's format may
// or may not end with a newline — we strip a trailing one before adding
// our own, so log entries always look like "HH:MM:SS.mmm msg\n".
func Printf(format string, args ...any) {
	mu.Lock()
	defer mu.Unlock()
	stamp := time.Now().Format("15:04:05.000")
	msg := fmt.Sprintf(format, args...)
	for len(msg) > 0 && (msg[len(msg)-1] == '\n' || msg[len(msg)-1] == '\r') {
		msg = msg[:len(msg)-1]
	}
	if file != nil {
		n, _ := fmt.Fprintf(file, "%s %s\n", stamp, msg)
		written += int64(n)
		rotateIfBigLocked()
	}
	// Also dump to stderr in case the binary was launched with a console
	// attached (wails dev, custom build).
	fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
}

// rotateIfBigLocked rolls the file over mid-session once it passes maxLogBytes.
//
// The check used to happen at STARTUP only, on the reasonable belief that a
// session writes a few kilobytes. The CI-V wire trace disproved it: left on for
// a day it wrote 416 MB, and nothing would have stopped it before the disk did.
// A log that has to be rotated by quitting the program is not a bound.
//
// Same one-generation scheme as the startup rotation, so the previous stretch
// survives — which is the half an operator usually needs, the part just before
// the thing they are reporting.
//
// Caller holds mu.
func rotateIfBigLocked() {
	if written < maxLogBytes || file == nil || path == "" {
		return
	}
	fmt.Fprintf(file, "%s ── log reached %d MB, rotating to %s.1 ──\n",
		time.Now().Format("15:04:05.000"), maxLogBytes/(1024*1024), filepath.Base(path))
	_ = file.Close()
	file = nil
	_ = os.Remove(path + ".1")
	if err := os.Rename(path, path+".1"); err != nil {
		// Rename refused (the file is held open elsewhere, a virus scanner): keep
		// writing to the old handle rather than losing the log entirely, and try
		// again in another maxLogBytes rather than on every single line.
		f, oerr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
		if oerr == nil {
			file = f
		}
		written = 0
		return
	}
	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
	if err != nil {
		return // nothing more to be done; the next Printf simply writes nowhere
	}
	file = f
	written = 0
	fmt.Fprintf(file, "%s ── continued from %s.1 ──\n",
		time.Now().Format("15:04:05.000"), filepath.Base(path))
}

// Path returns where the file is so the UI can surface it.
func Path() string {
	mu.Lock()
	defer mu.Unlock()
	return path
}

// Close flushes and releases the handle. Called from shutdown.
func Close() {
	mu.Lock()
	defer mu.Unlock()
	if file != nil {
		_ = file.Close()
		file = nil
	}
	// The crash file is held open for the whole run so the runtime can dump a
	// traceback into it without allocating; on the way out it has to be released
	// like any other handle, or the data directory cannot be moved or removed.
	if crashFile != nil {
		// The RUNTIME holds its own duplicate of this handle (SetCrashOutput), so
		// closing ours releases nothing on Windows — the data directory stays
		// locked. Hand the runtime a nil first. Anything fatal in the last moments
		// of shutdown is no longer captured, which is the right trade at a point
		// where the databases are already closed.
		_ = debug.SetCrashOutput(nil, debug.CrashOptions{})
		_ = crashFile.Close()
		crashFile = nil
	}
	path = ""
	written = 0
}
