// Package db2sdk provides common conversion routines from database types to codersdk types
package db2sdk

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"net/url"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/hashicorp/hcl/v2"
	"github.com/sqlc-dev/pqtype"
	"golang.org/x/xerrors"
	"tailscale.com/tailcfg"

	agentproto "github.com/coder/coder/v2/agent/proto"
	aibridgeutils "github.com/coder/coder/v2/aibridge/utils"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/rbac/policy"
	"github.com/coder/coder/v2/coderd/render"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/coderd/util/slice"
	"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
	"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/provisionersdk/proto"
	"github.com/coder/coder/v2/tailnet"
	previewtypes "github.com/coder/preview/types"
)

func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget {
	return codersdk.APIAllowListTarget{
		Type: codersdk.RBACResource(entry.Type),
		ID:   entry.ID,
	}
}

// AIProvider converts a database row plus its API keys into the
// codersdk shape. The caller is responsible for ensuring the row and
// keys have been decrypted (i.e. fetched through the dbcrypt-wrapped
// store). Each api_key is masked via aibridge utils.MaskSecret and
// write-only fields on Settings are stripped, so the result is safe
// to echo back in API responses.
func AIProvider(row database.AIProvider, keys []database.AIProviderKey) (codersdk.AIProvider, error) {
	display := row.Name
	if row.DisplayName.Valid && row.DisplayName.String != "" {
		display = row.DisplayName.String
	}
	out := codersdk.AIProvider{
		ID:          row.ID,
		Type:        codersdk.AIProviderType(row.Type),
		Name:        row.Name,
		DisplayName: display,
		Enabled:     row.Enabled,
		BaseURL:     row.BaseUrl,
		APIKeys:     maskAIProviderKeys(keys),
		CreatedAt:   row.CreatedAt,
		UpdatedAt:   row.UpdatedAt,
	}
	s, err := AIProviderSettings(row.Settings)
	if err != nil {
		return codersdk.AIProvider{}, xerrors.Errorf("decode settings: %w", err)
	}
	out.Settings = redactAIProviderSettings(s)
	return out, nil
}

// AIProviderSettings parses the on-disk JSON form back into a codersdk
// settings value. SQL NULL and the empty string decode to the zero
// value.
func AIProviderSettings(col sql.NullString) (codersdk.AIProviderSettings, error) {
	if !col.Valid || col.String == "" {
		return codersdk.AIProviderSettings{}, nil
	}
	var s codersdk.AIProviderSettings
	if err := json.Unmarshal([]byte(col.String), &s); err != nil {
		return codersdk.AIProviderSettings{}, err
	}
	return s, nil
}

// maskAIProviderKeys converts the supplied database rows into the
// public-facing AIProviderKey shape, preserving order. Plaintext is
// replaced by a non-reversible mask (see aibridgeutils.MaskSecret) so
// the result is safe to embed in API responses.
func maskAIProviderKeys(keys []database.AIProviderKey) []codersdk.AIProviderKey {
	out := make([]codersdk.AIProviderKey, 0, len(keys))
	for _, k := range keys {
		out = append(out, codersdk.AIProviderKey{
			ID:        k.ID,
			Masked:    aibridgeutils.MaskSecret(k.APIKey),
			CreatedAt: k.CreatedAt,
		})
	}
	return out
}

// redactAIProviderSettings strips write-only fields from a settings
// value so it can be safely echoed back in API responses.
func redactAIProviderSettings(s codersdk.AIProviderSettings) codersdk.AIProviderSettings {
	out := s
	if out.Bedrock != nil {
		// Deep-copy so we don't mutate the caller's struct.
		b := *out.Bedrock
		b.AccessKey = nil
		b.AccessKeySecret = nil
		out.Bedrock = &b
	}
	return out
}

type ExternalAuthMeta struct {
	Authenticated bool
	ValidateError string
}

func ExternalAuths(auths []database.ExternalAuthLink, meta map[string]ExternalAuthMeta) []codersdk.ExternalAuthLink {
	out := make([]codersdk.ExternalAuthLink, 0, len(auths))
	for _, auth := range auths {
		out = append(out, ExternalAuth(auth, meta[auth.ProviderID]))
	}
	return out
}

func ExternalAuth(auth database.ExternalAuthLink, meta ExternalAuthMeta) codersdk.ExternalAuthLink {
	return codersdk.ExternalAuthLink{
		ProviderID:      auth.ProviderID,
		CreatedAt:       auth.CreatedAt,
		UpdatedAt:       auth.UpdatedAt,
		HasRefreshToken: auth.OAuthRefreshToken != "",
		Expires:         auth.OAuthExpiry,
		Authenticated:   meta.Authenticated,
		ValidateError:   meta.ValidateError,
	}
}

func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.WorkspaceBuildParameter {
	return codersdk.WorkspaceBuildParameter{
		Name:  p.Name,
		Value: p.Value,
	}
}

func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
	return slice.List(params, WorkspaceBuildParameter)
}

func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]codersdk.TemplateVersionParameter, error) {
	out := make([]codersdk.TemplateVersionParameter, 0, len(params))
	for _, p := range params {
		np, err := TemplateVersionParameter(p)
		if err != nil {
			return nil, xerrors.Errorf("convert template version parameter %q: %w", p.Name, err)
		}
		out = append(out, np)
	}

	return out, nil
}

func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk.TemplateVersionParameter, error) {
	descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description)
	if err != nil {
		return codersdk.TemplateVersionParameter{}, err
	}

	sdkParam := codersdk.TemplateVersionParameter{
		Name:                 param.Name,
		DisplayName:          param.DisplayName,
		Description:          param.Description,
		DescriptionPlaintext: descriptionPlaintext,
		Type:                 string(param.Type),
		FormType:             string(param.FormType),
		Mutable:              param.Mutable,
		DefaultValue:         param.DefaultValue.AsString(),
		Icon:                 param.Icon,
		Required:             param.Required,
		Ephemeral:            param.Ephemeral,
		Options:              slice.List(param.Options, TemplateVersionParameterOptionFromPreview),
		// Validation set after
	}
	if len(param.Validations) > 0 {
		validation := param.Validations[0]
		sdkParam.ValidationError = validation.Error
		if validation.Monotonic != nil {
			sdkParam.ValidationMonotonic = codersdk.ValidationMonotonicOrder(*validation.Monotonic)
		}
		if validation.Regex != nil {
			sdkParam.ValidationRegex = *validation.Regex
		}
		if validation.Min != nil {
			//nolint:gosec // No other choice
			sdkParam.ValidationMin = ptr.Ref(int32(*validation.Min))
		}
		if validation.Max != nil {
			//nolint:gosec // No other choice
			sdkParam.ValidationMax = ptr.Ref(int32(*validation.Max))
		}
	}

	return sdkParam, nil
}

func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) {
	options, err := templateVersionParameterOptions(param.Options)
	if err != nil {
		return codersdk.TemplateVersionParameter{}, err
	}

	descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description)
	if err != nil {
		return codersdk.TemplateVersionParameter{}, err
	}

	var validationMin *int32
	if param.ValidationMin.Valid {
		validationMin = &param.ValidationMin.Int32
	}

	var validationMax *int32
	if param.ValidationMax.Valid {
		validationMax = &param.ValidationMax.Int32
	}

	return codersdk.TemplateVersionParameter{
		Name:                 param.Name,
		DisplayName:          param.DisplayName,
		Description:          param.Description,
		DescriptionPlaintext: descriptionPlaintext,
		Type:                 param.Type,
		FormType:             string(param.FormType),
		Mutable:              param.Mutable,
		DefaultValue:         param.DefaultValue,
		Icon:                 param.Icon,
		Options:              options,
		ValidationRegex:      param.ValidationRegex,
		ValidationMin:        validationMin,
		ValidationMax:        validationMax,
		ValidationError:      param.ValidationError,
		ValidationMonotonic:  codersdk.ValidationMonotonicOrder(param.ValidationMonotonic),
		Required:             param.Required,
		Ephemeral:            param.Ephemeral,
	}, nil
}

func MinimalUser(user database.User) codersdk.MinimalUser {
	return codersdk.MinimalUser{
		ID:        user.ID,
		Username:  user.Username,
		Name:      user.Name,
		AvatarURL: user.AvatarURL,
	}
}

func MinimalUserFromVisibleUser(user database.VisibleUser) codersdk.MinimalUser {
	return codersdk.MinimalUser{
		ID:        user.ID,
		Username:  user.Username,
		Name:      user.Name,
		AvatarURL: user.AvatarURL,
	}
}

func ReducedUser(user database.User) codersdk.ReducedUser {
	return codersdk.ReducedUser{
		MinimalUser:      MinimalUser(user),
		Email:            user.Email,
		CreatedAt:        user.CreatedAt,
		UpdatedAt:        user.UpdatedAt,
		LastSeenAt:       user.LastSeenAt,
		Status:           codersdk.UserStatus(user.Status),
		LoginType:        codersdk.LoginType(user.LoginType),
		IsServiceAccount: user.IsServiceAccount,
	}
}

func UserFromGroupMember(member database.GroupMember) database.User {
	return database.User{
		ID:                 member.UserID,
		Email:              member.UserEmail,
		Username:           member.UserUsername,
		HashedPassword:     member.UserHashedPassword,
		CreatedAt:          member.UserCreatedAt,
		UpdatedAt:          member.UserUpdatedAt,
		Status:             member.UserStatus,
		RBACRoles:          member.UserRbacRoles,
		LoginType:          member.UserLoginType,
		AvatarURL:          member.UserAvatarUrl,
		Deleted:            member.UserDeleted,
		LastSeenAt:         member.UserLastSeenAt,
		QuietHoursSchedule: member.UserQuietHoursSchedule,
		Name:               member.UserName,
		GithubComUserID:    member.UserGithubComUserID,
		IsServiceAccount:   member.UserIsServiceAccount,
	}
}

