// EON Intel agent — suit les logs de chat d'EVE et envoie les lignes brutes à l'API intel.
//
//   - Canaux d'intel : prend le fichier le plus récemment écrit de chaque canal (« I. Ftn Intel_20261010_160554_<charid>.txt »)
//     et envoie chaque nouvelle ligne telle quelle (horodatage, auteur, message). Aucune analyse ici : tout se fait côté serveur.
//   - Local (facultatif) : lit seulement les lignes « EVE System > Channel changed to Local : X » pour savoir où sont tes persos.
//     Les messages des joueurs en Local ne sont jamais envoyés.
//
// Réglages : petite page web locale (http://127.0.0.1:47821) ouverte au premier lancement : dossier des logs (avec
// « Parcourir… »), canaux trouvés dans le dossier (cases à cocher), URL + jeton (ou le fichier téléchargé depuis Réglages).
//
// Sans dépendance (bibliothèque standard Go). Build : voir build.sh / README.
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode/utf16"
)

const Version = "1.2.0"

type Config struct {
	LogsDir     string   `json:"logs_dir"`               // vide = Documents\EVE\logs\Chatlogs (OneDrive compris)
	Channels    []string `json:"channels"`               // noms des canaux d'intel, tels que dans le nom du fichier
	Local       bool     `json:"local"`                  // suivre la position de tes persos via les logs Local
	LocalNames  []string `json:"local_names"`            // nom du canal Local selon la langue du client
	APIURL      string   `json:"api_url"`                // ex. https://ddbuiq.tech/eon/intel/api.php
	Token       string   `json:"token"`                  // jeton créé dans Intel → Réglages → Agents
	PollMS      int      `json:"poll_ms"`                // lecture des fichiers
	ScanSeconds int      `json:"scan_seconds"`           // recherche d'un nouveau fichier
	MaxAgeHours int      `json:"max_age_hours"`          // fichiers plus vieux ignorés (hors -import)
	Quiet       bool     `json:"quiet"`                  // n'affiche pas chaque ligne
	MapperURL   string   `json:"mapper_url,omitempty"`   // EON Mapper (facultatif) : api.php du mapper, reçoit seulement les positions
	MapperToken string   `json:"mapper_token,omitempty"` // jeton créé dans Mapper → Settings → Agents
}

type Line struct {
	Ch  string `json:"ch"`
	TS  string `json:"ts"` // « 2026.10.10 16:07:07 » (heure EVE = UTC)
	Who string `json:"who"`
	Msg string `json:"msg"`
}

type Loc struct {
	CharID   string `json:"char_id"`
	Listener string `json:"listener"`
	TS       string `json:"ts"`
	System   string `json:"system"`
}

type Payload struct {
	V        string `json:"v"`
	Listener string `json:"listener,omitempty"`
	Lines    []Line `json:"lines"`
	Locs     []Loc  `json:"locs"`
}

// Un fichier suivi : position lue, encodage, reste de ligne incomplète, en-tête.
type tail struct {
	path     string
	off      int64
	enc      string // "utf16" | "utf8" | "" (pas encore vu)
	rest     []byte // octets UTF-16 impairs en attente
	partial  string // ligne pas encore terminée
	channel  string
	listener string
	charID   string
}

var (
	reLine   = regexp.MustCompile(`^\x{feff}*\[ (\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2}) \] (.*?) > (.*)$`)
	reHeader = regexp.MustCompile(`^\s*(Channel Name|Listener|Session started|Channel ID):\s*(.*?)\s*$`)
	reFile   = regexp.MustCompile(`^(.+)_(\d{8})_(\d{6})(?:_(\d+))?\.txt$`)
)

func main() {
	cfgPath := flag.String("config", "", "fichier de config (défaut : intel-agent.json à côté de l'exe)")
	dry := flag.Bool("dry", false, "n'envoie rien, affiche seulement")
	once := flag.Bool("once", false, "lit l'existant, envoie, puis s'arrête")
	imp := flag.Bool("import", false, "envoie TOUS les anciens fichiers des canaux (historique), puis s'arrête")
	setup := flag.Bool("setup", false, "ouvre la page de réglages dans le navigateur")
	noui := flag.Bool("noui", false, "pas de page de réglages locale")
	flag.Parse()

	path := *cfgPath
	if path == "" {
		exe, _ := os.Executable()
		path = filepath.Join(filepath.Dir(exe), "intel-agent.json")
	}
	cfg, created, err := loadConfig(path)
	if err != nil {
		fatal("config : %v", err)
	}
	a := &agent{cfg: cfg, cfgPath: path, dry: *dry, tails: map[string]*tail{}, client: &http.Client{Timeout: 15 * time.Second}, reload: make(chan *Config, 1)}
	a.applyConfig(cfg)
	fmt.Printf("EON Intel agent %s\n", Version)
	a.printConfig()
	if *imp || *once {
		if !dirOK(a.logsDir()) {
			fatal("dossier des logs introuvable : %s (règle logs_dir dans %s)", a.logsDir(), path)
		}
		if *imp {
			a.importAll()
			a.flush(true)
			return
		}
		a.loop(true)
		return
	}
	if !*noui {
		url := a.startUI()
		if url != "" {
			fmt.Printf("  réglages : %s\n\n", url)
			if *setup || created || cfg.Token == "" || cfg.APIURL == "" || !dirOK(a.logsDir()) || len(cfg.Channels) == 0 {
				openBrowser(url)
			}
		}
	}
	a.loop(false)
}

