package service

import (
	"context"
	"strconv"

	"github.com/go-errors/errors"
	"github.com/google/uuid"

	"github.com/phishingclub/phishingclub/data"
	"github.com/phishingclub/phishingclub/errs"
	"github.com/phishingclub/phishingclub/model"
	"github.com/phishingclub/phishingclub/repository"
	"github.com/phishingclub/phishingclub/validate"
	"github.com/phishingclub/phishingclub/vo"
	"gorm.io/gorm"
)

// Option is a service for Option
type Option struct {
	Common
	OptionRepository *repository.Option
	DomainRepository *repository.Domain
}

// GetOption gets an option
func (o *Option) GetOption(
	ctx context.Context,
	session *model.Session,
	key string,
) (*model.Option, error) {
	ae := NewAuditEvent("Option.GetOption", session)
	// check permissions
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return nil, errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return nil, errs.ErrAuthorizationFailed
	}
	opt, err := o.OptionRepository.GetByKey(
		ctx,
		key,
	)
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, errs.Wrap(err)
	}
	if err != nil {
		o.Logger.Errorw("failed to get option with key",
			"key", key,
			"error", err,
		)
		return nil, errs.Wrap(err)
	}
	return opt, nil
}

// GetOption gets an option
func (o *Option) GetOptionWithoutAuth(
	ctx context.Context,
	key string,
) (*model.Option, error) {
	opt, err := o.OptionRepository.GetByKey(
		ctx,
		key,
	)
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, errs.Wrap(err)
	}
	if err != nil {
		o.Logger.Errorw("failed to get option with key",
			"key", key,
			"error", err,
		)
		return nil, errs.Wrap(err)
	}
	return opt, nil
}

// MaskSSOSecret masks the sso secret
func (o *Option) MaskSSOSecret(opt *model.Option) (*model.Option, error) {
	a, err := model.NewSSOOptionFromJSON([]byte(opt.Value.String()))
	if err != nil {
		o.Logger.Errorw("failed to read sso option", "error", err)
		return nil, errs.Wrap(err)
	}
	// mask the key
	a.ClientSecret = *vo.NewOptionalString1024Must("")
	b, err := a.ToJSON()
	if err != nil {
		o.Logger.Errorw("failed to mask sso secret", "error", err)
		return nil, errs.Wrap(err)
	}
	c, err := vo.NewOptionalString1MB(string(b))
	if err != nil {
		o.Logger.Errorw("failed to mask secret option", "error", err)
		return nil, errs.Wrap(err)
	}
	opt.Value = *c
	return opt, nil
}