func ReducedUserFromGroupMember(member database.GroupMember) codersdk.ReducedUser {
	return ReducedUser(UserFromGroupMember(member))
}

func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.ReducedUser {
	return slice.List(members, ReducedUserFromGroupMember)
}

func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) database.User {
	return database.User{
		ID:                 member.UserID,
		Email:              member.UserEmail,
		Username:           member.UserUsername,
		HashedPassword:     member.UserHashedPassword,
		CreatedAt:          member.UserCreatedAt,
		UpdatedAt:          member.UserUpdatedAt,
		Status:             member.UserStatus,
		RBACRoles:          member.UserRbacRoles,
		LoginType:          member.UserLoginType,
		AvatarURL:          member.UserAvatarUrl,
		Deleted:            member.UserDeleted,
		LastSeenAt:         member.UserLastSeenAt,
		QuietHoursSchedule: member.UserQuietHoursSchedule,
		Name:               member.UserName,
		GithubComUserID:    member.UserGithubComUserID,
		IsServiceAccount:   member.UserIsServiceAccount,
	}
}

func ReducedUserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) codersdk.ReducedUser {
	return ReducedUser(UserFromGroupMemberRow(member))
}

func ReducedUsersFromGroupMemberRows(members []database.GetGroupMembersByGroupIDPaginatedRow) []codersdk.ReducedUser {
	return slice.List(members, ReducedUserFromGroupMemberRow)
}

func ReducedUsers(users []database.User) []codersdk.ReducedUser {
	return slice.List(users, ReducedUser)
}

func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
	convertedUser := codersdk.User{
		ReducedUser:     ReducedUser(user),
		OrganizationIDs: organizationIDs,
		Roles:           SlimRolesFromNames(user.RBACRoles),
	}

	return convertedUser
}

func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []codersdk.User {
	return slice.List(users, func(user database.User) codersdk.User {
		return User(user, organizationIDs[user.ID])
	})
}

func Group(row database.GetGroupsRow, members []database.GroupMember, totalMemberCount int) codersdk.Group {
	return codersdk.Group{
		ID:                      row.Group.ID,
		Name:                    row.Group.Name,
		DisplayName:             row.Group.DisplayName,
		OrganizationID:          row.Group.OrganizationID,
		AvatarURL:               row.Group.AvatarURL,
		Members:                 ReducedUsersFromGroupMembers(members),
		TotalMemberCount:        totalMemberCount,
		QuotaAllowance:          int(row.Group.QuotaAllowance),
		Source:                  codersdk.GroupSource(row.Group.Source),
		OrganizationName:        row.OrganizationName,
		OrganizationDisplayName: row.OrganizationDisplayName,
	}
}

func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) {
	// Use a stable sort, similarly to how we would sort in the query, note that
	// we don't sort in the query because order varies depending on the table
	// collation.
	//
	// ORDER BY utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value
	slices.SortFunc(parameterRows, func(a, b database.GetTemplateParameterInsightsRow) int {
		if a.Name != b.Name {
			return strings.Compare(a.Name, b.Name)
		}
		if a.Type != b.Type {
			return strings.Compare(a.Type, b.Type)
		}
		if a.DisplayName != b.DisplayName {
			return strings.Compare(a.DisplayName, b.DisplayName)
		}
		if a.Description != b.Description {
			return strings.Compare(a.Description, b.Description)
		}
		if string(a.Options) != string(b.Options) {
			return strings.Compare(string(a.Options), string(b.Options))
		}
		return strings.Compare(a.Value, b.Value)
	})

	parametersUsage := []codersdk.TemplateParameterUsage{}
	indexByNum := make(map[int64]int)
	for _, param := range parameterRows {
		if _, ok := indexByNum[param.Num]; !ok {
			var opts []codersdk.TemplateVersionParameterOption
			err := json.Unmarshal(param.Options, &opts)
			if err != nil {
				return nil, err
			}

			plaintextDescription, err := render.PlaintextFromMarkdown(param.Description)
			if err != nil {
				return nil, err
			}

			parametersUsage = append(parametersUsage, codersdk.TemplateParameterUsage{
				TemplateIDs: param.TemplateIDs,
				Name:        param.Name,
				Type:        param.Type,
				DisplayName: param.DisplayName,
				Description: plaintextDescription,
				Options:     opts,
			})
			indexByNum[param.Num] = len(parametersUsage) - 1
		}

		i := indexByNum[param.Num]
		parametersUsage[i].Values = append(parametersUsage[i].Values, codersdk.TemplateParameterValue{
			Value: param.Value,
			Count: param.Count,
		})
	}

	return parametersUsage, nil
}

func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.TemplateVersionParameterOption, error) {
	var protoOptions []*proto.RichParameterOption
	err := json.Unmarshal(rawOptions, &protoOptions)
	if err != nil {
		return nil, err
	}

	options := make([]codersdk.TemplateVersionParameterOption, 0)
	for _, option := range protoOptions {
		options = append(options, codersdk.TemplateVersionParameterOption{
			Name:        option.Name,
			Description: option.Description,
			Value:       option.Value,
			Icon:        option.Icon,
		})
	}
	return options, nil
}

func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOption) codersdk.TemplateVersionParameterOption {
	return codersdk.TemplateVersionParameterOption{
		Name:        option.Name,
		Description: option.Description,
		Value:       option.Value.AsString(),
		Icon:        option.Icon,
	}
}

func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
	return codersdk.OAuth2ProviderApp{
		ID:          dbApp.ID,
		Name:        dbApp.Name,
		CallbackURL: dbApp.CallbackURL,
		Icon:        dbApp.Icon,
		Endpoints: codersdk.OAuth2AppEndpoints{
			Authorization: accessURL.ResolveReference(&url.URL{
				Path: "/oauth2/authorize",
			}).String(),
			Token: accessURL.ResolveReference(&url.URL{
				Path: "/oauth2/tokens",
			}).String(),
			// We do not currently support DeviceAuth.
			DeviceAuth: "",
			TokenRevoke: accessURL.ResolveReference(&url.URL{
				Path: "/oauth2/revoke",
			}).String(),
		},
	}
}

func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp {
	return slice.List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
		return OAuth2ProviderApp(accessURL, dbApp)
	})
}

func convertDisplayApps(apps []database.DisplayApp) []codersdk.DisplayApp {
	dapps := make([]codersdk.DisplayApp, 0, len(apps))
	for _, app := range apps {
		switch codersdk.DisplayApp(app) {
		case codersdk.DisplayAppVSCodeDesktop, codersdk.DisplayAppVSCodeInsiders, codersdk.DisplayAppPortForward, codersdk.DisplayAppWebTerminal, codersdk.DisplayAppSSH:
			dapps = append(dapps, codersdk.DisplayApp(app))
		}
	}

	return dapps
}

func WorkspaceAgentEnvironment(workspaceAgent database.WorkspaceAgent) (map[string]string, error) {
	var envs map[string]string
	if workspaceAgent.EnvironmentVariables.Valid {
		err := json.Unmarshal(workspaceAgent.EnvironmentVariables.RawMessage, &envs)
		if err != nil {
			return nil, xerrors.Errorf("unmarshal environment variables: %w", err)
		}
	}

	return envs, nil
}

func WorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator,
	dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, scripts []codersdk.WorkspaceAgentScript, logSources []codersdk.WorkspaceAgentLogSource,
	agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string,
) (codersdk.WorkspaceAgent, error) {
	envs, err := WorkspaceAgentEnvironment(dbAgent)
	if err != nil {
		return codersdk.WorkspaceAgent{}, err
	}
	troubleshootingURL := agentFallbackTroubleshootingURL
	if dbAgent.TroubleshootingURL != "" {
		troubleshootingURL = dbAgent.TroubleshootingURL
	}
	subsystems := make([]codersdk.AgentSubsystem, len(dbAgent.Subsystems))
	for i, subsystem := range dbAgent.Subsystems {
		subsystems[i] = codersdk.AgentSubsystem(subsystem)
	}

	legacyStartupScriptBehavior := codersdk.WorkspaceAgentStartupScriptBehaviorNonBlocking
	for _, script := range scripts {
		if !script.RunOnStart {
			continue
		}
		if !script.StartBlocksLogin {
			continue
		}
		legacyStartupScriptBehavior = codersdk.WorkspaceAgentStartupScriptBehaviorBlocking
	}

	workspaceAgent := codersdk.WorkspaceAgent{
		ID:                       dbAgent.ID,
		ParentID:                 dbAgent.ParentID,
		CreatedAt:                dbAgent.CreatedAt,
		UpdatedAt:                dbAgent.UpdatedAt,
		ResourceID:               dbAgent.ResourceID,
		InstanceID:               dbAgent.AuthInstanceID.String,
		Name:                     dbAgent.Name,
		Architecture:             dbAgent.Architecture,
		OperatingSystem:          dbAgent.OperatingSystem,
		Scripts:                  scripts,
		StartupScriptBehavior:    legacyStartupScriptBehavior,
		LogsLength:               dbAgent.LogsLength,
		LogsOverflowed:           dbAgent.LogsOverflowed,
		LogSources:               logSources,
		Version:                  dbAgent.Version,
		APIVersion:               dbAgent.APIVersion,
		EnvironmentVariables:     envs,
		Directory:                dbAgent.Directory,
		ExpandedDirectory:        dbAgent.ExpandedDirectory,
		Apps:                     apps,
		ConnectionTimeoutSeconds: dbAgent.ConnectionTimeoutSeconds,
		TroubleshootingURL:       troubleshootingURL,
		LifecycleState:           codersdk.WorkspaceAgentLifecycle(dbAgent.LifecycleState),
		Subsystems:               subsystems,
		DisplayApps:              convertDisplayApps(dbAgent.DisplayApps),
	}
	node := coordinator.Node(dbAgent.ID)
	if node != nil {
		workspaceAgent.DERPLatency = map[string]codersdk.DERPRegion{}
		for rawRegion, latency := range node.DERPLatency {
			regionParts := strings.SplitN(rawRegion, "-", 2)
			regionID, err := strconv.Atoi(regionParts[0])
			if err != nil {
				return codersdk.WorkspaceAgent{}, xerrors.Errorf("convert derp region id %q: %w", rawRegion, err)
			}
			region, found := derpMap.Regions[regionID]
			if !found {
				// It's possible that a workspace agent is using an old DERPMap
				// and reports regions that do not exist. If that's the case,
				// report the region as unknown!
				region = &tailcfg.DERPRegion{
					RegionID:   regionID,
					RegionName: fmt.Sprintf("Unnamed %d", regionID),
				}
			}
			workspaceAgent.DERPLatency[region.RegionName] = codersdk.DERPRegion{
				Preferred:           node.PreferredDERP == regionID,
				LatencyMilliseconds: latency * 1000,
			}
		}
	}

	status := dbAgent.Status(dbtime.Now(), agentInactiveDisconnectTimeout)
	workspaceAgent.Status = codersdk.WorkspaceAgentStatus(status.Status)
	workspaceAgent.FirstConnectedAt = status.FirstConnectedAt
	workspaceAgent.LastConnectedAt = status.LastConnectedAt
	workspaceAgent.DisconnectedAt = status.DisconnectedAt

	if dbAgent.StartedAt.Valid {
		workspaceAgent.StartedAt = &dbAgent.StartedAt.Time
	}
	if dbAgent.ReadyAt.Valid {
		workspaceAgent.ReadyAt = &dbAgent.ReadyAt.Time
	}

	switch {
	case workspaceAgent.Status != codersdk.WorkspaceAgentConnected && workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleOff:
		workspaceAgent.Health.Reason = "agent is not running"
	case workspaceAgent.Status == codersdk.WorkspaceAgentConnecting:
		// Note: the case above catches connecting+off as "not running".
		// This case handles connecting agents with a non-off lifecycle
		// (e.g. "created" or "starting"), where the agent binary has
		// not yet established a connection to coderd.
		workspaceAgent.Health.Reason = "agent has not yet connected"
	case workspaceAgent.Status == codersdk.WorkspaceAgentTimeout:
		workspaceAgent.Health.Reason = "agent is taking too long to connect"
	case workspaceAgent.Status == codersdk.WorkspaceAgentDisconnected:
		workspaceAgent.Health.Reason = "agent has lost connection"
	// Note: We could also handle codersdk.WorkspaceAgentLifecycleStartTimeout
	// here, but it's more of a soft issue, so we don't want to mark the agent
	// as unhealthy.
	case workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleStartError:
		workspaceAgent.Health.Reason = "agent startup script exited with an error"
	case workspaceAgent.LifecycleState.ShuttingDown():
		workspaceAgent.Health.Reason = "agent is shutting down"
	default:
		workspaceAgent.Health.Healthy = true
	}

	return workspaceAgent, nil
}

func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerName string) string {
	if !dbApp.Subdomain || agentName == "" || ownerName == "" || workspaceName == "" {
		return ""
	}

	appSlug := dbApp.Slug
	if appSlug == "" {
		appSlug = dbApp.DisplayName
	}

	// Agent name is optional when app slug is present
	normalizedAgentName := agentName
	if !appurl.PortRegex.MatchString(appSlug) {
		normalizedAgentName = ""
	}

	return appurl.ApplicationURL{
		// We never generate URLs with a prefix. We only allow prefixes when
		// parsing URLs from the hostname. Users that want this feature can
		// write out their own URLs.
		Prefix:        "",
		AppSlugOrPort: appSlug,
		AgentName:     normalizedAgentName,
		WorkspaceName: workspaceName,
		Username:      ownerName,
	}.String()
}

func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.WorkspaceTable) []codersdk.WorkspaceApp {
	sort.Slice(dbApps, func(i, j int) bool {
		if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder {
			return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder
		}
		if dbApps[i].DisplayName != dbApps[j].DisplayName {
			return dbApps[i].DisplayName < dbApps[j].DisplayName
		}
		return dbApps[i].Slug < dbApps[j].Slug
	})

	statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{}
	for _, status := range statuses {
		statusesByAppID[status.AppID] = append(statusesByAppID[status.AppID], status)
	}

	apps := make([]codersdk.WorkspaceApp, 0)
	for _, dbApp := range dbApps {
		statuses := statusesByAppID[dbApp.ID]
		apps = append(apps, codersdk.WorkspaceApp{
			ID:            dbApp.ID,
			URL:           dbApp.Url.String,
			External:      dbApp.External,
			Slug:          dbApp.Slug,
			DisplayName:   dbApp.DisplayName,
			Command:       dbApp.Command.String,
			Icon:          dbApp.Icon,
			Subdomain:     dbApp.Subdomain,
			SubdomainName: AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName),
			SharingLevel:  codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel),
			Healthcheck: codersdk.Healthcheck{
				URL:       dbApp.HealthcheckUrl,
				Interval:  dbApp.HealthcheckInterval,
				Threshold: dbApp.HealthcheckThreshold,
			},
			Health:   codersdk.WorkspaceAppHealth(dbApp.Health),
			Group:    dbApp.DisplayGroup.String,
			Hidden:   dbApp.Hidden,
			OpenIn:   codersdk.WorkspaceAppOpenIn(dbApp.OpenIn),
			Tooltip:  dbApp.Tooltip,
			Statuses: WorkspaceAppStatuses(statuses),
		})
	}
	return apps
}

func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus {
	return slice.List(statuses, WorkspaceAppStatus)
}

func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus {
	return codersdk.WorkspaceAppStatus{
		ID:          status.ID,
		CreatedAt:   status.CreatedAt,
		WorkspaceID: status.WorkspaceID,
		AgentID:     status.AgentID,
		AppID:       status.AppID,
		URI:         status.Uri.String,
		Message:     status.Message,
		State:       codersdk.WorkspaceAppStatusState(status.State),
	}
}

func ProvisionerJobLog(log database.ProvisionerJobLog) codersdk.ProvisionerJobLog {
	return codersdk.ProvisionerJobLog{
		ID:        log.ID,
		CreatedAt: log.CreatedAt,
		Source:    codersdk.LogSource(log.Source),
		Level:     codersdk.LogLevel(log.Level),
		Stage:     log.Stage,
		Output:    log.Output,
	}
}

func WorkspaceAgentLog(log database.WorkspaceAgentLog) codersdk.WorkspaceAgentLog {
	return codersdk.WorkspaceAgentLog{
		ID:        log.ID,
		CreatedAt: log.CreatedAt,
		Output:    log.Output,
		Level:     codersdk.LogLevel(log.Level),
		SourceID:  log.LogSourceID,
	}
}

func WorkspaceAgentScript(dbScript database.GetWorkspaceAgentScriptsByAgentIDsRow) codersdk.WorkspaceAgentScript {
	script := codersdk.WorkspaceAgentScript{
		ID:               dbScript.ID,
		LogPath:          dbScript.LogPath,
		LogSourceID:      dbScript.LogSourceID,
		Script:           dbScript.Script,
		Cron:             dbScript.Cron,
		RunOnStart:       dbScript.RunOnStart,
		RunOnStop:        dbScript.RunOnStop,
		StartBlocksLogin: dbScript.StartBlocksLogin,
		Timeout:          time.Duration(dbScript.TimeoutSeconds) * time.Second,
		DisplayName:      dbScript.DisplayName,
		ExitCode:         nullInt32Ptr(dbScript.ExitCode),
	}
	if dbScript.Status.Valid {
		status := codersdk.WorkspaceAgentScriptStatus(dbScript.Status.WorkspaceAgentScriptTimingStatus)
		script.Status = &status
	}
	return script
}

func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon {
	result := codersdk.ProvisionerDaemon{
		ID:             dbDaemon.ID,
		OrganizationID: dbDaemon.OrganizationID,
		CreatedAt:      dbDaemon.CreatedAt,
		LastSeenAt:     codersdk.NullTime{NullTime: dbDaemon.LastSeenAt},
		Name:           dbDaemon.Name,
		Tags:           dbDaemon.Tags,
		Version:        dbDaemon.Version,
		APIVersion:     dbDaemon.APIVersion,
		KeyID:          dbDaemon.KeyID,
	}
	for _, provisionerType := range dbDaemon.Provisioners {
		result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType))
	}
	return result
}

func RecentProvisionerDaemons(now time.Time, staleInterval time.Duration, daemons []database.ProvisionerDaemon) []codersdk.ProvisionerDaemon {
	results := []codersdk.ProvisionerDaemon{}

	for _, daemon := range daemons {
		// Daemon never connected, skip.
		if !daemon.LastSeenAt.Valid {
			continue
		}
		// Daemon has gone away, skip.
		if now.Sub(daemon.LastSeenAt.Time) > staleInterval {
			continue
		}

		results = append(results, ProvisionerDaemon(daemon))
	}

	// Ensure stable order for display and for tests
	sort.Slice(results, func(i, j int) bool {
		return results[i].Name < results[j].Name
	})

	return results
}