func dirOK(d string) bool { st, err := os.Stat(d); return err == nil && st.IsDir() }

// Dossier des logs effectif (logs_dir vide = dossier par défaut d'EVE).
func (a *agent) logsDir() string {
	if a.cfg.LogsDir != "" {
		return a.cfg.LogsDir
	}
	return defaultLogsDir()
}

func (a *agent) applyConfig(c *Config) {
	a.cfg = c
	a.send = !a.dry && c.APIURL != "" && c.Token != ""
}

func (a *agent) printConfig() {
	fmt.Printf("  logs    : %s%s\n  canaux  : %s\n  local   : %v\n  envoi   : %s\n", a.logsDir(), map[bool]string{true: "", false: "  (INTROUVABLE)"}[dirOK(a.logsDir())],
		strings.Join(a.cfg.Channels, ", "), a.cfg.Local, map[bool]string{true: a.cfg.APIURL, false: "non (pas d'URL / de jeton : affichage seul)"}[a.send])
	if a.cfg.MapperURL != "" && a.cfg.MapperToken != "" {
		fmt.Printf("  mapper  : %s (positions seulement)\n", a.cfg.MapperURL)
	}
}

func saveConfig(path string, c *Config) error {
	out, _ := json.MarshalIndent(c, "", "  ")
	return os.WriteFile(path, out, 0600)
}

/* ---------- Config ---------- */

func loadConfig(path string) (*Config, bool, error) {
	cfg := &Config{Channels: []string{"I. Ftn Intel"}, Local: true, LocalNames: []string{"Local", "Lokal", "Локальный"}, PollMS: 500, ScanSeconds: 3, MaxAgeHours: 24}
	b, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		out, _ := json.MarshalIndent(cfg, "", "  ")
		if werr := os.WriteFile(path, out, 0600); werr != nil {
			return nil, false, werr
		}
		return cfg, true, nil
	}
	if err != nil {
		return nil, false, err
	}
	b = bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF}) // Bloc-notes ajoute parfois un BOM
	if err := json.Unmarshal(b, cfg); err != nil {
		return nil, false, fmt.Errorf("%s : JSON invalide (%v). Attention aux \\ : écris C:\\\\Users\\\\… ou C:/Users/…", path, err)
	}
	if cfg.PollMS < 100 {
		cfg.PollMS = 500
	}
	if cfg.ScanSeconds < 1 {
		cfg.ScanSeconds = 3
	}
	if cfg.MaxAgeHours < 1 {
		cfg.MaxAgeHours = 24
	}
	cfg.LogsDir = os.ExpandEnv(strings.ReplaceAll(cfg.LogsDir, "%USERPROFILE%", os.Getenv("USERPROFILE")))
	return cfg, false, nil
}

func defaultLogsDir() string {
	home := os.Getenv("USERPROFILE")
	if home == "" {
		home, _ = os.UserHomeDir()
	}
	cands := []string{
		filepath.Join(home, "Documents", "EVE", "logs", "Chatlogs"),
		filepath.Join(home, "OneDrive", "Documents", "EVE", "logs", "Chatlogs"),
		filepath.Join(home, "OneDrive", "Documents", "EVE", "logs", "Chatlogs"),
	}
	if od := os.Getenv("OneDrive"); od != "" {
		cands = append(cands, filepath.Join(od, "Documents", "EVE", "logs", "Chatlogs"), filepath.Join(od, "Documents", "EVE", "logs", "Chatlogs"))
	}
	for _, c := range cands {
		if st, err := os.Stat(c); err == nil && st.IsDir() {
			return c
		}
	}
	return cands[0]
}

/* ---------- Boucle ---------- */

