// mavcaldav — the CalDAV module: reads calendars into facts, and renders
// maven's own reminders back out to a calendar she owns.
//
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
// today's events and writes `facts (kind=env, source=poll:caldav)` through
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
// touch the store key, worst case a stale calendar_busy fact until the next
// poll. Two facts:
//
//   - calendar_busy  ("true"/"false") — read by the loop gate to suppress
//     nudges during meetings
//   - calendar_event ("<summary> @ <start>-<end>") — per-event for query
//
// Append-only discipline: a fact is written only when its value CHANGED vs the
// latest for that key+source.
//
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
// pending reminder as a single-event iCal resource in a collection maven owns.
// The calendar is a view, sqlite is the store — see render.go. The render URL
// must differ from the read URL, checked at startup, so the render target can
// never be a calendar maven is only supposed to read.
package main

import (
	"context"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/kami/maven/internal/calendar"
	"github.com/kami/maven/internal/ipc"
)

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "mavcaldav:", err)
		os.Exit(1)
	}
}

func run(args []string) error {
	fs := flag.NewFlagSet("mavcaldav", flag.ContinueOnError)
	socket := fs.String("socket", "", "core IPC socket path (required)")
	url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
	user := fs.String("user", "", "CalDAV basic-auth username (required)")
	pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
	renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
	renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
	renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
	renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
	interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
	timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *socket == "" {
		return fmt.Errorf("-socket is required")
	}
	if *url == "" || *user == "" || *pass == "" {
		return fmt.Errorf("-url, -user, -pass are required")
	}
	if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	core, err := ipc.DialWait(*socket, 60*time.Second)
	if err != nil {
		return err
	}
	defer core.Close()

	hc := &http.Client{Timeout: *timeout}
	p := &poller{
		core: core,
		http: hc,
		url:  strings.TrimRight(*url, "/"),
		user: *user,
		pass: *pass,
	}

	var rend *renderer
	if *renderURL != "" {
		ru, rp := *renderUser, *renderPass
		if ru == "" {
			ru = *user
		}
		if rp == "" {
			rp = *pass
		}
		rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
		log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
	}

	log.Printf("mavcaldav: polling %s every %s", *url, *interval)
	tick := func() {
		p.pollOnce(ctx)
		if rend != nil {
			rend.renderOnce(ctx)
		}
	}
	tick() // fire immediately
	t := time.NewTicker(*interval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			log.Printf("mavcaldav: bye")
			return nil
		case <-t.C:
			tick()
		}
	}
}

// checkRenderTarget refuses a render URL that is also one of the read URLs.
// This is the structural half of #127's "cannot write to your work calendar":
// the write credential and the write URL are separate flags, and a calendar
// maven is known to only read is rejected as a target at startup rather than
// trusted at runtime.
//
// It takes the whole read set, not one URL. The guarantee in the package
// comment is about every calendar maven reads, and a second read target added
// later must not quietly fall outside the check.
func checkRenderTarget(readURLs []string, renderURL string) error {
	if renderURL == "" {
		return nil
	}
	for _, read := range readURLs {
		if read == "" {
			continue
		}
		if sameCollection(read, renderURL) {
			return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
		}
	}
	return nil
}

func sameCollection(a, b string) bool {
	return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
}

type poller struct {
	core ipc.CoreAPI
	http *http.Client
	url  string
	user string
	pass string
}

func (p *poller) pollOnce(ctx context.Context) {
	now := time.Now()
	events, err := p.fetchEvents(ctx, now)
	if err != nil {
		log.Printf("mavcaldav: fetch: %v", err)
		return
	}

	busyVal := "false"
	if calendar.Busy(events, now) {
		busyVal = "true"
	}

	// Write calendar_busy on change.
	if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
		log.Printf("mavcaldav: write calendar_busy: %v", err)
		return
	}

	// Write per-event facts (one per event, keyed by day + event summary).
	// This lets the note RAG path answer "what's on my calendar" without
	// reaching back to Radicale.
	for _, e := range events {
		key := calendar.FactKey(e)
		if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
			log.Printf("mavcaldav: write %s: %v", key, err)
		}
	}
}

// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(p.user, p.pass)
	req.Header.Set("Accept", "text/calendar")

	resp, err := p.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
	}

	return calendar.ParseICalDay(body, now), nil
}

// writeIfChanged writes a fact only when the value differs from the latest.
// Everything this poller writes is a calendar read, which is full confidence by
// definition; a source that is not, such as the notification relay, does not
// come through here.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
	prev, err := p.core.LatestFactBySource(ctx, key, source)
	switch {
	case err == nil && prev.Value == val:
		return nil // unchanged
	case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
		return fmt.Errorf("read %s: %w", key, err)
	}
	_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
		Ts:         ts,
		Kind:       "env",
		Key:        key,
		Value:      val,
		Source:     source,
		Confidence: 1.0,
	})
	if err != nil {
		return fmt.Errorf("write %s: %w", key, err)
	}
	log.Printf("mavcaldav: %s=%s (%s)", key, val, source)
	return nil
}

// isNoFact unwarps error chains to find ipc.ErrNoFact.
func isNoFact(err error) bool {
	for e := err; e != nil; {
		if e == ipc.ErrNoFact {
			return true
		}
		u, ok := e.(interface{ Unwrap() error })
		if !ok {
			return false
		}
		e = u.Unwrap()
	}
	return false
}