func SlimRole(role rbac.Role) codersdk.SlimRole {
	orgID := ""
	if role.Identifier.OrganizationID != uuid.Nil {
		orgID = role.Identifier.OrganizationID.String()
	}

	return codersdk.SlimRole{
		DisplayName:    role.DisplayName,
		Name:           role.Identifier.Name,
		OrganizationID: orgID,
	}
}

func SlimRolesFromNames(names []string) []codersdk.SlimRole {
	convertedRoles := make([]codersdk.SlimRole, 0, len(names))

	for _, name := range names {
		convertedRoles = append(convertedRoles, SlimRoleFromName(name))
	}

	return convertedRoles
}

func SlimRoleFromName(name string) codersdk.SlimRole {
	rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name})
	var convertedRole codersdk.SlimRole
	if err == nil {
		convertedRole = SlimRole(rbacRole)
	} else {
		convertedRole = codersdk.SlimRole{Name: name}
	}
	return convertedRole
}

func RBACRole(role rbac.Role) codersdk.Role {
	slim := SlimRole(role)

	orgPerms := role.ByOrgID[slim.OrganizationID]
	return codersdk.Role{
		Name:                          slim.Name,
		OrganizationID:                slim.OrganizationID,
		DisplayName:                   slim.DisplayName,
		SitePermissions:               slice.List(role.Site, RBACPermission),
		UserPermissions:               slice.List(role.User, RBACPermission),
		OrganizationPermissions:       slice.List(orgPerms.Org, RBACPermission),
		OrganizationMemberPermissions: slice.List(orgPerms.Member, RBACPermission),
	}
}

func Role(role database.CustomRole) codersdk.Role {
	orgID := ""
	if role.OrganizationID.UUID != uuid.Nil {
		orgID = role.OrganizationID.UUID.String()
	}

	return codersdk.Role{
		Name:                    role.Name,
		OrganizationID:          orgID,
		DisplayName:             role.DisplayName,
		SitePermissions:         slice.List(role.SitePermissions, Permission),
		UserPermissions:         slice.List(role.UserPermissions, Permission),
		OrganizationPermissions: slice.List(role.OrgPermissions, Permission),
	}
}

func Permission(permission database.CustomRolePermission) codersdk.Permission {
	return codersdk.Permission{
		Negate:       permission.Negate,
		ResourceType: codersdk.RBACResource(permission.ResourceType),
		Action:       codersdk.RBACAction(permission.Action),
	}
}

func RBACPermission(permission rbac.Permission) codersdk.Permission {
	return codersdk.Permission{
		Negate:       permission.Negate,
		ResourceType: codersdk.RBACResource(permission.ResourceType),
		Action:       codersdk.RBACAction(permission.Action),
	}
}

func Organization(organization database.Organization) codersdk.Organization {
	return codersdk.Organization{
		MinimalOrganization: codersdk.MinimalOrganization{
			ID:          organization.ID,
			Name:        organization.Name,
			DisplayName: organization.DisplayName,
			Icon:        organization.Icon,
		},
		Description: organization.Description,
		CreatedAt:   organization.CreatedAt,
		UpdatedAt:   organization.UpdatedAt,
		IsDefault:   organization.IsDefault,
	}
}

func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
	return slice.List(keys, CryptoKey)
}

func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
	return codersdk.CryptoKey{
		Feature:   codersdk.CryptoKeyFeature(key.Feature),
		Sequence:  key.Sequence,
		StartsAt:  key.StartsAt,
		DeletesAt: key.DeletesAt.Time,
		Secret:    key.Secret.String,
	}
}

func MatchedProvisioners(provisionerDaemons []database.ProvisionerDaemon, now time.Time, staleInterval time.Duration) codersdk.MatchedProvisioners {
	minLastSeenAt := now.Add(-staleInterval)
	mostRecentlySeen := codersdk.NullTime{}
	var matched codersdk.MatchedProvisioners
	for _, provisioner := range provisionerDaemons {
		if !provisioner.LastSeenAt.Valid {
			continue
		}
		matched.Count++
		if provisioner.LastSeenAt.Time.After(minLastSeenAt) {
			matched.Available++
		}
		if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) {
			matched.MostRecentlySeen.Valid = true
			matched.MostRecentlySeen.Time = provisioner.LastSeenAt.Time
		}
	}
	return matched
}

func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action {
	switch role {
	case codersdk.TemplateRoleAdmin:
		return []policy.Action{policy.WildcardSymbol}
	case codersdk.TemplateRoleUse:
		return []policy.Action{policy.ActionRead, policy.ActionUse}
	}
	return []policy.Action{}
}

func WorkspaceRoleActions(role codersdk.WorkspaceRole) []policy.Action {
	switch role {
	case codersdk.WorkspaceRoleAdmin:
		return slice.Omit(
			// Small note: This intentionally includes "create" because it's sort of
			// double purposed as "can edit ACL". That's maybe a bit "incorrect", but
			// it's what templates do already and we're copying that implementation.
			rbac.ResourceWorkspace.AvailableActions(),
			// Don't let anyone delete something they can't recreate.
			policy.ActionDelete,
		)
	case codersdk.WorkspaceRoleUse:
		return []policy.Action{
			policy.ActionApplicationConnect,
			policy.ActionRead,
			policy.ActionSSH,
			policy.ActionWorkspaceStart,
			policy.ActionWorkspaceStop,
		}
	}
	return []policy.Action{}
}

func ChatRoleActions(role codersdk.ChatRole) []policy.Action {
	if role == codersdk.ChatRoleRead {
		return []policy.Action{policy.ActionRead}
	}
	return []policy.Action{}
}

func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) {
	switch typ {
	case agentproto.Connection_SSH:
		return database.ConnectionTypeSsh, nil
	case agentproto.Connection_JETBRAINS:
		return database.ConnectionTypeJetbrains, nil
	case agentproto.Connection_VSCODE:
		return database.ConnectionTypeVscode, nil
	case agentproto.Connection_RECONNECTING_PTY:
		return database.ConnectionTypeReconnectingPty, nil
	default:
		// Also Connection_TYPE_UNSPECIFIED, no mapping.
		return "", xerrors.Errorf("unknown agent connection type %q", typ)
	}
}

func ConnectionLogStatusFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionStatus, error) {
	switch action {
	case agentproto.Connection_CONNECT:
		return database.ConnectionStatusConnected, nil
	case agentproto.Connection_DISCONNECT:
		return database.ConnectionStatusDisconnected, nil
	default:
		// Also Connection_ACTION_UNSPECIFIED, no mapping.
		return "", xerrors.Errorf("unknown agent connection action %q", action)
	}
}

func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter {
	return codersdk.PreviewParameter{
		PreviewParameterData: codersdk.PreviewParameterData{
			Name:        param.Name,
			DisplayName: param.DisplayName,
			Description: param.Description,
			Type:        codersdk.OptionType(param.Type),
			FormType:    codersdk.ParameterFormType(param.FormType),
			Styling: codersdk.PreviewParameterStyling{
				Placeholder: param.Styling.Placeholder,
				Disabled:    param.Styling.Disabled,
				Label:       param.Styling.Label,
				MaskInput:   param.Styling.MaskInput,
			},
			Mutable:      param.Mutable,
			DefaultValue: PreviewHCLString(param.DefaultValue),
			Icon:         param.Icon,
			Options:      slice.List(param.Options, PreviewParameterOption),
			Validations:  slice.List(param.Validations, PreviewParameterValidation),
			Required:     param.Required,
			Order:        param.Order,
			Ephemeral:    param.Ephemeral,
		},
		Value:       PreviewHCLString(param.Value),
		Diagnostics: PreviewDiagnostics(param.Diagnostics),
	}
}

func HCLDiagnostics(d hcl.Diagnostics) []codersdk.FriendlyDiagnostic {
	return PreviewDiagnostics(previewtypes.Diagnostics(d))
}

func PreviewDiagnostics(d previewtypes.Diagnostics) []codersdk.FriendlyDiagnostic {
	f := d.FriendlyDiagnostics()
	return slice.List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
		return codersdk.FriendlyDiagnostic{
			Severity: codersdk.DiagnosticSeverityString(f.Severity),
			Summary:  f.Summary,
			Detail:   f.Detail,
			Extra: codersdk.DiagnosticExtra{
				Code: f.Extra.Code,
			},
		}
	})
}

func PreviewHCLString(h previewtypes.HCLString) codersdk.NullHCLString {
	n := h.NullHCLString()
	return codersdk.NullHCLString{
		Value: n.Value,
		Valid: n.Valid,
	}
}

func PreviewParameterOption(o *previewtypes.ParameterOption) codersdk.PreviewParameterOption {
	if o == nil {
		// This should never be sent
		return codersdk.PreviewParameterOption{}
	}
	return codersdk.PreviewParameterOption{
		Name:        o.Name,
		Description: o.Description,
		Value:       PreviewHCLString(o.Value),
		Icon:        o.Icon,
	}
}

func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.PreviewParameterValidation {
	if v == nil {
		// This should never be sent
		return codersdk.PreviewParameterValidation{}
	}
	return codersdk.PreviewParameterValidation{
		Error:     v.Error,
		Regex:     v.Regex,
		Min:       v.Min,
		Max:       v.Max,
		Monotonic: v.Monotonic,
	}
}