type agent struct {
	cfg     *Config
	cfgPath string
	dry     bool
	reload  chan *Config
	send    bool
	tails   map[string]*tail // par chemin
	current map[string]string
	queue   []Line
	locs    map[string]Loc // dernière position par perso
	sentLoc map[string]string
	client  *http.Client
	backoff time.Duration
	nextTry time.Time
	sent    int

	mSent    map[string]string // positions déjà envoyées au mapper
	mBackoff time.Duration
	mNext    time.Time
	mLast    time.Time
	mErr     string

	status    *agent     // import lancé depuis la page : l'avancement s'affiche sur l'agent principal
	mu        sync.Mutex // état affiché par la page de réglages
	lastSend  time.Time
	lastErr   string
	following []string
	importing string
}

func (a *agent) loop(once bool) {
	a.locs = map[string]Loc{}
	a.sentLoc = map[string]string{}
	a.mSent = map[string]string{}
	var lastScan time.Time
	var watched []string
	for {
		select {
		case c := <-a.reload: // nouvelle config depuis la page de réglages : on repart de zéro (le serveur ignore les doublons)
			a.mu.Lock()
			a.applyConfig(c)
			a.mu.Unlock()
			a.tails, a.current, a.queue = map[string]*tail{}, nil, nil
			a.backoff, a.nextTry = 0, time.Time{}
			a.mBackoff, a.mNext = 0, time.Time{}
			fmt.Printf("\n%s  nouvelle config\n", now())
			a.printConfig()
			lastScan = time.Time{}
		default:
		}
		if time.Since(lastScan) >= time.Duration(a.cfg.ScanSeconds)*time.Second {
			watched = a.pick()
			lastScan = time.Now()
		}
		for _, p := range watched {
			a.read(a.tails[p])
		}
		a.flush(once)
		if once {
			return
		}
		time.Sleep(time.Duration(a.cfg.PollMS) * time.Millisecond)
	}
}

type fileInfo struct {
	path, channel, charID string
	mod                   time.Time
}

// Liste les fichiers de logs utiles (canaux d'intel + Local), plus récents que max_age_hours si maxAge.
func (a *agent) list(maxAge bool) []fileInfo {
	dir := a.logsDir()
	ents, err := os.ReadDir(dir)
	if err != nil {
		a.setErr("dossier des logs illisible : " + err.Error())
		return nil
	}
	var out []fileInfo
	limit := time.Now().Add(-time.Duration(a.cfg.MaxAgeHours) * time.Hour)
	for _, e := range ents {
		m := reFile.FindStringSubmatch(e.Name())
		if m == nil || e.IsDir() {
			continue
		}
		ch := m[1]
		if !a.isChannel(ch) && !(a.cfg.Local && a.isLocal(ch)) {
			continue
		}
		fi, err := e.Info()
		if err != nil || (maxAge && fi.ModTime().Before(limit)) {
			continue
		}
		out = append(out, fileInfo{filepath.Join(dir, e.Name()), ch, m[4], fi.ModTime()})
	}
	return out
}

func (a *agent) isChannel(ch string) bool {
	for _, c := range a.cfg.Channels {
		if strings.EqualFold(c, ch) {
			return true
		}
	}
	return false
}
func (a *agent) isLocal(ch string) bool {
	for _, c := range a.cfg.LocalNames {
		if strings.EqualFold(c, ch) {
			return true
		}
	}
	return false
}

// Choisit les fichiers à suivre : le plus récemment écrit par canal d'intel, et par perso pour Local.
func (a *agent) pick() []string {
	best := map[string]fileInfo{}
	for _, f := range a.list(true) {
		key := f.channel
		if a.isLocal(f.channel) {
			key = "local:" + f.charID
		}
		b, ok := best[key]
		if !ok || f.mod.After(b.mod) || (f.mod.Equal(b.mod) && f.path > b.path) {
			best[key] = f
		}
	}
	if a.current == nil {
		a.current = map[string]string{}
	}
	var out, names []string
	keys := make([]string, 0, len(best))
	for k := range best {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		f := best[k]
		if a.current[k] != f.path {
			fmt.Printf("%s suivi : %s\n", now(), filepath.Base(f.path))
			a.current[k] = f.path
		}
		if _, ok := a.tails[f.path]; !ok {
			a.tails[f.path] = &tail{path: f.path, channel: f.channel, charID: f.charID}
		}
		out = append(out, f.path)
		names = append(names, filepath.Base(f.path))
	}
	a.mu.Lock()
	a.following = names
	a.mu.Unlock()
	return out
}

