// Send email reminders set by users for special occasions.
//
// Email-reminder allows users to define events that they want to be
// reminded of by email.
//
// This script is meant to be invoked every day by a cron job.  It mails
// the actual reminders out.
//
// When run by the root user, it processes all of the spooled reminders.
// When run by a specific user, it only processes the reminders set by that
// user.
//
// # Copyright (C) 2024-2025 Francois Marier
//
// Email-Reminder is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation; either version 3 of the
// License, or (at your option) any later version.
//
// Email-Reminder is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Email-Reminder; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
// 02110-1301, USA.
package main

import (
	"flag"
	"fmt"
	"io"
	"io/fs"
	"log"
	"os"
	"os/user"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"launchpad.net/email-reminder/internal/config"
	"launchpad.net/email-reminder/internal/events"
	"launchpad.net/email-reminder/internal/util"
)

func processEvent(now time.Time, event events.Event, defaultRecipient util.EmailRecipient, conf config.Config, notifier Notifier) {
	if conf.Verbose {
		log.Printf("Processing %s", event)
	}
	if err := event.Validate(); err != nil {
		log.Printf("Skipping invalid event '%s': %s", event, err)
		return
	}

	recipients := event.GetRecipients(defaultRecipient, conf.MaxRecipientsPerEvent)
	if len(recipients) == 0 {
		log.Printf("Skipping '%s': no recipient email addresses configured", event)
		return
	}

	for _, reminder := range event.Reminders {
		isOccurring, when, err := event.IsOccurring(now, reminder)
		if err != nil {
			log.Printf("Cannot tell whether '%s' is occurring: %s", event, err)
			continue
		}
		if !isOccurring {
			continue
		}

		body, subject, err := event.ReminderMessage(now, when)
		if err != nil {
			log.Printf("Skipping '%s': %s", event, err)
			break
		}

		if errs := notifier.Send(body, subject, recipients); len(errs) > 0 {
			log.Printf("Failed to send reminder(s) for '%s': %v", event, errs)
			notifier.SendDebug(now, event, when, errs, defaultRecipient)
		}

		break
	}
}

func processFile(now time.Time, filename string, contents []byte, conf config.Config) {
	if len(contents) > conf.MaxXMLSizeBytes {
		log.Printf("Skipping %s: XML file too large (max %d bytes)\n", filename, conf.MaxXMLSizeBytes)
		return
	}

	if conf.Verbose {
		log.Printf("Extracting events from %s\n", filename)
	}

	user, err := events.LoadUserEvents(filename, contents, conf)
	if err != nil {
		log.Printf("Skipping %s: %s", filename, err)
		return
	}
	if conf.Verbose {
		log.Printf("%d events found in %s\n", len(user.Events), filename)
	}

	defaultRecipient := user.ToEmailRecipient()
	notifier := &SmtpNotifier{Conf: conf}

	var wg sync.WaitGroup
	for _, event := range user.Events {
		wg.Go(func() {
			processEvent(now, event, defaultRecipient, conf, notifier)
		})
	}
	wg.Wait()
}

func processUserConfigFile(now time.Time, conf config.Config) {
	if conf.Verbose {
		log.Println("Processing only the current user's reminders")
	}

	homeDir, err := os.UserHomeDir()
	if err != nil {
		log.Fatalln("No HOME environment variable set")
	}
	configFilename := config.GetUserConfigFilename(homeDir)
	if configFilename == "" {
		log.Fatalln("No user config file found.")
	}
	configFilePath := filepath.Join(homeDir, configFilename)

	file, err := os.OpenInRoot(homeDir, configFilename)
	if err != nil {
		log.Fatalf("Aborting due to unreadable %s: %s\n", configFilePath, err)
	}
	defer file.Close()

	limitedReader := io.LimitReader(file, int64(conf.MaxXMLSizeBytes)+1) // one extra byte to detect truncated files
	contents, err := io.ReadAll(limitedReader)
	if err != nil {
		log.Fatalf("Aborting due to unreadable %s: %s\n", configFilePath, err)
	}

	processFile(now, configFilePath, contents, conf)
}