func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
	sdkTokenUsages := slice.List(tokenUsages, AIBridgeTokenUsage)
	sort.Slice(sdkTokenUsages, func(i, j int) bool {
		// created_at ASC
		return sdkTokenUsages[i].CreatedAt.Before(sdkTokenUsages[j].CreatedAt)
	})
	sdkUserPrompts := slice.List(userPrompts, AIBridgeUserPrompt)
	sort.Slice(sdkUserPrompts, func(i, j int) bool {
		// created_at ASC
		return sdkUserPrompts[i].CreatedAt.Before(sdkUserPrompts[j].CreatedAt)
	})
	sdkToolUsages := slice.List(toolUsages, AIBridgeToolUsage)
	sort.Slice(sdkToolUsages, func(i, j int) bool {
		// created_at ASC
		return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
	})
	intc := codersdk.AIBridgeInterception{
		ID:           interception.ID,
		Initiator:    MinimalUserFromVisibleUser(initiator),
		Provider:     interception.Provider,
		ProviderName: interception.ProviderName,
		Model:        interception.Model,
		Metadata:     jsonOrEmptyMap(interception.Metadata),
		StartedAt:    interception.StartedAt,
		TokenUsages:  sdkTokenUsages,
		UserPrompts:  sdkUserPrompts,
		ToolUsages:   sdkToolUsages,
	}
	if interception.APIKeyID.Valid {
		intc.APIKeyID = &interception.APIKeyID.String
	}
	if interception.EndedAt.Valid {
		intc.EndedAt = &interception.EndedAt.Time
	}
	if interception.Client.Valid {
		intc.Client = &interception.Client.String
	}
	return intc
}

func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSession {
	session := codersdk.AIBridgeSession{
		ID: row.SessionID,
		Initiator: MinimalUserFromVisibleUser(database.VisibleUser{
			ID:        row.UserID,
			Username:  row.UserUsername,
			Name:      row.UserName,
			AvatarURL: row.UserAvatarUrl,
		}),
		Providers:    row.Providers,
		Models:       row.Models,
		Metadata:     jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
		StartedAt:    row.StartedAt,
		Threads:      row.Threads,
		LastActiveAt: row.LastActiveAt,
		TokenUsageSummary: codersdk.AIBridgeSessionTokenUsageSummary{
			InputTokens:           row.InputTokens,
			OutputTokens:          row.OutputTokens,
			CacheReadInputTokens:  row.CacheReadInputTokens,
			CacheWriteInputTokens: row.CacheWriteInputTokens,
		},
	}
	// Ensure non-nil slices for JSON serialization.
	if session.Providers == nil {
		session.Providers = []string{}
	}
	if session.Models == nil {
		session.Models = []string{}
	}
	if row.Client != "" {
		session.Client = &row.Client
	}
	if !row.EndedAt.IsZero() {
		session.EndedAt = &row.EndedAt
	}
	if row.LastPrompt != "" {
		session.LastPrompt = &row.LastPrompt
	}
	return session
}

func AIBridgeTokenUsage(usage database.AIBridgeTokenUsage) codersdk.AIBridgeTokenUsage {
	return codersdk.AIBridgeTokenUsage{
		ID:                    usage.ID,
		InterceptionID:        usage.InterceptionID,
		ProviderResponseID:    usage.ProviderResponseID,
		InputTokens:           usage.InputTokens,
		OutputTokens:          usage.OutputTokens,
		CacheReadInputTokens:  usage.CacheReadInputTokens,
		CacheWriteInputTokens: usage.CacheWriteInputTokens,
		Metadata:              jsonOrEmptyMap(usage.Metadata),
		CreatedAt:             usage.CreatedAt,
	}
}

func AIBridgeUserPrompt(prompt database.AIBridgeUserPrompt) codersdk.AIBridgeUserPrompt {
	return codersdk.AIBridgeUserPrompt{
		ID:                 prompt.ID,
		InterceptionID:     prompt.InterceptionID,
		ProviderResponseID: prompt.ProviderResponseID,
		Prompt:             prompt.Prompt,
		Metadata:           jsonOrEmptyMap(prompt.Metadata),
		CreatedAt:          prompt.CreatedAt,
	}
}

func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUsage {
	return codersdk.AIBridgeToolUsage{
		ID:                 usage.ID,
		InterceptionID:     usage.InterceptionID,
		ProviderResponseID: usage.ProviderResponseID,
		ServerURL:          usage.ServerUrl.String,
		Tool:               usage.Tool,
		Input:              usage.Input,
		Injected:           usage.Injected,
		InvocationError:    usage.InvocationError.String,
		Metadata:           jsonOrEmptyMap(usage.Metadata),
		CreatedAt:          usage.CreatedAt,
	}
}

// AIBridgeSessionThreads converts session metadata and thread interceptions
// into the threads response. It groups interceptions into threads, builds
// agentic actions from tool usages and model thoughts, and aggregates
// token usage with metadata.
func AIBridgeSessionThreads(
	session database.ListAIBridgeSessionsRow,
	interceptions []database.ListAIBridgeSessionThreadsRow,
	tokenUsages []database.AIBridgeTokenUsage,
	toolUsages []database.AIBridgeToolUsage,
	userPrompts []database.AIBridgeUserPrompt,
	modelThoughts []database.AIBridgeModelThought,
) codersdk.AIBridgeSessionThreadsResponse {
	// Index subresources by interception ID.
	tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions))
	for _, tu := range tokenUsages {
		tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu)
	}
	toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions))
	for _, tu := range toolUsages {
		toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu)
	}
	promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions))
	for _, up := range userPrompts {
		promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up)
	}
	thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions))
	for _, mt := range modelThoughts {
		thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt)
	}

	// Group interceptions by thread_id, preserving the order returned by the
	// SQL query.
	interceptionsByThread := make(map[uuid.UUID][]database.AIBridgeInterception, len(interceptions))
	var threadIDs []uuid.UUID
	for _, row := range interceptions {
		if _, ok := interceptionsByThread[row.ThreadID]; !ok {
			threadIDs = append(threadIDs, row.ThreadID)
		}
		interceptionsByThread[row.ThreadID] = append(interceptionsByThread[row.ThreadID], row.AIBridgeInterception)
	}

	// Build threads and track page time bounds.
	threads := make([]codersdk.AIBridgeThread, 0, len(threadIDs))
	var pageStartedAt, pageEndedAt *time.Time
	for _, threadID := range threadIDs {
		intcs := interceptionsByThread[threadID]
		thread := buildAIBridgeThread(threadID, intcs, tokensByInterception, toolsByInterception, promptsByInterception, thoughtsByInterception)
		for _, intc := range intcs {
			if pageStartedAt == nil || intc.StartedAt.Before(*pageStartedAt) {
				t := intc.StartedAt
				pageStartedAt = &t
			}
			if intc.EndedAt.Valid {
				if pageEndedAt == nil || intc.EndedAt.Time.After(*pageEndedAt) {
					t := intc.EndedAt.Time
					pageEndedAt = &t
				}
			}
		}
		threads = append(threads, thread)
	}

	// Aggregate session-level token usage metadata from all token
	// usages in the session (not just the page).
	sessionTokenMeta := aggregateTokenMetadata(tokenUsages)

	resp := codersdk.AIBridgeSessionThreadsResponse{
		ID: session.SessionID,
		Initiator: MinimalUserFromVisibleUser(database.VisibleUser{
			ID:        session.UserID,
			Username:  session.UserUsername,
			Name:      session.UserName,
			AvatarURL: session.UserAvatarUrl,
		}),
		Providers:     session.Providers,
		Models:        session.Models,
		Metadata:      jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: session.Metadata, Valid: len(session.Metadata) > 0}),
		StartedAt:     session.StartedAt,
		PageStartedAt: pageStartedAt,
		PageEndedAt:   pageEndedAt,
		TokenUsageSummary: codersdk.AIBridgeSessionThreadsTokenUsage{
			InputTokens:           session.InputTokens,
			OutputTokens:          session.OutputTokens,
			CacheReadInputTokens:  session.CacheReadInputTokens,
			CacheWriteInputTokens: session.CacheWriteInputTokens,
			Metadata:              sessionTokenMeta,
		},
		Threads: threads,
	}
	if resp.Providers == nil {
		resp.Providers = []string{}
	}
	if resp.Models == nil {
		resp.Models = []string{}
	}
	if session.Client != "" {
		resp.Client = &session.Client
	}
	if !session.EndedAt.IsZero() {
		resp.EndedAt = &session.EndedAt
	}
	return resp
}