func (a *agent) importAll() {
	a.locs = map[string]Loc{}
	a.sentLoc = map[string]string{}
	files := a.list(false)
	sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
	n := 0
	for _, f := range files {
		if a.isLocal(f.channel) {
			continue
		}
		t := &tail{path: f.path, channel: f.channel, charID: f.charID}
		a.read(t)
		n++
		st := a
		if a.status != nil {
			st = a.status
		}
		st.mu.Lock()
		st.importing = fmt.Sprintf("%d / %d fichiers", n, len(files))
		st.mu.Unlock()
		if len(a.queue) >= 2000 {
			a.flush(true)
		}
	}
	fmt.Printf("%d fichiers lus pour l'import.\n", n)
}

/* ---------- Lecture d'un fichier ---------- */

func (a *agent) read(t *tail) {
	f, err := os.Open(t.path)
	if err != nil {
		return
	}
	defer f.Close()
	st, err := f.Stat()
	if err != nil {
		return
	}
	if st.Size() < t.off { // fichier tronqué / recréé
		t.off, t.rest, t.partial, t.enc = 0, nil, "", ""
	}
	if st.Size() == t.off {
		return
	}
	if _, err := f.Seek(t.off, io.SeekStart); err != nil {
		return
	}
	buf, err := io.ReadAll(io.LimitReader(f, 8<<20))
	if err != nil || len(buf) == 0 {
		return
	}
	t.off += int64(len(buf))
	if t.enc == "" {
		switch {
		case len(buf) >= 2 && buf[0] == 0xFF && buf[1] == 0xFE:
			t.enc, buf = "utf16", buf[2:]
		case len(buf) >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF:
			t.enc, buf = "utf8", buf[3:]
		case len(buf) >= 4 && buf[1] == 0 && buf[3] == 0:
			t.enc = "utf16"
		default:
			t.enc = "utf8"
		}
	}
	var text string
	if t.enc == "utf16" {
		buf = append(t.rest, buf...)
		n := len(buf) &^ 1
		t.rest = append([]byte(nil), buf[n:]...)
		u := make([]uint16, n/2)
		for i := range u {
			u[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8
		}
		text = string(utf16.Decode(u))
	} else {
		text = string(buf)
	}
	text = t.partial + text
	parts := strings.Split(text, "\n")
	t.partial = parts[len(parts)-1]
	for _, raw := range parts[:len(parts)-1] {
		a.line(t, strings.TrimRight(raw, "\r"))
	}
}

func (a *agent) line(t *tail, s string) {
	s = strings.TrimLeft(s, "\ufeff")
	if m := reHeader.FindStringSubmatch(s); m != nil {
		switch m[1] {
		case "Listener":
			t.listener = m[2]
		case "Channel Name":
			if !a.isLocal(t.channel) {
				t.channel = m[2]
			}
		}
		return
	}
	m := reLine.FindStringSubmatch(s)
	if m == nil {
		return
	}
	ts, who, msg := m[1], strings.TrimSpace(m[2]), strings.TrimSpace(m[3])
	if a.isLocal(t.channel) {
		if who != "EVE System" {
			return // jamais les messages des joueurs
		}
		if i := strings.LastIndex(msg, ":"); i > 0 && (strings.Contains(msg, "Local") || strings.Contains(msg, "Lokal") || strings.Contains(msg, "Локальный")) {
			sys := strings.TrimRight(strings.TrimSpace(msg[i+1:]), "*")
			if sys != "" {
				a.locs[t.charID] = Loc{CharID: t.charID, Listener: t.listener, TS: ts, System: sys}
				if !a.cfg.Quiet {
					fmt.Printf("%s  [position] %s → %s\n", ts[11:], nz(t.listener, t.charID), sys)
				}
			}
		}
		return
	}
	if who == "EVE System" {
		return // MOTD du canal, changements de canal… : pas de l'intel
	}
	a.queue = append(a.queue, Line{Ch: t.channel, TS: ts, Who: who, Msg: msg})
	if len(a.queue) > 50000 {
		a.queue = a.queue[len(a.queue)-50000:]
	}
	if !a.cfg.Quiet {
		fmt.Printf("%s  %s > %s\n", ts[11:], who, msg)
	}
}

/* ---------- Envoi ---------- */

func (a *agent) flush(force bool) {
	a.flushMapper()
	var locs []Loc
	for id, l := range a.locs {
		if a.sentLoc[id] != l.TS+l.System {
			locs = append(locs, l)
		}
	}
	if len(a.queue) == 0 && len(locs) == 0 {
		return
	}
	if !a.send {
		a.queue = a.queue[:0]
		for _, l := range locs {
			a.sentLoc[l.CharID] = l.TS + l.System
		}
		return
	}
	if !force && time.Now().Before(a.nextTry) {
		return
	}
	for {
		n := len(a.queue)
		if n > 500 {
			n = 500
		}
		p := Payload{V: Version, Lines: a.queue[:n], Locs: locs}
		if p.Lines == nil {
			p.Lines = []Line{}
		}
		if p.Locs == nil {
			p.Locs = []Loc{}
		}
		res, err := a.post(p)
		if err != nil {
			if a.backoff == 0 {
				a.backoff = 2 * time.Second
			} else if a.backoff < time.Minute {
				a.backoff *= 2
			}
			a.nextTry = time.Now().Add(a.backoff)
			a.setErr(err.Error())
			warn("envoi impossible (%v) — %d lignes en attente, nouvel essai dans %s", err, len(a.queue), a.backoff)
			if force {
				time.Sleep(a.backoff)
				continue
			}
			return
		}
		a.backoff = 0
		a.mu.Lock()
		a.lastSend, a.lastErr = time.Now(), ""
		a.mu.Unlock()
		a.queue = a.queue[n:]
		for _, l := range locs {
			a.sentLoc[l.CharID] = l.TS + l.System
		}
		locs = nil
		a.sent += n
		if n > 0 && (res.New > 0 || n >= 50) {
			fmt.Printf("%s  → %d envoyées (%d nouvelles)\n", now(), n, res.New)
		}
		if len(a.queue) == 0 {
			return
		}
	}
}

type apiRes struct {
	OK    bool   `json:"ok"`
	New   int    `json:"new"`
	Error string `json:"error"`
}

// Positions aussi envoyées à EON Mapper (facultatif) : même format, seulement les positions, jeton et nouvel essai à part.
// Le mapper s'en sert pour suivre le perso et repérer les sauts sans porte (trou de ver).
func (a *agent) flushMapper() {
	if a.dry || a.mSent == nil || a.cfg.MapperURL == "" || a.cfg.MapperToken == "" {
		return
	}
	var locs []Loc
	for id, l := range a.locs {
		if a.mSent[id] != l.TS+l.System {
			locs = append(locs, l)
		}
	}
	if len(locs) == 0 || time.Now().Before(a.mNext) {
		return
	}
	if _, err := a.postTo(a.cfg.MapperURL, a.cfg.MapperToken, Payload{V: Version, Lines: []Line{}, Locs: locs}); err != nil {
		if a.mBackoff == 0 {
			a.mBackoff = 2 * time.Second
		} else if a.mBackoff < time.Minute {
			a.mBackoff *= 2
		}
		a.mNext = time.Now().Add(a.mBackoff)
		a.mu.Lock()
		a.mErr = err.Error()
		a.mu.Unlock()
		warn("mapper : %v (nouvel essai dans %s)", err, a.mBackoff)
		return
	}
	a.mBackoff = 0
	a.mu.Lock()
	a.mLast, a.mErr = time.Now(), ""
	a.mu.Unlock()
	for _, l := range locs {
		a.mSent[l.CharID] = l.TS + l.System
	}
}

func (a *agent) post(p Payload) (*apiRes, error) { return a.postTo(a.cfg.APIURL, a.cfg.Token, p) }

func (a *agent) postTo(u, token string, p Payload) (*apiRes, error) {
	body, _ := json.Marshal(p)
	if strings.Contains(u, "?") {
		u += "&a=ingest"
	} else {
		u += "?a=ingest"
	}
	req, _ := http.NewRequest("POST", u, bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Intel-Token", token)
	req.Header.Set("User-Agent", "eon-intel-agent/"+Version)
	resp, err := a.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	var r apiRes
	if err := json.Unmarshal(b, &r); err != nil {
		return nil, fmt.Errorf("HTTP %d, réponse illisible : %.120s", resp.StatusCode, string(b))
	}
	if resp.StatusCode != 200 || !r.OK {
		return nil, fmt.Errorf("HTTP %d : %s", resp.StatusCode, r.Error)
	}
	return &r, nil
}

/* ---------- Divers ---------- */

func now() string { return time.Now().UTC().Format("15:04:05") }
func (a *agent) setErr(e string) {
	a.mu.Lock()
	a.lastErr = e
	a.mu.Unlock()
}
func nz(a, b string) string {
	if a != "" {
		return a
	}
	return b
}
func warn(f string, x ...any) { fmt.Fprintf(os.Stderr, "%s  ! "+f+"\n", append([]any{now()}, x...)...) }
func fatal(f string, x ...any) {
	fmt.Fprintf(os.Stderr, "ERREUR : "+f+"\n", x...)
	fmt.Fprintln(os.Stderr, "Entrée pour fermer.")
	fmt.Scanln()
	os.Exit(1)
}