func shouldProcessPath(path string, d fs.DirEntry, spoolDir string, walkErr error, conf config.Config) (process bool, err error) {
	if walkErr != nil {
		if path == spoolDir {
			// If the spoolDir can't be opened, we can't process anything.
			if conf.Verbose {
				log.Println(walkErr)
			}
			return false, walkErr
		}

		// Skip files that cannot be opened
		if conf.Verbose {
			log.Printf("Skipping unreadable file %s: %s\n", path, walkErr)
		}
		return false, nil
	}

	if strings.HasPrefix(filepath.Base(path), ".") {
		if d.IsDir() {
			// Skip hidden dirs and return to the parent dir
			return false, fs.SkipDir
		}
		return false, nil // Skip hidden files but continue with siblings
	}

	if d.IsDir() {
		// Don't try to read directories, but keep recursing in them
		return false, nil
	}

	return true, nil
}

func processSpooledFile(fullpath string, spoolDir string, now time.Time, conf config.Config) error {
	relPath, err := filepath.Rel(spoolDir, fullpath)
	if err != nil {
		if conf.Verbose {
			log.Printf("Skipping file %s: cannot determine relative path: %s\n", fullpath, err)
		}
		return nil
	}

	file, err := os.OpenInRoot(spoolDir, relPath)
	if err != nil {
		if conf.Verbose {
			log.Printf("Skipping unreadable file %s: %s\n", fullpath, err)
		}
		return nil
	}
	defer file.Close()

	limitedReader := io.LimitReader(file, int64(conf.MaxXMLSizeBytes)+1) // one extra byte to detect truncation
	contents, err := io.ReadAll(limitedReader)
	if err != nil {
		if conf.Verbose {
			log.Printf("Skipping unreadable file %s: %s\n", file.Name(), err)
		}
		return nil
	}

	// Start by deleting the file so that we don't end up in a loop if the file somehow crashes send-reminders:
	// 1. opening the file
	// 2. attempting to process it
	// 3. crashing
	// 4. opening the file again
	// etc.
	if conf.Verbose {
		log.Printf("Deleting %s\n", file.Name())
	}
	if conf.Simulate {
		log.Println("Simulating, not actually deleting the file")
	} else {
		if err := os.Remove(file.Name()); err != nil {
			if conf.Verbose {
				log.Printf("Unable to delete %s\n", file.Name())
			}
			// Without write access to the spoolDir, fall back to just the current user's reminders
			// TODO: return nil instead and then only fall back if we've been unable to delete anything from the spoolDir
			return err
		}
	}

	processFile(now, file.Name(), contents, conf)
	return nil
}

func processFiles(spoolDir string, conf config.Config) {
	// Set a consistent time for all reminders
	now := time.Now()

	err := filepath.WalkDir(spoolDir, func(path string, d fs.DirEntry, err error) error {
		process, walkErr := shouldProcessPath(path, d, spoolDir, err, conf)
		if walkErr != nil {
			return walkErr // can be fs.SkipDir or another error
		}
		if !process {
			return nil
		}

		return processSpooledFile(path, spoolDir, now, conf)
	})
	if err != nil {
		processUserConfigFile(now, conf)
	}
}

func main() {
	log.SetFlags(0)

	simulate := flag.Bool("simulate", false, "does not actually send any emails out")
	verbose := flag.Bool("verbose", false, "print out information about what the program is doing, including the full emails being sent out")
	version := flag.Bool("version", false, "display the version number")
	// TODO: add short names too, either hacking it like in https://www.antoniojgutierrez.com/posts/2021-05-14-short-and-long-options-in-go-flags-pkg/
	// or using another package like golang-github-pborman-getopt-dev in Debian
	flag.Parse()
	if *version {
		fmt.Println("send-reminders " + config.VersionNumber)
		os.Exit(0)
	}

	conf := config.ReadSystemWideConfig(*simulate, *verbose)

	// Check for root
	currentUser, err := user.Current()
	if err != nil {
		log.Fatalln(err)
	}
	if currentUser.Uid == "0" {
		log.Println("Warning: for security reasons, this script should not be run as root.")
	} else if conf.Verbose {
		log.Printf("Current user is %s (%s)\n", currentUser.Username, currentUser.Uid)
	}

	if !conf.SendReminders {
		if conf.Verbose {
			log.Printf("Nothing to do: reminders are disabled in system-wide configuration file")
		}
		os.Exit(0)
	}

	processFiles(config.SpoolDir, conf)
}