// SetOptionByKey sets an option
func (o *Option) SetOptionByKey(
	ctx context.Context,
	session *model.Session,
	option *model.Option,
) error {
	ae := NewAuditEvent("Option.SetOptionByKey", session)
	ae.Details["key"] = option.Key.String()
	ae.Details["value"] = option.Value.String()
	// check permissions
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return err
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return errs.ErrAuthorizationFailed
	}
	k := option.Key.String()
	v := option.Value.String()
	switch k {
	case data.OptionKeyMaxFileUploadSizeMB:
		if n, err := strconv.Atoi(v); err != nil || n <= 0 {
			o.Logger.Debugw("invalid max file size",
				"n", n,
				"error", err,
			)
			return validate.WrapErrorWithField(
				errs.NewValidationError(
					errors.New("invalid max"),
				),
				"max file size",
			)
		}
	case data.OptionKeyRepeatOffenderMonths:
		if n, err := strconv.Atoi(v); err != nil || n <= 0 {
			o.Logger.Debugw("invalid repeat offender months",
				"n", n,
				"error", err,
			)
			return validate.WrapErrorWithField(
				errs.NewValidationError(
					errors.New("invalid months"),
				),
				"repeat offender months",
			)
		}
	case data.OptionKeyLogLevel:
		// validate log level value
		if v != "debug" && v != "info" && v != "warn" && v != "error" {
			o.Logger.Debugw("invalid log level value",
				"value", v,
			)
			return validate.WrapErrorWithField(
				errs.NewValidationError(
					errors.New("invalid log level"),
				),
				"log level",
			)
		}
	case data.OptionKeyDBLogLevel:
		// validate db log level value
		if v != "silent" && v != "info" && v != "warn" && v != "error" {
			o.Logger.Debugw("invalid db log level value",
				"value", v,
			)
			return validate.WrapErrorWithField(
				errs.NewValidationError(
					errors.New("invalid db log level"),
				),
				"db log level",
			)
		}
	case data.OptionKeyAdminSSOLogin:
		// is allow listed
	case data.OptionKeyDisplayMode:
		// validate display mode value
		if v != data.OptionValueDisplayModeWhitebox && v != data.OptionValueDisplayModeBlackbox {
			o.Logger.Debugw("invalid display mode value",
				"value", v,
			)
			return validate.WrapErrorWithField(
				errs.NewValidationError(
					errors.New("invalid display mode"),
				),
				"display mode",
			)
		}
	case data.OptionKeyAutoPruneOrphanedRecipients:
		// stored as JSON — validate by parsing
		if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil {
			o.Logger.Debugw("invalid auto-prune option value", "value", v)
			return validate.WrapErrorWithField(
				errs.NewValidationError(errors.New("invalid value")),
				"value",
			)
		}
	case data.OptionKeyReportPDFEnabled:
		if v != "true" && v != "false" {
			return validate.WrapErrorWithField(
				errs.NewValidationError(errors.New("invalid value")),
				"value",
			)
		}
	case data.OptionKeyObfuscationTemplate:
		// is allow listed
	default:
		o.Logger.Debugw("invalid settings key", "key", k)
		return validate.WrapErrorWithField(
			errs.NewValidationError(
				errors.New("invalid option"),
			),
			"key",
		)
	}
	// update options
	err = o.OptionRepository.UpdateByKey(
		ctx,
		option,
	)
	if err != nil {
		o.Logger.Errorw("failed to update option by key", "error", err)
		return err
	}
	o.AuditLogAuthorized(ae)
	return nil
}

// GetAutoPruneOption returns the single auto-prune option row (requires global auth).
// Both the global flag and all per-company entries are embedded in the returned value.
func (o *Option) GetAutoPruneOption(
	ctx context.Context,
	session *model.Session,
) (*model.AutoPruneOption, error) {
	ae := NewAuditEvent("Option.GetAutoPruneOption", session)
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return nil, errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return nil, errs.ErrAuthorizationFailed
	}
	o.AuditLogAuthorized(ae)
	return o.getAutoPruneOption(ctx)
}

// SetAutoPruneOption persists the full auto-prune option as a single JSON row
// (requires global auth). The caller supplies the complete AutoPruneOption
// value including any per-company entries.
func (o *Option) SetAutoPruneOption(
	ctx context.Context,
	session *model.Session,
	autoPruneOpt *model.AutoPruneOption,
) error {
	ae := NewAuditEvent("Option.SetAutoPruneOption", session)
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return errs.ErrAuthorizationFailed
	}
	if err := o.upsertAutoPruneOption(ctx, autoPruneOpt); err != nil {
		return err
	}
	o.AuditLogAuthorized(ae)
	return nil
}

// GetCompanyAutoPruneOption returns whether the given company has opted in to auto-pruning
// by reading the single shared auto-prune option row (requires global auth).
func (o *Option) GetCompanyAutoPruneOption(
	ctx context.Context,
	session *model.Session,
	companyID *uuid.UUID,
) (bool, error) {
	ae := NewAuditEvent("Option.GetCompanyAutoPruneOption", session)
	ae.Details["companyID"] = companyID.String()
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return false, errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return false, errs.ErrAuthorizationFailed
	}
	opt, err := o.getAutoPruneOption(ctx)
	if err != nil {
		return false, err
	}
	o.AuditLogAuthorized(ae)
	return opt.IsCompanyEnabled(companyID), nil
}

// SetCompanyAutoPruneOption updates the per-company enabled flag within the
// single shared auto-prune option row (requires global auth).
func (o *Option) SetCompanyAutoPruneOption(
	ctx context.Context,
	session *model.Session,
	companyID *uuid.UUID,
	enabled bool,
) error {
	ae := NewAuditEvent("Option.SetCompanyAutoPruneOption", session)
	ae.Details["companyID"] = companyID.String()
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return errs.ErrAuthorizationFailed
	}
	// read-modify-write the single row
	opt, err := o.getAutoPruneOption(ctx)
	if err != nil {
		return err
	}
	opt.SetCompanyEnabled(companyID, enabled)
	if err := o.upsertAutoPruneOption(ctx, opt); err != nil {
		return err
	}
	o.AuditLogAuthorized(ae)
	return nil
}