func buildAIBridgeThread(
	threadID uuid.UUID,
	interceptions []database.AIBridgeInterception,
	tokensByInterception map[uuid.UUID][]database.AIBridgeTokenUsage,
	toolsByInterception map[uuid.UUID][]database.AIBridgeToolUsage,
	promptsByInterception map[uuid.UUID][]database.AIBridgeUserPrompt,
	thoughtsByInterception map[uuid.UUID][]database.AIBridgeModelThought,
) codersdk.AIBridgeThread {
	// Find the root interception (where id == threadID) to get the
	// thread prompt and model.
	var rootIntc *database.AIBridgeInterception
	for i := range interceptions {
		if interceptions[i].ID == threadID {
			rootIntc = &interceptions[i]
			break
		}
	}
	// Fallback to first interception if root not found.
	if rootIntc == nil && len(interceptions) > 0 {
		rootIntc = &interceptions[0]
	}

	thread := codersdk.AIBridgeThread{
		ID: threadID,
	}
	if rootIntc != nil {
		thread.Model = rootIntc.Model
		thread.Provider = rootIntc.Provider
		thread.CredentialKind = string(rootIntc.CredentialKind)
		thread.CredentialHint = sanitizeCredentialHint(rootIntc.CredentialHint)
		// Get first user prompt from root interception.
		// A thread can only have one prompt, by definition, since we currently
		// only store the last prompt observed in an interception.
		if prompts := promptsByInterception[rootIntc.ID]; len(prompts) > 0 {
			thread.Prompt = &prompts[0].Prompt
		}
	}

	// Compute thread time bounds from interceptions.
	for _, intc := range interceptions {
		if thread.StartedAt.IsZero() || intc.StartedAt.Before(thread.StartedAt) {
			thread.StartedAt = intc.StartedAt
		}
		if intc.EndedAt.Valid {
			if thread.EndedAt == nil || intc.EndedAt.Time.After(*thread.EndedAt) {
				t := intc.EndedAt.Time
				thread.EndedAt = &t
			}
		}
	}

	// Build agentic actions grouped by interception. Each interception that
	// has tool calls produces one action with all its tool calls, thinking
	// blocks, and token usage.
	var actions []codersdk.AIBridgeAgenticAction
	for _, intc := range interceptions {
		tools := toolsByInterception[intc.ID]
		if len(tools) == 0 {
			continue
		}

		// Thinking blocks for this interception.
		thoughts := thoughtsByInterception[intc.ID]
		thinking := make([]codersdk.AIBridgeModelThought, 0, len(thoughts))
		for _, mt := range thoughts {
			thinking = append(thinking, codersdk.AIBridgeModelThought{
				Text: mt.Content,
			})
		}

		// Token usage for the interception.
		actionTokenUsage := aggregateTokenUsage(tokensByInterception[intc.ID])

		// Build tool call list.
		toolCalls := make([]codersdk.AIBridgeToolCall, 0, len(tools))
		for _, tu := range tools {
			toolCalls = append(toolCalls, codersdk.AIBridgeToolCall{
				ID:                 tu.ID,
				InterceptionID:     tu.InterceptionID,
				ProviderResponseID: tu.ProviderResponseID,
				ServerURL:          tu.ServerUrl.String,
				Tool:               tu.Tool,
				Injected:           tu.Injected,
				Input:              tu.Input,
				Metadata:           jsonOrEmptyMap(tu.Metadata),
				CreatedAt:          tu.CreatedAt,
			})
		}

		actions = append(actions, codersdk.AIBridgeAgenticAction{
			Model:      intc.Model,
			TokenUsage: actionTokenUsage,
			Thinking:   thinking,
			ToolCalls:  toolCalls,
		})
	}

	if actions == nil {
		// Make an empty slice so we don't serialize `null`.
		actions = make([]codersdk.AIBridgeAgenticAction, 0)
	}

	thread.AgenticActions = actions

	// Aggregate thread-level token usage.
	var threadTokens []database.AIBridgeTokenUsage
	for _, intc := range interceptions {
		threadTokens = append(threadTokens, tokensByInterception[intc.ID]...)
	}
	thread.TokenUsage = aggregateTokenUsage(threadTokens)

	return thread
}

// aggregateTokenUsage sums token usage rows and aggregates metadata.
func aggregateTokenUsage(tokens []database.AIBridgeTokenUsage) codersdk.AIBridgeSessionThreadsTokenUsage {
	var inputTokens, outputTokens, cacheRead, cacheWrite int64
	for _, tu := range tokens {
		inputTokens += tu.InputTokens
		outputTokens += tu.OutputTokens
		cacheRead += tu.CacheReadInputTokens
		cacheWrite += tu.CacheWriteInputTokens
	}
	return codersdk.AIBridgeSessionThreadsTokenUsage{
		InputTokens:           inputTokens,
		OutputTokens:          outputTokens,
		CacheReadInputTokens:  cacheRead,
		CacheWriteInputTokens: cacheWrite,
		Metadata:              aggregateTokenMetadata(tokens),
	}
}

// aggregateTokenMetadata sums all numeric values from the metadata
// JSONB across the given token usage rows by key. Nested objects are
// flattened using dot-notation (e.g. {"cache": {"read_tokens": 10}}
// becomes "cache.read_tokens"). Non-numeric leaves (strings,
// booleans, arrays, nulls) are silently skipped.
func aggregateTokenMetadata(tokens []database.AIBridgeTokenUsage) map[string]any {
	sums := make(map[string]int64)
	for _, tu := range tokens {
		if !tu.Metadata.Valid || len(tu.Metadata.RawMessage) == 0 {
			continue
		}
		var m map[string]json.RawMessage
		if err := json.Unmarshal(tu.Metadata.RawMessage, &m); err != nil {
			continue
		}
		flattenAndSum(sums, "", m)
	}
	result := make(map[string]any, len(sums))
	for k, v := range sums {
		result[k] = v
	}
	return result
}

// flattenAndSum recursively walks a JSON object and sums all numeric
// leaf values into sums, using dot-separated keys for nested objects.
func flattenAndSum(sums map[string]int64, prefix string, m map[string]json.RawMessage) {
	for k, raw := range m {
		key := k
		if prefix != "" {
			key = prefix + "." + k
		}

		// Try as a number first.
		var n json.Number
		if err := json.Unmarshal(raw, &n); err == nil {
			if v, err := n.Int64(); err == nil {
				sums[key] += v
			}
			continue
		}

		// Try as a nested object.
		var nested map[string]json.RawMessage
		if err := json.Unmarshal(raw, &nested); err == nil {
			flattenAndSum(sums, key, nested)
		}
		// Arrays, strings, booleans, nulls are skipped.
	}
}

func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget {
	return codersdk.GroupAIBudget{
		GroupID:          b.GroupID,
		SpendLimitMicros: b.SpendLimitMicros,
		CreatedAt:        b.CreatedAt,
		UpdatedAt:        b.UpdatedAt,
	}
}

func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
	var presets []codersdk.InvalidatedPreset
	for _, p := range invalidatedPresets {
		presets = append(presets, codersdk.InvalidatedPreset{
			TemplateName:        p.TemplateName,
			TemplateVersionName: p.TemplateVersionName,
			PresetName:          p.TemplateVersionPresetName,
		})
	}
	return presets
}

// sanitizeCredentialHint ensures the hint looks masked before exposing
// it in the API. The aibridge library uses "..." as the masking
// delimiter (e.g. "sk-a...efgh"), so we check for its presence. If
// the hint doesn't contain "..." or exceeds the max length, it's
// replaced with "..." to prevent leaking raw secrets.
func sanitizeCredentialHint(hint string) string {
	// Matches the VARCHAR(15) DB constraint.
	const maxCredentialHintLength = 15

	if hint == "" {
		return ""
	}

	if len(hint) > maxCredentialHintLength || !strings.Contains(hint, "...") {
		return "..."
	}
	return hint
}

func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any {
	var m map[string]any
	if !rawMessage.Valid {
		return m
	}

	err := json.Unmarshal(rawMessage.RawMessage, &m)
	if err != nil {
		// Don't reuse m
		return map[string]any{}
	}
	return m
}

func ChatMessage(m database.ChatMessage) codersdk.ChatMessage {
	modelConfigID := &m.ModelConfigID.UUID
	if !m.ModelConfigID.Valid {
		modelConfigID = nil
	}
	createdBy := &m.CreatedBy.UUID
	if !m.CreatedBy.Valid {
		createdBy = nil
	}
	msg := codersdk.ChatMessage{
		ID:            m.ID,
		ChatID:        m.ChatID,
		CreatedBy:     createdBy,
		ModelConfigID: modelConfigID,
		CreatedAt:     m.CreatedAt,
		Role:          codersdk.ChatMessageRole(m.Role),
	}
	if m.Content.Valid {
		parts, err := chatMessageParts(m)
		if err == nil {
			msg.Content = parts
		}
	}
	usage := chatMessageUsage(m)
	if usage != nil {
		msg.Usage = usage
	}
	return msg
}

// chatMessageUsage builds a ChatMessageUsage from the database row,
// returning nil when no token fields are populated.
func chatMessageUsage(m database.ChatMessage) *codersdk.ChatMessageUsage {
	inputTokens := nullInt64Ptr(m.InputTokens)
	outputTokens := nullInt64Ptr(m.OutputTokens)
	totalTokens := nullInt64Ptr(m.TotalTokens)
	reasoningTokens := nullInt64Ptr(m.ReasoningTokens)
	cacheCreationTokens := nullInt64Ptr(m.CacheCreationTokens)
	cacheReadTokens := nullInt64Ptr(m.CacheReadTokens)
	contextLimit := nullInt64Ptr(m.ContextLimit)

	if inputTokens == nil && outputTokens == nil && totalTokens == nil &&
		reasoningTokens == nil && cacheCreationTokens == nil &&
		cacheReadTokens == nil && contextLimit == nil {
		return nil
	}

	return &codersdk.ChatMessageUsage{
		InputTokens:         inputTokens,
		OutputTokens:        outputTokens,
		TotalTokens:         totalTokens,
		ReasoningTokens:     reasoningTokens,
		CacheCreationTokens: cacheCreationTokens,
		CacheReadTokens:     cacheReadTokens,
		ContextLimit:        contextLimit,
	}
}

