package audit

import (
	"fmt"
	"os"
	"reflect"
	"runtime"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/codersdk"
)

// This mapping creates a relationship between an Auditable Resource
// and the Audit Actions we track for that resource.
// It is important to maintain this mapping when adding a new Auditable Resource to the
// AuditableResources map (below) as our documentation - generated in scripts/auditdocgen/main.go -
// depends upon it.
var AuditActionMap = map[string][]codersdk.AuditAction{
	"GitSSHKey":       {codersdk.AuditActionCreate},
	"Template":        {codersdk.AuditActionWrite, codersdk.AuditActionDelete},
	"TemplateVersion": {codersdk.AuditActionCreate, codersdk.AuditActionWrite},
	"User":            {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
	"Workspace":       {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
	"WorkspaceBuild":  {codersdk.AuditActionStart, codersdk.AuditActionStop},
	"Group":           {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
	"APIKey":          {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionRegister, codersdk.AuditActionCreate, codersdk.AuditActionDelete},
	"License":         {codersdk.AuditActionCreate, codersdk.AuditActionDelete},
}

type Action string

const (
	// ActionIgnore ignores diffing for the field.
	ActionIgnore = "ignore"
	// ActionTrack includes the value in the diff if the value changed.
	ActionTrack = "track"
	// ActionSecret includes a zero value of the same type if the value changed.
	// It lets you indicate that a value changed, but without leaking its
	// contents.
	ActionSecret = "secret"
)

// Table is a map of struct names to a map of field names that indicate that
// field's AuditType.
type Table map[string]map[string]Action

// AuditableResources contains a definitive list of all auditable resources and
// which fields are auditable. All resource types must be valid audit.Auditable
// types.
var AuditableResources = auditMap(auditableResourcesTypes)

var auditableResourcesTypes = map[any]map[string]Action{
	&database.GitSSHKey{}: {
		"user_id":     ActionTrack,
		"created_at":  ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
		"updated_at":  ActionIgnore, // Changes, but is implicit and not helpful in a diff.
		"private_key": ActionSecret, // We don't want to expose private keys in diffs.
		"public_key":  ActionTrack,  // Public keys are ok to expose in a diff.
	},
	&database.Template{}: {
		"id":                                ActionTrack,
		"created_at":                        ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
		"updated_at":                        ActionIgnore, // Changes, but is implicit and not helpful in a diff.
		"organization_id":                   ActionIgnore, /// Never changes.
		"deleted":                           ActionIgnore, // Changes, but is implicit when a delete event is fired.
		"name":                              ActionTrack,
		"display_name":                      ActionTrack,
		"provisioner":                       ActionTrack,
		"active_version_id":                 ActionTrack,
		"description":                       ActionTrack,
		"icon":                              ActionTrack,
		"default_ttl":                       ActionTrack,
		"autostart_block_days_of_week":      ActionTrack,
		"autostop_requirement_days_of_week": ActionTrack,
		"autostop_requirement_weeks":        ActionTrack,
		"created_by":                        ActionTrack,
		"created_by_username":               ActionIgnore,
		"created_by_avatar_url":             ActionIgnore,
		"group_acl":                         ActionTrack,
		"user_acl":                          ActionTrack,
		"allow_user_autostart":              ActionTrack,
		"allow_user_autostop":               ActionTrack,
		"allow_user_cancel_workspace_jobs":  ActionTrack,
		"failure_ttl":                       ActionTrack,
		"time_til_dormant":                  ActionTrack,
		"time_til_dormant_autodelete":       ActionTrack,
		"require_active_version":            ActionTrack,
		"deprecated":                        ActionTrack,
		"max_port_sharing_level":            ActionTrack,
		"activity_bump":                     ActionTrack,
	},
	&database.TemplateVersion{}: {
		"id":                      ActionTrack,
		"template_id":             ActionTrack,
		"organization_id":         ActionIgnore, // Never changes.
		"created_at":              ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
		"updated_at":              ActionIgnore, // Changes, but is implicit and not helpful in a diff.
		"name":                    ActionTrack,
		"message":                 ActionIgnore, // Never changes after creation.
		"readme":                  ActionTrack,
		"job_id":                  ActionIgnore, // Not helpful in a diff because jobs aren't tracked in audit logs.
		"created_by":              ActionTrack,
		"external_auth_providers": ActionIgnore, // Not helpful because this can only change when new versions are added.
		"created_by_avatar_url":   ActionIgnore,
		"created_by_username":     ActionIgnore,
		"archived":                ActionTrack,
	},
	&database.User{}: {
		"id":                   ActionTrack,
		"email":                ActionTrack,
		"username":             ActionTrack,
		"hashed_password":      ActionSecret, // Do not expose a users hashed password.
		"created_at":           ActionIgnore, // Never changes.
		"updated_at":           ActionIgnore, // Changes, but is implicit and not helpful in a diff.
		"status":               ActionTrack,
		"rbac_roles":           ActionTrack,
		"login_type":           ActionTrack,
		"avatar_url":           ActionIgnore,
		"last_seen_at":         ActionIgnore,
		"deleted":              ActionTrack,
		"quiet_hours_schedule": ActionTrack,
		"theme_preference":     ActionIgnore,
		"name":                 ActionTrack,
	},
	&database.Workspace{}: {
		"id":                 ActionTrack,
		"created_at":         ActionIgnore, // Never changes.
		"updated_at":         ActionIgnore, // Changes, but is implicit and not helpful in a diff.
		"owner_id":           ActionTrack,
		"organization_id":    ActionIgnore, // Never changes.
		"template_id":        ActionTrack,
		"deleted":            ActionIgnore, // Changes, but is implicit when a delete event is fired.
		"name":               ActionTrack,
		"autostart_schedule": ActionTrack,
		"ttl":                ActionTrack,
		"last_used_at":       ActionIgnore,
		"dormant_at":         ActionTrack,
		"deleting_at":        ActionTrack,
		"automatic_updates":  ActionTrack,
		"favorite":           ActionTrack,
	},
	&database.WorkspaceBuild{}: {
		"id":                      ActionIgnore,
		"created_at":              ActionIgnore,
		"updated_at":              ActionIgnore,
		"workspace_id":            ActionIgnore,
		"template_version_id":     ActionTrack,
		"build_number":            ActionIgnore,
		"transition":              ActionIgnore,
		"initiator_id":            ActionIgnore,
		"provisioner_state":       ActionIgnore,
		"job_id":                  ActionIgnore,
		"deadline":                ActionIgnore,
		"reason":                  ActionIgnore,
		"daily_cost":              ActionIgnore,
		"max_deadline":            ActionIgnore,
		"initiator_by_avatar_url": ActionIgnore,
		"initiator_by_username":   ActionIgnore,
	},
	&database.AuditableGroup{}: {
		"id":              ActionTrack,
		"name":            ActionTrack,
		"display_name":    ActionTrack,
		"organization_id": ActionIgnore, // Never changes.
		"avatar_url":      ActionTrack,
		"quota_allowance": ActionTrack,
		"members":         ActionTrack,
		"source":          ActionIgnore,
	},
	&database.APIKey{}: {
		"id":               ActionIgnore,
		"hashed_secret":    ActionIgnore,
		"user_id":          ActionTrack,
		"last_used":        ActionTrack,
		"expires_at":       ActionTrack,
		"created_at":       ActionTrack,
		"updated_at":       ActionIgnore,
		"login_type":       ActionIgnore,
		"lifetime_seconds": ActionIgnore,
		"ip_address":       ActionIgnore,
		"scope":            ActionIgnore,
		"token_name":       ActionIgnore,
	},
	&database.AuditOAuthConvertState{}: {
		"created_at":      ActionTrack,
		"expires_at":      ActionTrack,
		"from_login_type": ActionTrack,
		"to_login_type":   ActionTrack,
		"user_id":         ActionTrack,
	},
	&database.HealthSettings{}: {
		"id":                     ActionIgnore,
		"dismissed_healthchecks": ActionTrack,
	},
	// TODO: track an ID here when the below ticket is completed:
	// https://github.com/coder/coder/pull/6012
	&database.License{}: {
		"id":          ActionIgnore,
		"uploaded_at": ActionTrack,
		"jwt":         ActionIgnore,
		"exp":         ActionTrack,
		"uuid":        ActionTrack,
	},
	&database.WorkspaceProxy{}: {
		"id":                  ActionTrack,
		"name":                ActionTrack,
		"display_name":        ActionTrack,
		"icon":                ActionTrack,
		"url":                 ActionTrack,
		"wildcard_hostname":   ActionTrack,
		"created_at":          ActionTrack,
		"updated_at":          ActionIgnore,
		"deleted":             ActionIgnore,
		"token_hashed_secret": ActionSecret,
		"derp_enabled":        ActionTrack,
		"derp_only":           ActionTrack,
		"region_id":           ActionTrack,
		"version":             ActionTrack,
	},
	&database.OAuth2ProviderApp{}: {
		"id":           ActionIgnore,
		"created_at":   ActionIgnore,
		"updated_at":   ActionIgnore,
		"name":         ActionTrack,
		"icon":         ActionTrack,
		"callback_url": ActionTrack,
	},
	&database.OAuth2ProviderAppSecret{}: {
		"id":             ActionIgnore,
		"created_at":     ActionIgnore,
		"last_used_at":   ActionIgnore,
		"hashed_secret":  ActionIgnore,
		"display_secret": ActionIgnore,
		"app_id":         ActionIgnore,
		"secret_prefix":  ActionIgnore,
	},
}

// auditMap converts a map of struct pointers to a map of struct names as
// strings. It's a convenience wrapper so that structs can be passed in by value
// instead of manually typing struct names as strings.
func auditMap(m map[any]map[string]Action) Table {
	out := make(Table, len(m))

	for k, v := range m {
		tableKey, tableValue := entry(k, v)
		out[tableKey] = tableValue
	}

	return out
}

// entry is a helper function that checks the json tags to make sure all fields
// are tracked. And no excess fields are tracked.
func entry(v any, f map[string]Action) (string, map[string]Action) {
	vt := reflect.TypeOf(v)
	for vt.Kind() == reflect.Ptr {
		vt = vt.Elem()
	}

	// This should never happen because audit.Audible only allows structs in
	// its union.
	if vt.Kind() != reflect.Struct {
		panic(fmt.Sprintf("audit table entry value must be a struct, got %T", v))
	}

	name := structName(vt)

	// Use the flattenStructFields to recurse anonymously embedded structs
	vv := reflect.ValueOf(v)
	diffs, err := flattenStructFields(vv, vv)
	if err != nil {
		panic(fmt.Sprintf("audit table entry type %T failed to flatten", v))
	}

	fcpy := make(map[string]Action, len(f))
	for k, v := range f {
		fcpy[k] = v
	}
	for _, d := range diffs {
		jsonTag := d.FieldType.Tag.Get("json")
		if jsonTag == "-" {
			// This field is explicitly ignored.
			continue
		}
		if _, ok := fcpy[jsonTag]; !ok {
			_, _ = fmt.Fprintf(os.Stderr, "ERROR: Audit table entry missing action for field %q in type %q\nPlease update the auditable resource types in: %s\n", d.FieldType.Name, name, self())
			//nolint:revive
			os.Exit(1)
		}
		delete(fcpy, jsonTag)
	}

	// If there are any fields left in fcpy, they are extra fields that don't
	// exist in the struct. Don't track them.
	if len(fcpy) > 0 {
		panic(fmt.Sprintf("audit table entry has extra actions for type %q: %v", name, fcpy))
	}

	return structName(vt), f
}

func (t Action) String() string {
	return string(t)
}

func self() string {
	//nolint:dogsled
	_, file, _, _ := runtime.Caller(1)
	return file
}
