// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

// observation.go implements the observation subsystem, which is the data
// collection layer for the checker framework. An observation represents a
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
// headers, TLS certificate details). Observations are identified by an
// ObservationKey and collected on demand by registered ObservationProviders.
//
// The ObservationContext provides lazy-loading, cached, thread-safe access to
// observations: the first checker that requests a given observation triggers
// its collection, and subsequent checkers reuse the cached result. This
// design decouples data collection from evaluation: checkers declare which
// observations they need, and the context ensures each is collected at most
// once per check run. Observations can also be persisted as snapshots and
// reused across runs when freshness requirements allow.
//
// Reporter helpers (HTML reports, metrics extraction) live in reporter.go.

package dnschecker

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"sync"
	"time"

	sdk "git.happydns.org/checker-sdk-go/checker"
	"git.happydns.org/happyDomain/model"
)

// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data, collection time, and the EnabledRulesHash recorded
// at collection time (empty for legacy entries), or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, string, error)

// RelatedObservationLookup resolves observations produced by other checkers
// on DiscoveryEntry records this checker has published for the given target.
// It returns the empty slice (not an error) when there is nothing to relate.
type RelatedObservationLookup func(ctx context.Context, producerCheckerID string, target happydns.CheckTarget, key happydns.ObservationKey) ([]happydns.RelatedObservation, error)

// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
//
// Concurrency model: the outer mu protects only the cache/errors/inflight
// maps and is held for short critical sections. Provider collection runs
// *without* mu held, so two calls to Get for *different* keys can collect
// concurrently. Two calls for the *same* key are deduplicated: the first
// installs an inflight channel, runs the collection, then closes the
// channel; the others wait on it and read the cached result afterwards.
type ObservationContext struct {
	target            happydns.CheckTarget
	opts              happydns.CheckerOptions
	cache             map[happydns.ObservationKey]json.RawMessage
	errors            map[happydns.ObservationKey]error
	inflight          map[happydns.ObservationKey]chan struct{}
	entries           map[happydns.ObservationKey][]happydns.DiscoveryEntry
	mu                sync.Mutex
	cacheLookup       ObservationCacheLookup // nil = no DB cache
	freshness         time.Duration          // 0 = always collect
	enabledRulesHash  string                 // current execution's rule-enable fingerprint; cache entries with a different hash bypass the freshness window
	providerOverride  map[happydns.ObservationKey]happydns.ObservationProvider
	producerCheckerID string                   // filled by engine; empty = GetRelated always empty
	relatedLookup     RelatedObservationLookup // nil = GetRelated always empty
}

// NewObservationContext creates a new ObservationContext for the given target and options.
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
	return &ObservationContext{
		target:      target,
		opts:        opts,
		cache:       make(map[happydns.ObservationKey]json.RawMessage),
		errors:      make(map[happydns.ObservationKey]error),
		inflight:    make(map[happydns.ObservationKey]chan struct{}),
		entries:     make(map[happydns.ObservationKey][]happydns.DiscoveryEntry),
		cacheLookup: cacheLookup,
		freshness:   freshness,
	}
}

// SetEnabledRulesHash records the fingerprint of the rule-enable map in
// effect for this execution. Subsequent cache lookups compare against
// this value and bypass the freshness window on mismatch, so observations
// collected while a rule was disabled do not satisfy reads that would
// now include that rule.
func (oc *ObservationContext) SetEnabledRulesHash(h string) {
	oc.mu.Lock()
	defer oc.mu.Unlock()
	oc.enabledRulesHash = h
}

// Entries returns the DiscoveryEntry records published by each observation
// provider during this run, aggregated by observation key. The engine
// collects these after rule evaluation to persist them via the discovery
// store.
func (oc *ObservationContext) Entries() map[happydns.ObservationKey][]happydns.DiscoveryEntry {
	oc.mu.Lock()
	defer oc.mu.Unlock()

	out := make(map[happydns.ObservationKey][]happydns.DiscoveryEntry, len(oc.entries))
	for k, v := range oc.entries {
		out[k] = append([]happydns.DiscoveryEntry(nil), v...)
	}
	return out
}

// SetProviderOverride registers a per-context provider that takes precedence
// over the global registry for the given observation key.  This is used to
// substitute local providers with HTTP-backed ones when an endpoint is configured.
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
	oc.mu.Lock()
	defer oc.mu.Unlock()
	if oc.providerOverride == nil {
		oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
	}
	oc.providerOverride[key] = p
}