// GetAutoPruneOptionInternal returns the full auto-prune option without any
// authorization check. intended for internal/task-runner use only.
func (o *Option) GetAutoPruneOptionInternal(ctx context.Context) (*model.AutoPruneOption, error) {
	return o.getAutoPruneOption(ctx)
}

// getAutoPruneOption reads the single auto-prune option row, returning the
// default (all-disabled) value when the row does not exist yet.
func (o *Option) getAutoPruneOption(ctx context.Context) (*model.AutoPruneOption, error) {
	raw, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return model.NewAutoPruneOptionDefault(), nil
		}
		o.Logger.Errorw("failed to get auto-prune option", "error", err)
		return nil, errs.Wrap(err)
	}
	return model.NewAutoPruneOptionFromJSON([]byte(raw.Value.String()))
}

// upsertAutoPruneOption inserts or updates the single auto-prune option row.
func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model.AutoPruneOption) error {
	opt, err := autoPruneOpt.ToOption()
	if err != nil {
		return errs.Wrap(err)
	}
	_, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients)
	if getErr != nil {
		if !errors.Is(getErr, gorm.ErrRecordNotFound) {
			o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr)
			return errs.Wrap(getErr)
		}
		// row does not exist yet — insert
		if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
			o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr)
			return errs.Wrap(insertErr)
		}
		return nil
	}
	// row exists — update
	if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
		o.Logger.Errorw("failed to update auto-prune option", "error", updateErr)
		return errs.Wrap(updateErr)
	}
	return nil
}

// GetObfuscationTemplate gets the obfuscation template from options or returns default
func (o *Option) GetObfuscationTemplate(ctx context.Context) (string, error) {
	opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyObfuscationTemplate)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			// return default template if not found
			return data.OptionValueObfuscationTemplateDefault, nil
		}
		o.Logger.Errorw("failed to get obfuscation template option", "error", err)
		return "", errs.Wrap(err)
	}
	template := opt.Value.String()
	if template == "" {
		// return default if empty
		return data.OptionValueObfuscationTemplateDefault, nil
	}
	return template, nil
}

// GetScimDomainInternal returns the configured global SCIM domain without any
// authorization check. intended for the phishing-server host gate. returns an
// empty string when SCIM serving is not configured.
func (o *Option) GetScimDomainInternal(ctx context.Context) (string, error) {
	opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return "", nil
		}
		o.Logger.Errorw("failed to get scim domain option", "error", err)
		return "", errs.Wrap(err)
	}
	return opt.Value.String(), nil
}

// GetScimDomain returns the configured global SCIM domain (requires global auth).
func (o *Option) GetScimDomain(
	ctx context.Context,
	session *model.Session,
) (string, error) {
	ae := NewAuditEvent("Option.GetScimDomain", session)
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return "", errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return "", errs.ErrAuthorizationFailed
	}
	return o.GetScimDomainInternal(ctx)
}

// SetScimDomain persists the global SCIM domain (requires global auth). An empty
// string disables SCIM serving. A non-empty value must be an existing global
// domain (one not tied to a company).
func (o *Option) SetScimDomain(
	ctx context.Context,
	session *model.Session,
	domain string,
) error {
	ae := NewAuditEvent("Option.SetScimDomain", session)
	ae.Details["domain"] = domain
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return errs.ErrAuthorizationFailed
	}
	// a non-empty value must resolve to an existing global domain
	if domain != "" {
		nameVO, err := vo.NewString255(domain)
		if err != nil {
			return errs.NewValidationError(err)
		}
		d, err := o.DomainRepository.GetByName(ctx, nameVO, &repository.DomainOption{})
		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return errs.NewValidationError(errors.Errorf("domain %q does not exist", domain))
			}
			o.Logger.Errorw("failed to look up scim domain", "error", err)
			return errs.Wrap(err)
		}
		// only global domains (not tied to a company) may be used for SCIM
		if d.CompanyID.IsSpecified() && !d.CompanyID.IsNull() {
			return errs.NewValidationError(errors.Errorf("domain %q is not a global domain", domain))
		}
		// AiTM proxy domains route traffic to a target site and must never serve
		// the SCIM provisioning API
		if d.ProxyID.IsSpecified() && !d.ProxyID.IsNull() {
			return errs.NewValidationError(errors.Errorf("domain %q is a proxy domain and cannot be used for SCIM", domain))
		}
	}
	if err := o.setScimDomainValue(ctx, domain); err != nil {
		return err
	}
	o.AuditLogAuthorized(ae)
	return nil
}

