package tlsrptdb

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/mjl-/bstore"

	"github.com/mjl-/mox/dns"
	"github.com/mjl-/mox/mox-"
	"github.com/mjl-/mox/tlsrpt"
)

// TLSResult is stored in the database to track TLS results per policy domain, day
// and recipient domain. These records will be included in TLS reports.
type TLSResult struct {
	ID int64

	// Domain potentially with TLSRPT DNS record, with addresses that will receive
	// reports. Either a recipient domain (for MTA-STS policies) or an (MX) host (for
	// DANE policies). Unicode.
	PolicyDomain string `bstore:"unique PolicyDomain+DayUTC+RecipientDomain,nonzero"`

	// DayUTC is of the form yyyymmdd.
	DayUTC string `bstore:"nonzero"`
	// We send per 24h UTC-aligned days. ../rfc/8460:474

	// Reports are sent per recipient domain and per MX host. For reports to a
	// recipient domain, we type send a result for MTA-STS and one or more MX host
	// (DANE) results. Unicode.
	RecipientDomain string `bstore:"index,nonzero"`

	Created time.Time `bstore:"default now"`
	Updated time.Time `bstore:"default now"`

	IsHost bool // Result is for MX host (DANE), not recipient domain (MTA-STS).

	// Whether to send a report. TLS results for delivering messages with TLS reports
	// will be recorded, but will not cause a report to be sent.
	SendReport bool
	// ../rfc/8460:318 says we should not include TLS results for sending a TLS report,
	// but presumably that's to prevent mail servers sending a report every day once
	// they start.

	// Set after sending to recipient domain, before sending results to policy domain
	// (after which the record is removed).
	SentToRecipientDomain bool
	// Reporting addresses from the recipient domain TLSRPT record, not necessarily
	// those we sent to (e.g. due to failure). Used to leave results to MX target
	// (DANE) policy domains out that were already sent in the report to the recipient
	// domain, so we don't report twice.
	RecipientDomainReportingAddresses []string
	// Set after sending report to policy domain.
	SentToPolicyDomain bool

	// Results is updated for each TLS attempt.
	Results []tlsrpt.Result
}

// todo: TLSRPTSuppressAddress should be named just SuppressAddress, but would clash with dmarcdb.SuppressAddress in sherpa api.

// TLSRPTSuppressAddress is a reporting address for which outgoing TLS reports
// will be suppressed for a period.
type TLSRPTSuppressAddress struct {
	ID               int64
	Inserted         time.Time `bstore:"default now"`
	ReportingAddress string    `bstore:"unique"`
	Until            time.Time `bstore:"nonzero"`
	Comment          string
}

func resultDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
	mutex.Lock()
	defer mutex.Unlock()
	if ResultDB == nil {
		p := mox.DataDirPath("tlsrptresult.db")
		os.MkdirAll(filepath.Dir(p), 0770)
		db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, ResultDBTypes...)
		if err != nil {
			return nil, err
		}
		ResultDB = db
	}
	return ResultDB, nil
}

// AddTLSResults adds or merges all tls results for delivering to a policy domain,
// on its UTC day to a recipient domain to the database. Results may cause multiple
// separate reports to be sent.
func AddTLSResults(ctx context.Context, results []TLSResult) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	now := time.Now()

	err = db.Write(ctx, func(tx *bstore.Tx) error {
		for _, result := range results {
			// Ensure all slices are non-nil. We do this now so all readers will marshal to
			// compliant with the JSON schema. And also for consistent equality checks when
			// merging policies created in different places.
			for i, r := range result.Results {
				if r.Policy.String == nil {
					r.Policy.String = []string{}
				}
				if r.Policy.MXHost == nil {
					r.Policy.MXHost = []string{}
				}
				if r.FailureDetails == nil {
					r.FailureDetails = []tlsrpt.FailureDetails{}
				}
				result.Results[i] = r
			}

			q := bstore.QueryTx[TLSResult](tx)
			q.FilterNonzero(TLSResult{PolicyDomain: result.PolicyDomain, DayUTC: result.DayUTC, RecipientDomain: result.RecipientDomain})
			r, err := q.Get()
			if err == bstore.ErrAbsent {
				result.ID = 0
				if err := tx.Insert(&result); err != nil {
					return fmt.Errorf("insert: %w", err)
				}
				continue
			} else if err != nil {
				return err
			}

			report := tlsrpt.Report{Policies: r.Results}
			report.Merge(result.Results...)
			r.Results = report.Policies

			r.IsHost = result.IsHost
			if result.SendReport {
				r.SendReport = true
			}
			r.Updated = now
			if err := tx.Update(&r); err != nil {
				return fmt.Errorf("update: %w", err)
			}
		}
		return nil
	})
	return err
}