// ChatQueuedMessage converts a queued message to its SDK representation.
func ChatQueuedMessage(message database.ChatQueuedMessage) codersdk.ChatQueuedMessage {
	// Queued messages are always written by current code via
	// MarshalParts, so they are always current content version.
	parts, err := chatMessageParts(database.ChatMessage{
		Role: database.ChatMessageRoleUser,
		Content: pqtype.NullRawMessage{
			RawMessage: message.Content,
			Valid:      len(message.Content) > 0,
		},
		ContentVersion: chatprompt.CurrentContentVersion,
	})
	if err != nil {
		parts = nil
	}

	return codersdk.ChatQueuedMessage{
		ID:            message.ID,
		ChatID:        message.ChatID,
		ModelConfigID: nullUUIDPtr(message.ModelConfigID),
		Content:       parts,
		CreatedAt:     message.CreatedAt,
	}
}

// ChatQueuedMessages converts a slice of database queued messages
// to their SDK representation.
func ChatQueuedMessages(messages []database.ChatQueuedMessage) []codersdk.ChatQueuedMessage {
	out := make([]codersdk.ChatQueuedMessage, 0, len(messages))
	for _, message := range messages {
		out = append(out, ChatQueuedMessage(message))
	}
	return out
}

func chatMessageParts(m database.ChatMessage) ([]codersdk.ChatMessagePart, error) {
	parts, err := chatprompt.ParseContent(m)
	if err != nil {
		return nil, err
	}
	// Strip internal-only fields before API responses.
	for i := range parts {
		parts[i].StripInternal()
	}
	return parts, nil
}

func nullUUIDPtr(v uuid.NullUUID) *uuid.UUID {
	if !v.Valid {
		return nil
	}
	value := v.UUID
	return &value
}

func nullInt64Ptr(v sql.NullInt64) *int64 {
	if !v.Valid {
		return nil
	}
	value := v.Int64
	return &value
}

func nullInt32Ptr(n sql.NullInt32) *int32 {
	if !n.Valid {
		return nil
	}
	return &n.Int32
}

func nullStringPtr(v sql.NullString) *string {
	if !v.Valid {
		return nil
	}
	value := v.String
	return &value
}

func nullTimePtr(v sql.NullTime) *time.Time {
	if !v.Valid {
		return nil
	}
	value := v.Time
	return &value
}

const fallbackChatLastErrorMessage = "The chat request failed unexpectedly."

func decodeChatLastError(raw pqtype.NullRawMessage) *codersdk.ChatError {
	if !raw.Valid {
		return nil
	}

	var payload codersdk.ChatError
	if err := json.Unmarshal(raw.RawMessage, &payload); err != nil {
		return &codersdk.ChatError{
			Message: fallbackChatLastErrorMessage,
			Kind:    codersdk.ChatErrorKindGeneric,
		}
	}

	payload.Message = strings.TrimSpace(payload.Message)
	payload.Detail = strings.TrimSpace(payload.Detail)
	payload.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(payload.Kind)))
	payload.Provider = strings.TrimSpace(payload.Provider)
	if payload.Kind == "" {
		payload.Kind = codersdk.ChatErrorKindGeneric
	}
	if payload.Message == "" {
		payload.Message = fallbackChatLastErrorMessage
	}
	return &payload
}

// Chat converts a database.Chat to a codersdk.Chat. It coalesces
// nil slices and maps to empty values for JSON serialization and
// derives RootChatID from the parent chain when not explicitly set.
// When diffStatus is non-nil the response includes diff metadata.
// When files is non-empty the response includes file metadata;
// pass nil to omit the files field (e.g. list endpoints).
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database.GetChatFileMetadataByChatIDRow) codersdk.Chat {
	mcpServerIDs := c.MCPServerIDs
	if mcpServerIDs == nil {
		mcpServerIDs = []uuid.UUID{}
	}
	labels := map[string]string(c.Labels)
	if labels == nil {
		labels = map[string]string{}
	}
	lastError := decodeChatLastError(c.LastError)
	chat := codersdk.Chat{
		ID:                c.ID,
		OrganizationID:    c.OrganizationID,
		OwnerID:           c.OwnerID,
		OwnerUsername:     c.OwnerUsername,
		OwnerName:         c.OwnerName,
		LastModelConfigID: c.LastModelConfigID,
		Title:             c.Title,
		Status:            codersdk.ChatStatus(c.Status),
		Archived:          c.Archived,
		PinOrder:          c.PinOrder,
		CreatedAt:         c.CreatedAt,
		UpdatedAt:         c.UpdatedAt,
		MCPServerIDs:      mcpServerIDs,
		Labels:            labels,
		ClientType:        codersdk.ChatClientType(c.ClientType),
		LastError:         lastError,
	}
	if c.LastTurnSummary.Valid {
		chat.LastTurnSummary = &c.LastTurnSummary.String
	}
	if c.PlanMode.Valid {
		chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode)
	}
	if c.ParentChatID.Valid {
		parentChatID := c.ParentChatID.UUID
		chat.ParentChatID = &parentChatID
	}
	// Always initialize Children to an empty slice so the JSON
	// field serializes as [] rather than null. Root chats may
	// later have children populated; child chats remain empty
	// because nesting depth is capped at 1.
	chat.Children = []codersdk.Chat{}
	switch {
	case c.RootChatID.Valid:
		rootChatID := c.RootChatID.UUID
		chat.RootChatID = &rootChatID
	case c.ParentChatID.Valid:
		rootChatID := c.ParentChatID.UUID
		chat.RootChatID = &rootChatID
	default:
		rootChatID := c.ID
		chat.RootChatID = &rootChatID
	}
	if c.WorkspaceID.Valid {
		chat.WorkspaceID = &c.WorkspaceID.UUID
	}
	if c.BuildID.Valid {
		chat.BuildID = &c.BuildID.UUID
	}
	if c.AgentID.Valid {
		chat.AgentID = &c.AgentID.UUID
	}
	if diffStatus != nil {
		convertedDiffStatus := ChatDiffStatus(c.ID, diffStatus)
		chat.DiffStatus = &convertedDiffStatus
	}
	if len(files) > 0 {
		chat.Files = make([]codersdk.ChatFileMetadata, 0, len(files))
		for _, row := range files {
			chat.Files = append(chat.Files, codersdk.ChatFileMetadata{
				ID:             row.ID,
				OwnerID:        row.OwnerID,
				OrganizationID: row.OrganizationID,
				Name:           row.Name,
				MimeType:       row.Mimetype,
				CreatedAt:      row.CreatedAt,
			})
		}
	}
	if c.LastInjectedContext.Valid {
		var parts []codersdk.ChatMessagePart
		// Internal fields are stripped at write time in
		// chatd.updateLastInjectedContext, so no
		// StripInternal call is needed here. Unmarshal
		// errors are suppressed — the column is written by
		// us with a known schema.
		if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil {
			chat.LastInjectedContext = parts
		}
	}
	return chat
}

func chatDebugAttempts(raw json.RawMessage) []map[string]any {
	if len(raw) == 0 {
		return nil
	}

	var attempts []map[string]any
	if err := json.Unmarshal(raw, &attempts); err != nil {
		return []map[string]any{{
			"error":       "malformed attempts payload",
			"parse_error": err.Error(),
			"raw":         string(raw),
		}}
	}
	// Guard against JSON literal "null" which unmarshals successfully
	// but leaves the slice nil. The DB column is JSONB NOT NULL but
	// that only rejects SQL NULL, not JSONB null.
	if attempts == nil {
		return []map[string]any{}
	}
	return attempts
}

// rawJSONObject deserializes a JSON object payload for debug display.
// If the payload is malformed, it returns a map with "error" and "raw"
// keys preserving the original content for diagnostics.  Callers that
// consume the result programmatically should check for the "error" key.
func rawJSONObject(raw json.RawMessage) map[string]any {
	if len(raw) == 0 {
		return nil
	}

	var object map[string]any
	if err := json.Unmarshal(raw, &object); err != nil {
		return map[string]any{
			"error":       "malformed debug payload",
			"parse_error": err.Error(),
			"raw":         string(raw),
		}
	}
	// Guard against JSON literal "null" which unmarshals successfully
	// but leaves the map nil. The DB column is JSONB NOT NULL but
	// that only rejects SQL NULL, not JSONB null.
	if object == nil {
		return map[string]any{}
	}
	return object
}

func nullRawJSONObject(raw pqtype.NullRawMessage) map[string]any {
	if !raw.Valid {
		return nil
	}
	return rawJSONObject(raw.RawMessage)
}

// ChatDebugRunSummary converts a database.ChatDebugRun to a
// codersdk.ChatDebugRunSummary.
func ChatDebugRunSummary(r database.ChatDebugRun) codersdk.ChatDebugRunSummary {
	return codersdk.ChatDebugRunSummary{
		ID:         r.ID,
		ChatID:     r.ChatID,
		Kind:       codersdk.ChatDebugRunKind(r.Kind),
		Status:     codersdk.ChatDebugStatus(r.Status),
		Provider:   nullStringPtr(r.Provider),
		Model:      nullStringPtr(r.Model),
		Summary:    rawJSONObject(r.Summary),
		StartedAt:  r.StartedAt,
		UpdatedAt:  r.UpdatedAt,
		FinishedAt: nullTimePtr(r.FinishedAt),
	}
}