// setScimDomainValue inserts or updates the single SCIM domain option row.
func (o *Option) setScimDomainValue(ctx context.Context, domain string) error {
	valueVO, err := vo.NewOptionalString1MB(domain)
	if err != nil {
		return errs.NewValidationError(err)
	}
	opt := &model.Option{
		Key:   *vo.NewString127Must(data.OptionKeyScimDomain),
		Value: *valueVO,
	}
	_, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain)
	if getErr != nil {
		if !errors.Is(getErr, gorm.ErrRecordNotFound) {
			o.Logger.Errorw("failed to check scim domain option existence", "error", getErr)
			return errs.Wrap(getErr)
		}
		if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
			o.Logger.Errorw("failed to insert scim domain option", "error", insertErr)
			return errs.Wrap(insertErr)
		}
		return nil
	}
	if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
		o.Logger.Errorw("failed to update scim domain option", "error", updateErr)
		return errs.Wrap(updateErr)
	}
	return nil
}

// upsertOptionValue inserts or updates a single option row by key.
func (o *Option) upsertOptionValue(ctx context.Context, key string, value string) error {
	valueVO, err := vo.NewOptionalString1MB(value)
	if err != nil {
		return errs.NewValidationError(err)
	}
	opt := &model.Option{
		Key:   *vo.NewString127Must(key),
		Value: *valueVO,
	}
	_, getErr := o.OptionRepository.GetByKey(ctx, key)
	if getErr != nil {
		if !errors.Is(getErr, gorm.ErrRecordNotFound) {
			return errs.Wrap(getErr)
		}
		if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
			return errs.Wrap(insertErr)
		}
		return nil
	}
	if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
		return errs.Wrap(updateErr)
	}
	return nil
}

// GetScimSoftDeleteRetentionDaysInternal returns the retention window in days,
// defaulting when unset or invalid. No authorization check.
func (o *Option) GetScimSoftDeleteRetentionDaysInternal(ctx context.Context) (int, error) {
	opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimSoftDeleteRetentionDays)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil
		}
		return 0, errs.Wrap(err)
	}
	days, convErr := strconv.Atoi(opt.Value.String())
	if convErr != nil || days < 0 {
		return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil
	}
	return days, nil
}

// GetScimSoftDeleteRetentionDays returns the retention window (requires global auth).
func (o *Option) GetScimSoftDeleteRetentionDays(
	ctx context.Context,
	session *model.Session,
) (int, error) {
	ae := NewAuditEvent("Option.GetScimSoftDeleteRetentionDays", session)
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return 0, errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return 0, errs.ErrAuthorizationFailed
	}
	return o.GetScimSoftDeleteRetentionDaysInternal(ctx)
}

// SetScimSoftDeleteRetentionDays persists the retention window (requires global auth).
func (o *Option) SetScimSoftDeleteRetentionDays(
	ctx context.Context,
	session *model.Session,
	days int,
) error {
	ae := NewAuditEvent("Option.SetScimSoftDeleteRetentionDays", session)
	ae.Details["days"] = days
	isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
	if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
		o.LogAuthError(err)
		return errs.Wrap(err)
	}
	if !isAuthorized {
		o.AuditLogNotAuthorized(ae)
		return errs.ErrAuthorizationFailed
	}
	if days < 0 {
		return errs.NewValidationError(errors.Errorf("retention days must be zero or positive"))
	}
	if err := o.upsertOptionValue(ctx, data.OptionKeyScimSoftDeleteRetentionDays, strconv.Itoa(days)); err != nil {
		return err
	}
	o.AuditLogAuthorized(ae)
	return nil
}