// getProvider returns the observation provider for the given key, checking
// per-context overrides first, then falling back to the global registry.
// Safe to call without holding oc.mu - it acquires the lock internally.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
	oc.mu.Lock()
	override := oc.providerOverride
	oc.mu.Unlock()
	if override != nil {
		if p, ok := override[key]; ok {
			return p
		}
	}
	return sdk.FindObservationProvider(key)
}

// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key are deduplicated; concurrent
// calls for different keys collect in parallel.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
	for {
		oc.mu.Lock()
		if raw, ok := oc.cache[key]; ok {
			oc.mu.Unlock()
			return json.Unmarshal(raw, dest)
		}
		if err, ok := oc.errors[key]; ok {
			oc.mu.Unlock()
			return err
		}
		if ch, ok := oc.inflight[key]; ok {
			// Another goroutine is already collecting this key. Release
			// the lock, wait for it to finish, then re-check the cache.
			oc.mu.Unlock()
			select {
			case <-ch:
			case <-ctx.Done():
				return ctx.Err()
			}
			continue
		}

		// We are the leader for this key. Install the inflight channel
		// before releasing the lock so concurrent callers wait on us.
		ch := make(chan struct{})
		oc.inflight[key] = ch
		oc.mu.Unlock()

		raw, collectErr := oc.collect(ctx, key)

		// Collection errors are cached for the lifetime of this
		// ObservationContext (i.e. a single execution run). This is
		// intentional: within one run the same transient failure would
		// keep recurring, and retrying would slow down the pipeline.
		// A new execution creates a fresh context, giving the provider
		// another chance.
		oc.mu.Lock()
		if collectErr != nil {
			oc.errors[key] = collectErr
		} else {
			oc.cache[key] = raw
		}
		delete(oc.inflight, key)
		close(ch)
		oc.mu.Unlock()

		if collectErr != nil {
			return collectErr
		}
		return json.Unmarshal(raw, dest)
	}
}

// collect runs the DB-cache lookup and provider collection for a single key
// without holding oc.mu, so collections for different keys can run in
// parallel. Callers are responsible for installing the result into the cache
// or errors map and signalling waiters.
func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) {
	if oc.cacheLookup != nil && oc.freshness > 0 {
		if raw, collectedAt, cachedHash, err := oc.cacheLookup(oc.target, key); err == nil {
			// Bypass the freshness window when the cached observation was
			// collected with a different rule-enable set: a re-enabled rule
			// would otherwise be served data gathered while it was off.
			oc.mu.Lock()
			currentHash := oc.enabledRulesHash
			oc.mu.Unlock()
			if cachedHash == currentHash && time.Since(collectedAt) < oc.freshness {
				return raw, nil
			}
		}
	}

	provider := oc.getProvider(key)
	if provider == nil {
		return nil, fmt.Errorf("no observation provider registered for key %q", key)
	}

	val, err := provider.Collect(ctx, oc.opts)
	if err != nil {
		return nil, err
	}

	if pub, ok := provider.(sdk.DiscoveryPublisher); ok {
		if entries, err := pub.DiscoverEntries(val); err != nil {
			log.Printf("observation %q: DiscoverEntries failed: %v", key, err)
		} else if len(entries) > 0 {
			oc.mu.Lock()
			oc.entries[key] = entries
			oc.mu.Unlock()
		}
	}

	raw, err := json.Marshal(val)
	if err != nil {
		return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err)
	}
	return json.RawMessage(raw), nil
}

// SetRelatedLookup installs the producer identity and resolver closure the
// ObservationContext will use to answer GetRelated during rule evaluation.
// A nil lookup disables GetRelated (returns an empty slice).
func (oc *ObservationContext) SetRelatedLookup(producerCheckerID string, lookup RelatedObservationLookup) {
	oc.mu.Lock()
	defer oc.mu.Unlock()
	oc.producerCheckerID = producerCheckerID
	oc.relatedLookup = lookup
}

// GetRelated returns observations produced by other checkers on DiscoveryEntry
// records this target's producer has published. When no discovery storage is
// wired (or this checker never published any entries), it returns an empty
// slice — callers must tolerate that per SDK contract.
func (oc *ObservationContext) GetRelated(ctx context.Context, key happydns.ObservationKey) ([]happydns.RelatedObservation, error) {
	oc.mu.Lock()
	producer := oc.producerCheckerID
	lookup := oc.relatedLookup
	oc.mu.Unlock()

	if lookup == nil || producer == "" {
		return nil, nil
	}
	return lookup(ctx, producer, oc.target, key)
}

// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
	oc.mu.Lock()
	defer oc.mu.Unlock()

	data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
	for k, v := range oc.cache {
		data[k] = v
	}
	return data
}