// ChatDebugStep converts a database.ChatDebugStep to a
// codersdk.ChatDebugStep.
func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep {
	return codersdk.ChatDebugStep{
		ID:                  s.ID,
		RunID:               s.RunID,
		ChatID:              s.ChatID,
		StepNumber:          s.StepNumber,
		Operation:           codersdk.ChatDebugStepOperation(s.Operation),
		Status:              codersdk.ChatDebugStatus(s.Status),
		HistoryTipMessageID: nullInt64Ptr(s.HistoryTipMessageID),
		AssistantMessageID:  nullInt64Ptr(s.AssistantMessageID),
		NormalizedRequest:   rawJSONObject(s.NormalizedRequest),
		NormalizedResponse:  nullRawJSONObject(s.NormalizedResponse),
		Usage:               nullRawJSONObject(s.Usage),
		Attempts:            chatDebugAttempts(s.Attempts),
		Error:               nullRawJSONObject(s.Error),
		Metadata:            rawJSONObject(s.Metadata),
		StartedAt:           s.StartedAt,
		UpdatedAt:           s.UpdatedAt,
		FinishedAt:          nullTimePtr(s.FinishedAt),
	}
}

// ChatDebugRunDetail converts a database.ChatDebugRun and its steps
// to a codersdk.ChatDebugRun.
func ChatDebugRunDetail(r database.ChatDebugRun, steps []database.ChatDebugStep) codersdk.ChatDebugRun {
	sdkSteps := make([]codersdk.ChatDebugStep, 0, len(steps))
	for _, s := range steps {
		sdkSteps = append(sdkSteps, ChatDebugStep(s))
	}
	return codersdk.ChatDebugRun{
		ID:                  r.ID,
		ChatID:              r.ChatID,
		RootChatID:          nullUUIDPtr(r.RootChatID),
		ParentChatID:        nullUUIDPtr(r.ParentChatID),
		ModelConfigID:       nullUUIDPtr(r.ModelConfigID),
		TriggerMessageID:    nullInt64Ptr(r.TriggerMessageID),
		HistoryTipMessageID: nullInt64Ptr(r.HistoryTipMessageID),
		Kind:                codersdk.ChatDebugRunKind(r.Kind),
		Status:              codersdk.ChatDebugStatus(r.Status),
		Provider:            nullStringPtr(r.Provider),
		Model:               nullStringPtr(r.Model),
		Summary:             rawJSONObject(r.Summary),
		StartedAt:           r.StartedAt,
		UpdatedAt:           r.UpdatedAt,
		FinishedAt:          nullTimePtr(r.FinishedAt),
		Steps:               sdkSteps,
	}
}

// ChildChatRows converts child chat rows to codersdk.Chat values,
// resolving diff statuses from the shared map. When diffStatuses
// is non-nil, children without an entry receive an empty DiffStatus.
func ChildChatRows(
	children []database.GetChildChatsByParentIDsRow,
	diffStatuses map[uuid.UUID]database.ChatDiffStatus,
) []codersdk.Chat {
	result := make([]codersdk.Chat, len(children))
	for i, row := range children {
		diffStatus, ok := diffStatuses[row.Chat.ID]
		if ok {
			result[i] = Chat(row.Chat, &diffStatus, nil)
		} else {
			result[i] = Chat(row.Chat, nil, nil)
			if diffStatuses != nil {
				emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
				result[i].DiffStatus = &emptyDiffStatus
			}
		}
		result[i].HasUnread = row.HasUnread
	}
	return result
}

// ChatRowsWithChildren converts root chat rows and their child rows
// into codersdk.Chat values with children embedded under each parent.
// Both root and child diff statuses are resolved from the shared map.
func ChatRowsWithChildren(
	roots []database.GetChatsRow,
	children []database.GetChildChatsByParentIDsRow,
	diffStatuses map[uuid.UUID]database.ChatDiffStatus,
) []codersdk.Chat {
	// Group children by parent ID.
	childrenByParent := make(map[uuid.UUID][]database.GetChildChatsByParentIDsRow, len(children))
	for _, row := range children {
		parentID := row.Chat.ParentChatID.UUID
		childrenByParent[parentID] = append(childrenByParent[parentID], row)
	}

	result := make([]codersdk.Chat, len(roots))
	for i, row := range roots {
		diffStatus, ok := diffStatuses[row.Chat.ID]
		if ok {
			result[i] = Chat(row.Chat, &diffStatus, nil)
		} else {
			result[i] = Chat(row.Chat, nil, nil)
			if diffStatuses != nil {
				emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
				result[i].DiffStatus = &emptyDiffStatus
			}
		}
		result[i].HasUnread = row.HasUnread

		// Embed child chats.
		if childRows, ok := childrenByParent[row.Chat.ID]; ok {
			result[i].Children = ChildChatRows(childRows, diffStatuses)
		}
	}
	return result
}

// ChatDiffStatus converts a database.ChatDiffStatus to a
// codersdk.ChatDiffStatus. When status is nil an empty value
// containing only the chatID is returned.
func ChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) codersdk.ChatDiffStatus {
	result := codersdk.ChatDiffStatus{
		ChatID: chatID,
	}
	if status == nil {
		return result
	}

	result.ChatID = status.ChatID
	if status.Url.Valid {
		u := strings.TrimSpace(status.Url.String)
		if u != "" {
			result.URL = &u
		}
	}
	if result.URL == nil {
		// Try to build a branch URL from the stored origin.
		// Since this function does not have access to the API
		// instance, we construct a GitHub provider directly as
		// a best-effort fallback.
		// TODO: This uses the default github.com API base URL,
		// so branch URLs for GitHub Enterprise instances will
		// be incorrect. To fix this, this function would need
		// access to the external auth configs.
		gp, _ := gitprovider.New("github", "", nil)
		if gp != nil {
			if owner, repo, _, ok := gp.ParseRepositoryOrigin(status.GitRemoteOrigin); ok {
				branchURL := gp.BuildBranchURL(owner, repo, status.GitBranch)
				if branchURL != "" {
					result.URL = &branchURL
				}
			}
		}
	}
	if status.PullRequestState.Valid {
		pullRequestState := strings.TrimSpace(status.PullRequestState.String)
		if pullRequestState != "" {
			result.PullRequestState = &pullRequestState
		}
	}
	result.PullRequestTitle = status.PullRequestTitle
	result.PullRequestDraft = status.PullRequestDraft
	result.ChangesRequested = status.ChangesRequested
	result.Additions = status.Additions
	result.Deletions = status.Deletions
	result.ChangedFiles = status.ChangedFiles
	if status.AuthorLogin.Valid {
		result.AuthorLogin = &status.AuthorLogin.String
	}
	if status.AuthorAvatarUrl.Valid {
		result.AuthorAvatarURL = &status.AuthorAvatarUrl.String
	}
	if status.BaseBranch.Valid {
		result.BaseBranch = &status.BaseBranch.String
	}
	if status.HeadBranch.Valid {
		result.HeadBranch = &status.HeadBranch.String
	}
	if status.PrNumber.Valid {
		result.PRNumber = &status.PrNumber.Int32
	}
	if status.Commits.Valid {
		result.Commits = &status.Commits.Int32
	}
	if status.Approved.Valid {
		result.Approved = &status.Approved.Bool
	}
	if status.ReviewerCount.Valid {
		result.ReviewerCount = &status.ReviewerCount.Int32
	}
	if status.RefreshedAt.Valid {
		refreshedAt := status.RefreshedAt.Time
		result.RefreshedAt = &refreshedAt
	}
	staleAt := status.StaleAt
	result.StaleAt = &staleAt

	return result
}

// UserSecret converts a database ListUserSecretsRow (metadata only,
// no value) to an SDK UserSecret.
func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret {
	return codersdk.UserSecret{
		ID:          secret.ID,
		Name:        secret.Name,
		Description: secret.Description,
		EnvName:     secret.EnvName,
		FilePath:    secret.FilePath,
		CreatedAt:   secret.CreatedAt,
		UpdatedAt:   secret.UpdatedAt,
	}
}

// UserSecretFromFull converts a full database UserSecret row to an
// SDK UserSecret, omitting the value and encryption key ID.
func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret {
	return codersdk.UserSecret{
		ID:          secret.ID,
		Name:        secret.Name,
		Description: secret.Description,
		EnvName:     secret.EnvName,
		FilePath:    secret.FilePath,
		CreatedAt:   secret.CreatedAt,
		UpdatedAt:   secret.UpdatedAt,
	}
}

// UserSecrets converts a slice of database ListUserSecretsRow to
// SDK UserSecret values.
func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
	result := make([]codersdk.UserSecret, 0, len(secrets))
	for _, s := range secrets {
		result = append(result, UserSecret(s))
	}
	return result
}

// UserSkill converts a database UserSkill to an SDK UserSkill.
func UserSkill(skill database.UserSkill) codersdk.UserSkill {
	return codersdk.UserSkill{
		UserSkillMetadata: codersdk.UserSkillMetadata{
			ID:          skill.ID,
			Name:        skill.Name,
			Description: skill.Description,
			CreatedAt:   skill.CreatedAt,
			UpdatedAt:   skill.UpdatedAt,
		},
		Content: skill.Content,
	}
}

// UserSkillMetadata converts database user skill metadata to an SDK UserSkillMetadata.
func UserSkillMetadata(skill database.ListUserSkillMetadataByUserIDRow) codersdk.UserSkillMetadata {
	return codersdk.UserSkillMetadata{
		ID:          skill.ID,
		Name:        skill.Name,
		Description: skill.Description,
		CreatedAt:   skill.CreatedAt,
		UpdatedAt:   skill.UpdatedAt,
	}
}

// UserSkillMetadataList converts database user skill metadata rows to SDK values.
func UserSkillMetadataList(rows []database.ListUserSkillMetadataByUserIDRow) []codersdk.UserSkillMetadata {
	metadata := make([]codersdk.UserSkillMetadata, 0, len(rows))
	for _, row := range rows {
		metadata = append(metadata, UserSkillMetadata(row))
	}
	return metadata
}