// Results returns all TLS results in the database, for all policy domains each
// with potentially multiple days. Sorted by RecipientDomain and day.
func Results(ctx context.Context) ([]TLSResult, error) {
	db, err := resultDB(ctx)
	if err != nil {
		return nil, err
	}

	return bstore.QueryDB[TLSResult](ctx, db).SortAsc("PolicyDomain", "DayUTC", "RecipientDomain").List()
}

// ResultsDomain returns all TLSResults for a policy domain, potentially for
// multiple days.
func ResultsPolicyDomain(ctx context.Context, policyDomain dns.Domain) ([]TLSResult, error) {
	db, err := resultDB(ctx)
	if err != nil {
		return nil, err
	}

	return bstore.QueryDB[TLSResult](ctx, db).FilterNonzero(TLSResult{PolicyDomain: policyDomain.Name()}).SortAsc("DayUTC", "RecipientDomain").List()
}

// ResultsRecipientDomain returns all TLSResults for a recipient domain,
// potentially for multiple days.
func ResultsRecipientDomain(ctx context.Context, recipientDomain dns.Domain) ([]TLSResult, error) {
	db, err := resultDB(ctx)
	if err != nil {
		return nil, err
	}

	return bstore.QueryDB[TLSResult](ctx, db).FilterNonzero(TLSResult{RecipientDomain: recipientDomain.Name()}).SortAsc("DayUTC", "PolicyDomain").List()
}

// RemoveResultsPolicyDomain removes all TLSResults for the policy domain on the
// day from the database.
func RemoveResultsPolicyDomain(ctx context.Context, policyDomain dns.Domain, dayUTC string) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	_, err = bstore.QueryDB[TLSResult](ctx, db).FilterNonzero(TLSResult{PolicyDomain: policyDomain.Name(), DayUTC: dayUTC}).Delete()
	return err
}

// RemoveResultsRecipientDomain removes all TLSResults for the recipient domain on
// the day from the database.
func RemoveResultsRecipientDomain(ctx context.Context, recipientDomain dns.Domain, dayUTC string) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	_, err = bstore.QueryDB[TLSResult](ctx, db).FilterNonzero(TLSResult{RecipientDomain: recipientDomain.Name(), DayUTC: dayUTC}).Delete()
	return err
}

// SuppressAdd adds an address to the suppress list.
func SuppressAdd(ctx context.Context, ba *TLSRPTSuppressAddress) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	return db.Insert(ctx, ba)
}

// SuppressList returns all reporting addresses on the suppress list.
func SuppressList(ctx context.Context) ([]TLSRPTSuppressAddress, error) {
	db, err := resultDB(ctx)
	if err != nil {
		return nil, err
	}

	return bstore.QueryDB[TLSRPTSuppressAddress](ctx, db).SortDesc("ID").List()
}

// SuppressRemove removes a reporting address record from the suppress list.
func SuppressRemove(ctx context.Context, id int64) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	return db.Delete(ctx, &TLSRPTSuppressAddress{ID: id})
}

// SuppressUpdate updates the until field of a reporting address record.
func SuppressUpdate(ctx context.Context, id int64, until time.Time) error {
	db, err := resultDB(ctx)
	if err != nil {
		return err
	}

	ba := TLSRPTSuppressAddress{ID: id}
	err = db.Get(ctx, &ba)
	if err != nil {
		return err
	}
	ba.Until = until
	return db.Update(ctx, &ba)
}
