     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package repository provides data access and persistence layer for ClusterCockpit.
     7→//
     8→// This file implements tag management functionality for job categorization and classification.
     9→// Tags support both manual assignment (via REST/GraphQL APIs) and automatic detection
    10→// (via tagger plugins). The implementation includes role-based access control through
    11→// tag scopes and maintains bidirectional consistency between the SQL database and
    12→// the file-based job archive.
    13→//
    14→// Database Schema:
    15→//
    16→//	CREATE TABLE tag (
    17→//	    id INTEGER PRIMARY KEY AUTOINCREMENT,
    18→//	    tag_type VARCHAR(255) NOT NULL,
    19→//	    tag_name VARCHAR(255) NOT NULL,
    20→//	    tag_scope VARCHAR(255) NOT NULL DEFAULT "global",
    21→//	    CONSTRAINT tag_unique UNIQUE (tag_type, tag_name, tag_scope)
    22→//	);
    23→//
    24→//	CREATE TABLE jobtag (
    25→//	    job_id INTEGER,
    26→//	    tag_id INTEGER,
    27→//	    PRIMARY KEY (job_id, tag_id),
    28→//	    FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
    29→//	    FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
    30→//	);
    31→//
    32→// The jobtag junction table enables many-to-many relationships between jobs and tags.
    33→// CASCADE deletion ensures referential integrity when jobs or tags are removed.
    34→package repository
    35→
    36→import (
    37→	"errors"
    38→	"fmt"
    39→	"strings"
    40→
    41→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    42→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    43→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    44→	sq "github.com/Masterminds/squirrel"
    45→)
    46→
    47→// Tag Scope Rules:
    48→//
    49→// Tags in ClusterCockpit have three visibility scopes that control who can see and use them:
    50→//
    51→//  1. "global" - Visible to all users, can be used by anyone
    52→//     Example: System-generated tags like "energy-efficient", "failed", "short"
    53→//
    54→//  2. "private" - Only visible to the creating user
    55→//     Example: Personal notes like "needs-review", "interesting-case"
    56→//
    57→//  3. "admin" - Only visible to users with admin or support roles
    58→//     Example: Internal notes like "hardware-issue", "billing-problem"
    59→//
    60→// Authorization Rules:
    61→//   - Regular users can only create/see "global" and their own "private" tags
    62→//   - Admin/Support can create/see all scopes including "admin" tags
    63→//   - Users can only add tags to jobs they have permission to view
    64→//   - Tag scope is enforced at query time in GetTags() and CountTags()
    65→
    66→// AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
    67→// Requires user authentication for security checks.
    68→//
    69→// The user must have permission to view the job. Tag visibility is determined by scope:
    70→//   - "global" tags: visible to all users
    71→//   - "private" tags: only visible to the tag creator
    72→//   - "admin" tags: only visible to admin/support users
    73→func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
    74→	j, err := r.FindByIDWithUser(user, job)
    75→	if err != nil {
    76→		cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
    77→		return nil, err
    78→	}
    79→
    80→	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
    81→		return r.GetTags(user, &job)
    82→	})
    83→}
    84→
    85→// AddTagDirect adds a tag without user security checks.
    86→// Use only for internal/admin operations.
    87→func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
    88→	j, err := r.FindByIDDirect(job)
    89→	if err != nil {
    90→		cclog.Warnf("Error finding job %d: %v", job, err)
    91→		return nil, err
    92→	}
    93→
    94→	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
    95→		return r.GetTagsDirect(&job)
    96→	})
    97→}
    98→
    99→// RemoveTag removes the tag with the database id `tag` from the job with the database id `job`.
   100→// Requires user authentication for security checks. Used by GraphQL API.
   101→func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) {
   102→	j, err := r.FindByIDWithUser(user, job)
   103→	if err != nil {
   104→		cclog.Warnf("Error while finding job %d for user %s during tag removal: %v", job, user.Username, err)
   105→		return nil, err
   106→	}
   107→
   108→	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag)
   109→
   110→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   111→		s, _, _ := q.ToSql()
   112→		cclog.Errorf("Error removing tag with %s: %v", s, err)
   113→		return nil, err
   114→	}
   115→
   116→	tags, err := r.GetTags(user, &job)
   117→	if err != nil {
   118→		cclog.Warn("Error while getting tags for job")
   119→		return nil, err
   120→	}
   121→
   122→	archiveTags, err := r.getArchiveTags(&job)
   123→	if err != nil {
   124→		cclog.Warnf("Error while getting archive tags for job %d in RemoveTag: %v", job, err)
   125→		return nil, err
   126→	}
   127→
   128→	return tags, archive.UpdateTags(j, archiveTags)
   129→}
   130→
   131→// RemoveJobTagByRequest removes a tag from the job with the database id `job` by tag type, name, and scope.
   132→// Requires user authentication for security checks. Used by REST API.
   133→func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) {
   134→	// Get Tag ID to delete
   135→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   136→	if !exists {
   137→		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   138→		return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   139→	}
   140→
   141→	// Get Job
   142→	j, err := r.FindByIDWithUser(user, job)
   143→	if err != nil {
   144→		cclog.Warnf("Error while finding job %d for user %s during tag removal by request: %v", job, user.Username, err)
   145→		return nil, err
   146→	}
   147→
   148→	// Handle Delete
   149→	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tagID)
   150→
   151→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   152→		s, _, _ := q.ToSql()
   153→		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
   154→		return nil, err
   155→	}
   156→
   157→	tags, err := r.GetTags(user, &job)
   158→	if err != nil {
   159→		cclog.Warnf("Error while getting tags for job %d in RemoveJobTagByRequest: %v", job, err)
   160→		return nil, err
   161→	}
   162→
   163→	archiveTags, err := r.getArchiveTags(&job)
   164→	if err != nil {
   165→		cclog.Warnf("Error while getting archive tags for job %d in RemoveJobTagByRequest: %v", job, err)
   166→		return nil, err
   167→	}
   168→
   169→	return tags, archive.UpdateTags(j, archiveTags)
   170→}
   171→
   172→// removeTagFromArchiveJobs updates the job archive for all affected jobs after a tag deletion.
   173→//
   174→// This function is called asynchronously (via goroutine) after removing a tag from the database
   175→// to synchronize the file-based job archive with the database state. Errors are logged but not
   176→// returned since this runs in the background.
   177→//
   178→// Parameters:
   179→//   - jobIds: Database IDs of all jobs that had the deleted tag
   180→//
   181→// Implementation note: Each job is processed individually to handle partial failures gracefully.
   182→// If one job fails to update, others will still be processed.
   183→func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
   184→	for _, j := range jobIds {
   185→		tags, err := r.getArchiveTags(&j)
   186→		if err != nil {
   187→			cclog.Warnf("Error while getting tags for job %d", j)
   188→			continue
   189→		}
   190→
   191→		job, err := r.FindByIDDirect(j)
   192→		if err != nil {
   193→			cclog.Warnf("Error while getting job %d", j)
   194→			continue
   195→		}
   196→
   197→		archive.UpdateTags(job, tags)
   198→	}
   199→}
   200→
   201→// Removes a tag from db by tag info
   202→// Used by REST API. Does not update tagged jobs in Job archive.
   203→func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error {
   204→	// Get Tag ID to delete
   205→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   206→	if !exists {
   207→		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   208→		return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
   209→	}
   210→
   211→	return r.RemoveTagByID(tagID)
   212→}
   213→
   214→// Removes a tag from db by tag id
   215→// Used by GraphQL API.
   216→func (r *JobRepository) RemoveTagByID(tagID int64) error {
   217→	jobIds, err := r.FindJobIdsByTag(tagID)
   218→	if err != nil {
   219→		return err
   220→	}
   221→
   222→	// Handle Delete JobTagTable
   223→	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID)
   224→
   225→	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil {
   226→		s, _, _ := qJobTag.ToSql()
   227→		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
   228→		return err
   229→	}
   230→
   231→	// Handle Delete TagTable
   232→	qTag := sq.Delete("tag").Where("tag.id = ?", tagID)
   233→
   234→	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil {
   235→		s, _, _ := qTag.ToSql()
   236→		cclog.Errorf("Error removing tag from table 'tag' with %s: %v", s, err)
   237→		return err
   238→	}
   239→
   240→	// asynchronously update archive jobs
   241→	go r.removeTagFromArchiveJobs(jobIds)
   242→
   243→	return nil
   244→}
   245→
   246→// CreateTag creates a new tag with the specified type, name, and scope.
   247→// Returns the database ID of the newly created tag.
   248→//
   249→// Scope defaults to "global" if empty string is provided.
   250→// Valid scopes: "global", "private", "admin"
   251→//
   252→// Example:
   253→//
   254→//	tagID, err := repo.CreateTag("performance", "high-memory", "global")
   255→func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagID int64, err error) {
   256→	// Default to "Global" scope if none defined
   257→	if tagScope == "" {
   258→		tagScope = "global"
   259→	}
   260→
   261→	q := sq.Insert("tag").Columns("tag_type", "tag_name", "tag_scope").Values(tagType, tagName, tagScope)
   262→
   263→	res, err := q.RunWith(r.stmtCache).Exec()
   264→	if err != nil {
   265→		s, _, _ := q.ToSql()
   266→		cclog.Errorf("Error inserting tag with %s: %v", s, err)
   267→		return 0, err
   268→	}
   269→
   270→	return res.LastInsertId()
   271→}
   272→
   273→// CountTags returns all tags visible to the user and the count of jobs for each tag.
   274→// Applies scope-based filtering to respect tag visibility rules.
   275→//
   276→// Returns:
   277→//   - tags: slice of tags the user can see
   278→//   - counts: map of tag name to job count
   279→//   - err: any error encountered
   280→func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) {
   281→	tags = make([]schema.Tag, 0, 100)
   282→	xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name, tag_scope FROM tag")
   283→	if err != nil {
   284→		return nil, nil, err
   285→	}
   286→
   287→	for xrows.Next() {
   288→		var t schema.Tag
   289→		if err = xrows.StructScan(&t); err != nil {
   290→			return nil, nil, err
   291→		}
   292→
   293→		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
   294→		readable, err := r.checkScopeAuth(user, "read", t.Scope)
   295→		if err != nil {
   296→			return nil, nil, err
   297→		}
   298→		if readable {
   299→			tags = append(tags, t)
   300→		}
   301→	}
   302→
   303→	// Query and Count Jobs with attached Tags
   304→	q := sq.Select("t.tag_type, t.tag_name, t.id, count(jt.tag_id)").
   305→		From("tag t").
   306→		LeftJoin("jobtag jt ON t.id = jt.tag_id").
   307→		GroupBy("t.tag_type, t.tag_name")
   308→
   309→	// Build scope list for filtering
   310→	var scopeBuilder strings.Builder
   311→	scopeBuilder.WriteString(`"global"`)
   312→	if user != nil {
   313→		scopeBuilder.WriteString(`,"`)
   314→		scopeBuilder.WriteString(user.Username)
   315→		scopeBuilder.WriteString(`"`)
   316→		if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   317→			scopeBuilder.WriteString(`,"admin"`)
   318→		}
   319→	}
   320→	q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
   321→
   322→	// Handle Job Ownership
   323→	if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
   324→		// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
   325→		// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
   326→	} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
   327→		// Build ("project1", "project2", ...) list of variable length directly in SQL string
   328→		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
   329→	} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
   330→		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
   331→	}
   332→
   333→	rows, err := q.RunWith(r.stmtCache).Query()
   334→	if err != nil {
   335→		return nil, nil, err
   336→	}
   337→
   338→	counts = make(map[string]int)
   339→	for rows.Next() {
   340→		var tagType string
   341→		var tagName string
   342→		var tagID int
   343→		var count int
   344→		if err = rows.Scan(&tagType, &tagName, &tagID, &count); err != nil {
   345→			return nil, nil, err
   346→		}
   347→		// Use tagId as second Map-Key component to differentiate tags with identical names
   348→		counts[fmt.Sprint(tagType, tagName, tagID)] = count
   349→	}
   350→	err = rows.Err()
   351→
   352→	return tags, counts, err
   353→}
   354→
   355→var (
   356→	// ErrTagNotFound is returned when a tag ID or tag identifier (type, name, scope) does not exist in the database.
   357→	ErrTagNotFound = errors.New("the tag does not exist")
   358→
   359→	// ErrJobNotOwned is returned when a user attempts to tag a job they do not have permission to access.
   360→	ErrJobNotOwned = errors.New("user is not owner of job")
   361→
   362→	// ErrTagNoAccess is returned when a user attempts to use a tag they cannot access due to scope restrictions.
   363→	ErrTagNoAccess = errors.New("user not permitted to use that tag")
   364→
   365→	// ErrTagPrivateScope is returned when a user attempts to access another user's private tag.
   366→	ErrTagPrivateScope = errors.New("tag is private to another user")
   367→
   368→	// ErrTagAdminScope is returned when a non-admin user attempts to use an admin-scoped tag.
   369→	ErrTagAdminScope = errors.New("tag requires admin privileges")
   370→
   371→	// ErrTagsIncompatScopes is returned when attempting to combine admin and non-admin scoped tags in a single operation.
   372→	ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
   373→)
   374→
   375→// addJobTag is a helper function that inserts a job-tag association and updates the archive.
   376→//
   377→// This function performs three operations atomically:
   378→//  1. Inserts the job-tag association into the jobtag junction table
   379→//  2. Retrieves the updated tag list for the job (using the provided getTags callback)
   380→//  3. Updates the job archive with the new tags to maintain database-archive consistency
   381→//
   382→// Parameters:
   383→//   - jobId: Database ID of the job
   384→//   - tagId: Database ID of the tag to associate
   385→//   - job: Full job object needed for archive update
   386→//   - getTags: Callback function to retrieve updated tags (allows different security contexts)
   387→//
   388→// Returns the complete updated tag list for the job or an error.
   389→//
   390→// Note: This function does NOT validate tag scope permissions - callers must perform
   391→// authorization checks before invoking this helper.
   392→func (r *JobRepository) addJobTag(jobID int64, tagID int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
   393→	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)
   394→
   395→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   396→		s, _, _ := q.ToSql()
   397→		cclog.Errorf("Error adding tag with %s: %v", s, err)
   398→		return nil, err
   399→	}
   400→
   401→	tags, err := getTags()
   402→	if err != nil {
   403→		cclog.Warnf("Error getting tags for job %d: %v", jobID, err)
   404→		return nil, err
   405→	}
   406→
   407→	archiveTags, err := r.getArchiveTags(&jobID)
   408→	if err != nil {
   409→		cclog.Warnf("Error getting archive tags for job %d: %v", jobID, err)
   410→		return nil, err
   411→	}
   412→
   413→	return tags, archive.UpdateTags(job, archiveTags)
   414→}
   415→
   416→// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
   417→// If such a tag does not yet exist, it is created.
   418→func (r *JobRepository) AddTagOrCreate(user *schema.User, jobID int64, tagType string, tagName string, tagScope string) (tagID int64, err error) {
   419→	// Default to "Global" scope if none defined
   420→	if tagScope == "" {
   421→		tagScope = "global"
   422→	}
   423→
   424→	writable, err := r.checkScopeAuth(user, "write", tagScope)
   425→	if err != nil {
   426→		return 0, err
   427→	}
   428→	if !writable {
   429→		return 0, fmt.Errorf("cannot write tag scope with current authorization")
   430→	}
   431→
   432→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   433→	if !exists {
   434→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   435→		if err != nil {
   436→			return 0, err
   437→		}
   438→	}
   439→
   440→	if _, err := r.AddTag(user, jobID, tagID); err != nil {
   441→		return 0, err
   442→	}
   443→
   444→	return tagID, nil
   445→}
   446→
   447→func (r *JobRepository) AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error) {
   448→	tagScope := "global"
   449→
   450→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   451→	if !exists {
   452→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   453→		if err != nil {
   454→			return 0, err
   455→		}
   456→	}
   457→
   458→	cclog.Infof("Adding tag %s:%s:%s (direct)", tagType, tagName, tagScope)
   459→
   460→	if _, err := r.AddTagDirect(jobID, tagID); err != nil {
   461→		return 0, err
   462→	}
   463→
   464→	return tagID, nil
   465→}
   466→
   467→func (r *JobRepository) HasTag(jobID int64, tagType string, tagName string) bool {
   468→	var id int64
   469→	q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id").
   470→		Where("jobtag.job_id = ?", jobID).Where("tag.tag_type = ?", tagType).
   471→		Where("tag.tag_name = ?", tagName)
   472→	err := q.RunWith(r.stmtCache).QueryRow().Scan(&id)
   473→	if err != nil {
   474→		return false
   475→	} else {
   476→		return true
   477→	}
   478→}
   479→
   480→// TagID returns the database id of the tag with the specified type and name.
   481→func (r *JobRepository) TagID(tagType string, tagName string, tagScope string) (tagID int64, exists bool) {
   482→	exists = true
   483→	if err := sq.Select("id").From("tag").
   484→		Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
   485→		RunWith(r.stmtCache).QueryRow().Scan(&tagID); err != nil {
   486→		exists = false
   487→	}
   488→	return
   489→}
   490→
   491→// TagInfo returns the database infos of the tag with the specified id.
   492→func (r *JobRepository) TagInfo(tagID int64) (tagType string, tagName string, tagScope string, exists bool) {
   493→	exists = true
   494→	if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagID).
   495→		RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil {
   496→		exists = false
   497→	}
   498→	return
   499→}
   500→
   501→// GetTags returns a list of all scoped tags if job is nil or of the tags that the job with that database ID has.
   502→func (r *JobRepository) GetTags(user *schema.User, job *int64) ([]*schema.Tag, error) {
   503→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   504→	if job != nil {
   505→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   506→	}
   507→
   508→	rows, err := q.RunWith(r.stmtCache).Query()
   509→	if err != nil {
   510→		s, _, _ := q.ToSql()
   511→		cclog.Errorf("Error get tags with %s: %v", s, err)
   512→		return nil, err
   513→	}
   514→
   515→	tags := make([]*schema.Tag, 0)
   516→	for rows.Next() {
   517→		tag := &schema.Tag{}
   518→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   519→			cclog.Warnf("Error while scanning tag rows in GetTags: %v", err)
   520→			return nil, err
   521→		}
   522→		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
   523→		readable, err := r.checkScopeAuth(user, "read", tag.Scope)
   524→		if err != nil {
   525→			return nil, err
   526→		}
   527→		if readable {
   528→			tags = append(tags, tag)
   529→		}
   530→	}
   531→
   532→	return tags, nil
   533→}
   534→
   535→func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
   536→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   537→	if job != nil {
   538→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   539→	}
   540→
   541→	rows, err := q.RunWith(r.stmtCache).Query()
   542→	if err != nil {
   543→		s, _, _ := q.ToSql()
   544→		cclog.Errorf("Error get tags with %s: %v", s, err)
   545→		return nil, err
   546→	}
   547→
   548→	tags := make([]*schema.Tag, 0)
   549→	for rows.Next() {
   550→		tag := &schema.Tag{}
   551→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   552→			cclog.Warnf("Error while scanning tag rows in GetTagsDirect: %v", err)
   553→			return nil, err
   554→		}
   555→		tags = append(tags, tag)
   556→	}
   557→
   558→	return tags, nil
   559→}
   560→
   561→// getArchiveTags returns all tags for a job WITHOUT applying scope-based filtering.
   562→//
   563→// This internal function is used exclusively for job archive synchronization where we need
   564→// to store all tags regardless of the current user's permissions. Unlike GetTags() which
   565→// filters by scope, this returns the complete unfiltered tag list.
   566→//
   567→// Parameters:
   568→//   - job: Pointer to job database ID, or nil to return all tags in the system
   569→//
   570→// Returns all tags without scope filtering, used only for archive operations.
   571→//
   572→// WARNING: Do NOT expose this function to user-facing APIs as it bypasses authorization.
   573→func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
   574→	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
   575→	if job != nil {
   576→		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
   577→	}
   578→
   579→	rows, err := q.RunWith(r.stmtCache).Query()
   580→	if err != nil {
   581→		s, _, _ := q.ToSql()
   582→		cclog.Errorf("Error get tags with %s: %v", s, err)
   583→		return nil, err
   584→	}
   585→
   586→	tags := make([]*schema.Tag, 0)
   587→	for rows.Next() {
   588→		tag := &schema.Tag{}
   589→		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
   590→			cclog.Warnf("Error while scanning tag rows in getArchiveTags: %v", err)
   591→			return nil, err
   592→		}
   593→		tags = append(tags, tag)
   594→	}
   595→
   596→	return tags, nil
   597→}
   598→
   599→func (r *JobRepository) ImportTag(jobID int64, tagType string, tagName string, tagScope string) (err error) {
   600→	// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err
   601→
   602→	tagID, exists := r.TagID(tagType, tagName, tagScope)
   603→	if !exists {
   604→		tagID, err = r.CreateTag(tagType, tagName, tagScope)
   605→		if err != nil {
   606→			return err
   607→		}
   608→	}
   609→
   610→	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)
   611→
   612→	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
   613→		s, _, _ := q.ToSql()
   614→		cclog.Errorf("Error adding tag on import with %s: %v", s, err)
   615→		return err
   616→	}
   617→
   618→	return nil
   619→}
   620→
   621→// checkScopeAuth validates whether a user is authorized to perform an operation on a tag with the given scope.
   622→//
   623→// This function implements the tag scope authorization matrix:
   624→//
   625→//	Scope        | Read Access                      | Write Access
   626→//	-------------|----------------------------------|----------------------------------
   627→//	"global"     | All users                        | Admin, Support, API-only
   628→//	"admin"      | Admin, Support                   | Admin, API-only
   629→//	<username>   | Owner only                       | Owner only (private tags)
   630→//
   631→// Parameters:
   632→//   - user: User attempting the operation (must not be nil)
   633→//   - operation: Either "read" or "write"
   634→//   - scope: Tag scope value ("global", "admin", or username for private tags)
   635→//
   636→// Returns:
   637→//   - pass: true if authorized, false if denied
   638→//   - err: error only if operation is invalid or user is nil
   639→//
   640→// Special cases:
   641→//   - API-only users (single role: RoleApi) can write to admin and global scopes for automation
   642→//   - Private tags use the username as scope, granting exclusive access to that user
   643→func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) {
   644→	if user != nil {
   645→		switch {
   646→		case operation == "write" && scope == "admin":
   647→			if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
   648→				return true, nil
   649→			}
   650→			return false, nil
   651→		case operation == "write" && scope == "global":
   652→			if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
   653→				return true, nil
   654→			}
   655→			return false, nil
   656→		case operation == "write" && scope == user.Username:
   657→			return true, nil
   658→		case operation == "read" && scope == "admin":
   659→			return user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}), nil
   660→		case operation == "read" && scope == "global":
   661→			return true, nil
   662→		case operation == "read" && scope == user.Username:
   663→			return true, nil
   664→		default:
   665→			if operation == "read" || operation == "write" {
   666→				// No acceptable scope: deny tag
   667→				return false, nil
   668→			} else {
   669→				return false, fmt.Errorf("error while checking tag operation auth: unknown operation (%s)", operation)
   670→			}
   671→		}
   672→	} else {
   673→		return false, fmt.Errorf("error while checking tag operation auth: no user in context")
   674→	}
   675→}
   676→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package repository provides data access and persistence layer for ClusterCockpit.
//
// This file implements tag management functionality for job categorization and classification.
// Tags support both manual assignment (via REST/GraphQL APIs) and automatic detection
// (via tagger plugins). The implementation includes role-based access control through
// tag scopes and maintains bidirectional consistency between the SQL database and
// the file-based job archive.
//
// Database Schema:
//
//	CREATE TABLE tag (
//	    id INTEGER PRIMARY KEY AUTOINCREMENT,
//	    tag_type VARCHAR(255) NOT NULL,
//	    tag_name VARCHAR(255) NOT NULL,
//	    tag_scope VARCHAR(255) NOT NULL DEFAULT "global",
//	    CONSTRAINT tag_unique UNIQUE (tag_type, tag_name, tag_scope)
//	);
//
//	CREATE TABLE jobtag (
//	    job_id INTEGER,
//	    tag_id INTEGER,
//	    PRIMARY KEY (job_id, tag_id),
//	    FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
//	    FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
//	);
//
// The jobtag junction table enables many-to-many relationships between jobs and tags.
// CASCADE deletion ensures referential integrity when jobs or tags are removed.
package repository

import (
	"errors"
	"fmt"
	"strings"

	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

// Tag Scope Rules:
//
// Tags in ClusterCockpit have three visibility scopes that control who can see and use them:
//
//  1. "global" - Visible to all users, can be used by anyone
//     Example: System-generated tags like "energy-efficient", "failed", "short"
//
//  2. "private" - Only visible to the creating user
//     Example: Personal notes like "needs-review", "interesting-case"
//
//  3. "admin" - Only visible to users with admin or support roles
//     Example: Internal notes like "hardware-issue", "billing-problem"
//
// Authorization Rules:
//   - Regular users can only create/see "global" and their own "private" tags
//   - Admin/Support can create/see all scopes including "admin" tags
//   - Users can only add tags to jobs they have permission to view
//   - Tag scope is enforced at query time in GetTags() and CountTags()

// AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
// Requires user authentication for security checks.
//
// The user must have permission to view the job. Tag visibility is determined by scope:
//   - "global" tags: visible to all users
//   - "private" tags: only visible to the tag creator
//   - "admin" tags: only visible to admin/support users
func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
		return nil, err
	}

	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
		return r.GetTags(user, &job)
	})
}

// AddTagDirect adds a tag without user security checks.
// Use only for internal/admin operations.
func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDDirect(job)
	if err != nil {
		cclog.Warnf("Error finding job %d: %v", job, err)
		return nil, err
	}

	return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
		return r.GetTagsDirect(&job)
	})
}

// RemoveTag removes the tag with the database id `tag` from the job with the database id `job`.
// Requires user authentication for security checks. Used by GraphQL API.
func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) {
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error while finding job %d for user %s during tag removal: %v", job, user.Username, err)
		return nil, err
	}

	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tag)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error removing tag with %s: %v", s, err)
		return nil, err
	}

	tags, err := r.GetTags(user, &job)
	if err != nil {
		cclog.Warn("Error while getting tags for job")
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&job)
	if err != nil {
		cclog.Warnf("Error while getting archive tags for job %d in RemoveTag: %v", job, err)
		return nil, err
	}

	return tags, archive.UpdateTags(j, archiveTags)
}

// RemoveJobTagByRequest removes a tag from the job with the database id `job` by tag type, name, and scope.
// Requires user authentication for security checks. Used by REST API.
func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) {
	// Get Tag ID to delete
	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
		return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
	}

	// Get Job
	j, err := r.FindByIDWithUser(user, job)
	if err != nil {
		cclog.Warnf("Error while finding job %d for user %s during tag removal by request: %v", job, user.Username, err)
		return nil, err
	}

	// Handle Delete
	q := sq.Delete("jobtag").Where("jobtag.job_id = ?", job).Where("jobtag.tag_id = ?", tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
		return nil, err
	}

	tags, err := r.GetTags(user, &job)
	if err != nil {
		cclog.Warnf("Error while getting tags for job %d in RemoveJobTagByRequest: %v", job, err)
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&job)
	if err != nil {
		cclog.Warnf("Error while getting archive tags for job %d in RemoveJobTagByRequest: %v", job, err)
		return nil, err
	}

	return tags, archive.UpdateTags(j, archiveTags)
}

// removeTagFromArchiveJobs updates the job archive for all affected jobs after a tag deletion.
//
// This function is called asynchronously (via goroutine) after removing a tag from the database
// to synchronize the file-based job archive with the database state. Errors are logged but not
// returned since this runs in the background.
//
// Parameters:
//   - jobIds: Database IDs of all jobs that had the deleted tag
//
// Implementation note: Each job is processed individually to handle partial failures gracefully.
// If one job fails to update, others will still be processed.
func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
	for _, j := range jobIds {
		tags, err := r.getArchiveTags(&j)
		if err != nil {
			cclog.Warnf("Error while getting tags for job %d", j)
			continue
		}

		job, err := r.FindByIDDirect(j)
		if err != nil {
			cclog.Warnf("Error while getting job %d", j)
			continue
		}

		archive.UpdateTags(job, tags)
	}
}

// Removes a tag from db by tag info
// Used by REST API. Does not update tagged jobs in Job archive.
func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error {
	// Get Tag ID to delete
	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
		return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
	}

	return r.RemoveTagByID(tagID)
}

// Removes a tag from db by tag id
// Used by GraphQL API.
func (r *JobRepository) RemoveTagByID(tagID int64) error {
	jobIds, err := r.FindJobIdsByTag(tagID)
	if err != nil {
		return err
	}

	// Handle Delete JobTagTable
	qJobTag := sq.Delete("jobtag").Where("jobtag.tag_id = ?", tagID)

	if _, err := qJobTag.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := qJobTag.ToSql()
		cclog.Errorf("Error removing tag from table 'jobTag' with %s: %v", s, err)
		return err
	}

	// Handle Delete TagTable
	qTag := sq.Delete("tag").Where("tag.id = ?", tagID)

	if _, err := qTag.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := qTag.ToSql()
		cclog.Errorf("Error removing tag from table 'tag' with %s: %v", s, err)
		return err
	}

	// asynchronously update archive jobs
	go r.removeTagFromArchiveJobs(jobIds)

	return nil
}

// CreateTag creates a new tag with the specified type, name, and scope.
// Returns the database ID of the newly created tag.
//
// Scope defaults to "global" if empty string is provided.
// Valid scopes: "global", "private", "admin"
//
// Example:
//
//	tagID, err := repo.CreateTag("performance", "high-memory", "global")
func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagID int64, err error) {
	// Default to "Global" scope if none defined
	if tagScope == "" {
		tagScope = "global"
	}

	q := sq.Insert("tag").Columns("tag_type", "tag_name", "tag_scope").Values(tagType, tagName, tagScope)

	res, err := q.RunWith(r.stmtCache).Exec()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error inserting tag with %s: %v", s, err)
		return 0, err
	}

	return res.LastInsertId()
}

// CountTags returns all tags visible to the user and the count of jobs for each tag.
// Applies scope-based filtering to respect tag visibility rules.
//
// Returns:
//   - tags: slice of tags the user can see
//   - counts: map of tag name to job count
//   - err: any error encountered
func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) {
	tags = make([]schema.Tag, 0, 100)
	xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name, tag_scope FROM tag")
	if err != nil {
		return nil, nil, err
	}

	for xrows.Next() {
		var t schema.Tag
		if err = xrows.StructScan(&t); err != nil {
			return nil, nil, err
		}

		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
		readable, err := r.checkScopeAuth(user, "read", t.Scope)
		if err != nil {
			return nil, nil, err
		}
		if readable {
			tags = append(tags, t)
		}
	}

	// Query and Count Jobs with attached Tags
	q := sq.Select("t.tag_type, t.tag_name, t.id, count(jt.tag_id)").
		From("tag t").
		LeftJoin("jobtag jt ON t.id = jt.tag_id").
		GroupBy("t.tag_type, t.tag_name")

	// Build scope list for filtering
	var scopeBuilder strings.Builder
	scopeBuilder.WriteString(`"global"`)
	if user != nil {
		scopeBuilder.WriteString(`,"`)
		scopeBuilder.WriteString(user.Username)
		scopeBuilder.WriteString(`"`)
		if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
			scopeBuilder.WriteString(`,"admin"`)
		}
	}
	q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")

	// Handle Job Ownership
	if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
		// cclog.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
		// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
	} else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
		// Build ("project1", "project2", ...) list of variable length directly in SQL string
		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
	} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs
		q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.hpc_user = ?)", user.Username)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		return nil, nil, err
	}

	counts = make(map[string]int)
	for rows.Next() {
		var tagType string
		var tagName string
		var tagID int
		var count int
		if err = rows.Scan(&tagType, &tagName, &tagID, &count); err != nil {
			return nil, nil, err
		}
		// Use tagId as second Map-Key component to differentiate tags with identical names
		counts[fmt.Sprint(tagType, tagName, tagID)] = count
	}
	err = rows.Err()

	return tags, counts, err
}

var (
	// ErrTagNotFound is returned when a tag ID or tag identifier (type, name, scope) does not exist in the database.
	ErrTagNotFound = errors.New("the tag does not exist")

	// ErrJobNotOwned is returned when a user attempts to tag a job they do not have permission to access.
	ErrJobNotOwned = errors.New("user is not owner of job")

	// ErrTagNoAccess is returned when a user attempts to use a tag they cannot access due to scope restrictions.
	ErrTagNoAccess = errors.New("user not permitted to use that tag")

	// ErrTagPrivateScope is returned when a user attempts to access another user's private tag.
	ErrTagPrivateScope = errors.New("tag is private to another user")

	// ErrTagAdminScope is returned when a non-admin user attempts to use an admin-scoped tag.
	ErrTagAdminScope = errors.New("tag requires admin privileges")

	// ErrTagsIncompatScopes is returned when attempting to combine admin and non-admin scoped tags in a single operation.
	ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
)

// addJobTag is a helper function that inserts a job-tag association and updates the archive.
//
// This function performs three operations atomically:
//  1. Inserts the job-tag association into the jobtag junction table
//  2. Retrieves the updated tag list for the job (using the provided getTags callback)
//  3. Updates the job archive with the new tags to maintain database-archive consistency
//
// Parameters:
//   - jobId: Database ID of the job
//   - tagId: Database ID of the tag to associate
//   - job: Full job object needed for archive update
//   - getTags: Callback function to retrieve updated tags (allows different security contexts)
//
// Returns the complete updated tag list for the job or an error.
//
// Note: This function does NOT validate tag scope permissions - callers must perform
// authorization checks before invoking this helper.
func (r *JobRepository) addJobTag(jobID int64, tagID int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error adding tag with %s: %v", s, err)
		return nil, err
	}

	tags, err := getTags()
	if err != nil {
		cclog.Warnf("Error getting tags for job %d: %v", jobID, err)
		return nil, err
	}

	archiveTags, err := r.getArchiveTags(&jobID)
	if err != nil {
		cclog.Warnf("Error getting archive tags for job %d: %v", jobID, err)
		return nil, err
	}

	return tags, archive.UpdateTags(job, archiveTags)
}

// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
// If such a tag does not yet exist, it is created.
func (r *JobRepository) AddTagOrCreate(user *schema.User, jobID int64, tagType string, tagName string, tagScope string) (tagID int64, err error) {
	// Default to "Global" scope if none defined
	if tagScope == "" {
		tagScope = "global"
	}

	writable, err := r.checkScopeAuth(user, "write", tagScope)
	if err != nil {
		return 0, err
	}
	if !writable {
		return 0, fmt.Errorf("cannot write tag scope with current authorization")
	}

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return 0, err
		}
	}

	if _, err := r.AddTag(user, jobID, tagID); err != nil {
		return 0, err
	}

	return tagID, nil
}

func (r *JobRepository) AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error) {
	tagScope := "global"

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return 0, err
		}
	}

	cclog.Infof("Adding tag %s:%s:%s (direct)", tagType, tagName, tagScope)

	if _, err := r.AddTagDirect(jobID, tagID); err != nil {
		return 0, err
	}

	return tagID, nil
}

func (r *JobRepository) HasTag(jobID int64, tagType string, tagName string) bool {
	var id int64
	q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id").
		Where("jobtag.job_id = ?", jobID).Where("tag.tag_type = ?", tagType).
		Where("tag.tag_name = ?", tagName)
	err := q.RunWith(r.stmtCache).QueryRow().Scan(&id)
	if err != nil {
		return false
	} else {
		return true
	}
}

// TagID returns the database id of the tag with the specified type and name.
func (r *JobRepository) TagID(tagType string, tagName string, tagScope string) (tagID int64, exists bool) {
	exists = true
	if err := sq.Select("id").From("tag").
		Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
		RunWith(r.stmtCache).QueryRow().Scan(&tagID); err != nil {
		exists = false
	}
	return
}

// TagInfo returns the database infos of the tag with the specified id.
func (r *JobRepository) TagInfo(tagID int64) (tagType string, tagName string, tagScope string, exists bool) {
	exists = true
	if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagID).
		RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil {
		exists = false
	}
	return
}

// GetTags returns a list of all scoped tags if job is nil or of the tags that the job with that database ID has.
func (r *JobRepository) GetTags(user *schema.User, job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in GetTags: %v", err)
			return nil, err
		}
		// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
		readable, err := r.checkScopeAuth(user, "read", tag.Scope)
		if err != nil {
			return nil, err
		}
		if readable {
			tags = append(tags, tag)
		}
	}

	return tags, nil
}

func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in GetTagsDirect: %v", err)
			return nil, err
		}
		tags = append(tags, tag)
	}

	return tags, nil
}

// getArchiveTags returns all tags for a job WITHOUT applying scope-based filtering.
//
// This internal function is used exclusively for job archive synchronization where we need
// to store all tags regardless of the current user's permissions. Unlike GetTags() which
// filters by scope, this returns the complete unfiltered tag list.
//
// Parameters:
//   - job: Pointer to job database ID, or nil to return all tags in the system
//
// Returns all tags without scope filtering, used only for archive operations.
//
// WARNING: Do NOT expose this function to user-facing APIs as it bypasses authorization.
func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
	q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
	if job != nil {
		q = q.Join("jobtag ON jobtag.tag_id = tag.id").Where("jobtag.job_id = ?", *job)
	}

	rows, err := q.RunWith(r.stmtCache).Query()
	if err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error get tags with %s: %v", s, err)
		return nil, err
	}

	tags := make([]*schema.Tag, 0)
	for rows.Next() {
		tag := &schema.Tag{}
		if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
			cclog.Warnf("Error while scanning tag rows in getArchiveTags: %v", err)
			return nil, err
		}
		tags = append(tags, tag)
	}

	return tags, nil
}

func (r *JobRepository) ImportTag(jobID int64, tagType string, tagName string, tagScope string) (err error) {
	// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err

	tagID, exists := r.TagID(tagType, tagName, tagScope)
	if !exists {
		tagID, err = r.CreateTag(tagType, tagName, tagScope)
		if err != nil {
			return err
		}
	}

	q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)

	if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
		s, _, _ := q.ToSql()
		cclog.Errorf("Error adding tag on import with %s: %v", s, err)
		return err
	}

	return nil
}

// checkScopeAuth validates whether a user is authorized to perform an operation on a tag with the given scope.
//
// This function implements the tag scope authorization matrix:
//
//	Scope        | Read Access                      | Write Access
//	-------------|----------------------------------|----------------------------------
//	"global"     | All users                        | Admin, Support, API-only
//	"admin"      | Admin, Support                   | Admin, API-only
//	<username>   | Owner only                       | Owner only (private tags)
//
// Parameters:
//   - user: User attempting the operation (must not be nil)
//   - operation: Either "read" or "write"
//   - scope: Tag scope value ("global", "admin", or username for private tags)
//
// Returns:
//   - pass: true if authorized, false if denied
//   - err: error only if operation is invalid or user is nil
//
// Special cases:
//   - API-only users (single role: RoleApi) can write to admin and global scopes for automation
//   - Private tags use the username as scope, granting exclusive access to that user
func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) {
	if user != nil {
		switch {
		case operation == "write" && scope == "admin":
			if user.HasRole(schema.RoleAdmin) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
				return true, nil
			}
			return false, nil
		case operation == "write" && scope == "global":
			if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) || (len(user.Roles) == 1 && user.HasRole(schema.RoleAPI)) {
				return true, nil
			}
			return false, nil
		case operation == "write" && scope == user.Username:
			return true, nil
		case operation == "read" && scope == "admin":
			return user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}), nil
		case operation == "read" && scope == "global":
			return true, nil
		case operation == "read" && scope == user.Username:
			return true, nil
		default:
			if operation == "read" || operation == "write" {
				// No acceptable scope: deny tag
				return false, nil
			} else {
				return false, fmt.Errorf("error while checking tag operation auth: unknown operation (%s)", operation)
			}
		}
	} else {
		return false, fmt.Errorf("error while checking tag operation auth: no user in context")
	}
}


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package repository provides job query functionality with filtering, pagination,
     7→// and security controls. This file contains the main query builders and security
     8→// checks for job retrieval operations.
     9→package repository
    10→
    11→import (
    12→	"context"
    13→	"errors"
    14→	"fmt"
    15→	"regexp"
    16→	"strings"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/config"
    20→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    21→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    22→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    23→	sq "github.com/Masterminds/squirrel"
    24→)
    25→
    26→const (
    27→	// Default initial capacity for job result slices
    28→	defaultJobsCapacity = 50
    29→)
    30→
    31→// QueryJobs retrieves jobs from the database with optional filtering, pagination,
    32→// and sorting. Security controls are automatically applied based on the user context.
    33→//
    34→// Parameters:
    35→//   - ctx: Context containing user authentication information
    36→//   - filters: Optional job filters (cluster, state, user, time ranges, etc.)
    37→//   - page: Optional pagination parameters (page number and items per page)
    38→//   - order: Optional sorting specification (column or footprint field)
    39→//
    40→// Returns a slice of jobs matching the criteria, or an error if the query fails.
    41→// The function enforces role-based access control through SecurityCheck.
    42→func (r *JobRepository) QueryJobs(
    43→	ctx context.Context,
    44→	filters []*model.JobFilter,
    45→	page *model.PageRequest,
    46→	order *model.OrderByInput,
    47→) ([]*schema.Job, error) {
    48→	query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
    49→	if qerr != nil {
    50→		return nil, qerr
    51→	}
    52→
    53→	if order != nil {
    54→		field := toSnakeCase(order.Field)
    55→		if order.Type == "col" {
    56→			switch order.Order {
    57→			case model.SortDirectionEnumAsc:
    58→				query = query.OrderBy(fmt.Sprintf("job.%s ASC", field))
    59→			case model.SortDirectionEnumDesc:
    60→				query = query.OrderBy(fmt.Sprintf("job.%s DESC", field))
    61→			default:
    62→				return nil, errors.New("invalid sorting order for column")
    63→			}
    64→		} else {
    65→			// Order by footprint JSON field values
    66→			query = query.Where("JSON_VALID(meta_data)")
    67→			switch order.Order {
    68→			case model.SortDirectionEnumAsc:
    69→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))
    70→			case model.SortDirectionEnumDesc:
    71→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))
    72→			default:
    73→				return nil, errors.New("invalid sorting order for footprint")
    74→			}
    75→		}
    76→	}
    77→
    78→	if page != nil && page.ItemsPerPage != -1 {
    79→		limit := uint64(page.ItemsPerPage)
    80→		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
    81→	}
    82→
    83→	for _, f := range filters {
    84→		query = BuildWhereClause(f, query)
    85→	}
    86→
    87→	rows, err := query.RunWith(r.stmtCache).Query()
    88→	if err != nil {
    89→		queryString, queryVars, _ := query.ToSql()
    90→		return nil, fmt.Errorf("query failed [%s] %v: %w", queryString, queryVars, err)
    91→	}
    92→	defer rows.Close()
    93→
    94→	jobs := make([]*schema.Job, 0, defaultJobsCapacity)
    95→	for rows.Next() {
    96→		job, err := scanJob(rows)
    97→		if err != nil {
    98→			cclog.Warnf("Error scanning job row: %v", err)
    99→			return nil, fmt.Errorf("failed to scan job row: %w", err)
   100→		}
   101→		jobs = append(jobs, job)
   102→	}
   103→
   104→	if err := rows.Err(); err != nil {
   105→		return nil, fmt.Errorf("error iterating job rows: %w", err)
   106→	}
   107→
   108→	return jobs, nil
   109→}
   110→
   111→// CountJobs returns the total number of jobs matching the given filters.
   112→// Security controls are automatically applied based on the user context.
   113→// Uses DISTINCT count to handle tag filters correctly (jobs may appear multiple
   114→// times when joined with the tag table).
   115→func (r *JobRepository) CountJobs(
   116→	ctx context.Context,
   117→	filters []*model.JobFilter,
   118→) (int, error) {
   119→	query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
   120→	if qerr != nil {
   121→		return 0, qerr
   122→	}
   123→
   124→	for _, f := range filters {
   125→		query = BuildWhereClause(f, query)
   126→	}
   127→
   128→	var count int
   129→	if err := query.RunWith(r.DB).Scan(&count); err != nil {
   130→		return 0, fmt.Errorf("failed to count jobs: %w", err)
   131→	}
   132→
   133→	return count, nil
   134→}
   135→
   136→// SecurityCheckWithUser applies role-based access control filters to a job query
   137→// based on the provided user's roles and permissions.
   138→//
   139→// Access rules by role:
   140→//   - API role (exclusive): Full access to all jobs
   141→//   - Admin/Support roles: Full access to all jobs
   142→//   - Manager role: Access to jobs in managed projects plus own jobs
   143→//   - User role: Access only to own jobs
   144→//
   145→// Returns an error if the user is nil or has no recognized roles.
   146→func SecurityCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   147→	if user == nil {
   148→		var qnil sq.SelectBuilder
   149→		return qnil, fmt.Errorf("user context is nil")
   150→	}
   151→
   152→	switch {
   153→	case len(user.Roles) == 1 && user.HasRole(schema.RoleAPI):
   154→		return query, nil
   155→	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}):
   156→		return query, nil
   157→	case user.HasRole(schema.RoleManager):
   158→		if len(user.Projects) != 0 {
   159→			return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.hpc_user": user.Username}}), nil
   160→		}
   161→		cclog.Debugf("Manager '%s' has no assigned projects, restricting to personal jobs", user.Username)
   162→		return query.Where("job.hpc_user = ?", user.Username), nil
   163→	case user.HasRole(schema.RoleUser):
   164→		return query.Where("job.hpc_user = ?", user.Username), nil
   165→	default:
   166→		var qnil sq.SelectBuilder
   167→		return qnil, fmt.Errorf("user has no or unknown roles")
   168→	}
   169→}
   170→
   171→// SecurityCheck extracts the user from the context and applies role-based access
   172→// control filters to the query. This is a convenience wrapper around SecurityCheckWithUser.
   173→func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   174→	user := GetUserFromContext(ctx)
   175→	return SecurityCheckWithUser(user, query)
   176→}
   177→
   178→// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
   179→// them to the query. Supports filtering by job properties (cluster, state, user),
   180→// time ranges, resource usage, tags, and JSON field searches in meta_data,
   181→// footprint, and resources columns.
   182→func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
   183→	// Primary Key
   184→	if filter.DbID != nil {
   185→		dbIDs := make([]string, len(filter.DbID))
   186→		copy(dbIDs, filter.DbID)
   187→		query = query.Where(sq.Eq{"job.id": dbIDs})
   188→	}
   189→	// Explicit indices
   190→	if filter.Cluster != nil {
   191→		query = buildStringCondition("job.cluster", filter.Cluster, query)
   192→	}
   193→	if filter.SubCluster != nil {
   194→		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
   195→	}
   196→	if filter.Partition != nil {
   197→		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
   198→	}
   199→	if filter.State != nil {
   200→		states := make([]string, len(filter.State))
   201→		for i, val := range filter.State {
   202→			states[i] = string(val)
   203→		}
   204→		query = query.Where(sq.Eq{"job.job_state": states})
   205→	}
   206→	if filter.Shared != nil {
   207→		query = query.Where("job.shared = ?", *filter.Shared)
   208→	}
   209→	if filter.Project != nil {
   210→		query = buildStringCondition("job.project", filter.Project, query)
   211→	}
   212→	if filter.User != nil {
   213→		query = buildStringCondition("job.hpc_user", filter.User, query)
   214→	}
   215→	if filter.NumNodes != nil {
   216→		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
   217→	}
   218→	if filter.NumAccelerators != nil {
   219→		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
   220→	}
   221→	if filter.NumHWThreads != nil {
   222→		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
   223→	}
   224→	if filter.ArrayJobID != nil {
   225→		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
   226→	}
   227→	if filter.StartTime != nil {
   228→		query = buildTimeCondition("job.start_time", filter.StartTime, query)
   229→	}
   230→	if filter.Duration != nil {
   231→		query = buildIntCondition("job.duration", filter.Duration, query)
   232→	}
   233→	if filter.Energy != nil {
   234→		query = buildFloatCondition("job.energy", filter.Energy, query)
   235→	}
   236→	// Indices on Tag Table
   237→	if filter.Tags != nil {
   238→		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
   239→		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
   240→	}
   241→	// No explicit Indices
   242→	if filter.JobID != nil {
   243→		query = buildStringCondition("job.job_id", filter.JobID, query)
   244→	}
   245→	// Queries Within JSONs
   246→	if filter.MetricStats != nil {
   247→		for _, ms := range filter.MetricStats {
   248→			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
   249→		}
   250→	}
   251→	if filter.Node != nil {
   252→		query = buildResourceJSONCondition("hostname", filter.Node, query)
   253→	}
   254→	if filter.JobName != nil {
   255→		query = buildMetaJSONCondition("jobName", filter.JobName, query)
   256→	}
   257→	if filter.Schedule != nil {
   258→		interactiveJobname := "interactive"
   259→		switch *filter.Schedule {
   260→		case "interactive":
   261→			iFilter := model.StringInput{Eq: &interactiveJobname}
   262→			query = buildMetaJSONCondition("jobName", &iFilter, query)
   263→		case "batch":
   264→			sFilter := model.StringInput{Neq: &interactiveJobname}
   265→			query = buildMetaJSONCondition("jobName", &sFilter, query)
   266→		}
   267→	}
   268→
   269→	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
   270→	if filter.MinRunningFor != nil {
   271→		now := time.Now().Unix()
   272→		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
   273→		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
   274→		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
   275→	}
   276→	return query
   277→}
   278→
   279→// buildIntCondition creates a BETWEEN clause for integer range filters.
   280→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   281→func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
   282→	if cond.From != 0 && cond.To != 0 {
   283→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   284→	} else if cond.From != 0 {
   285→		return query.Where(field+" >= ?", cond.From)
   286→	} else if cond.To != 0 {
   287→		return query.Where(field+" <= ?", cond.To)
   288→	} else {
   289→		return query
   290→	}
   291→}
   292→
   293→// buildFloatCondition creates a BETWEEN clause for float range filters.
   294→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   295→func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   296→	if cond.From != 0.0 && cond.To != 0.0 {
   297→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   298→	} else if cond.From != 0.0 {
   299→		return query.Where(field+" >= ?", cond.From)
   300→	} else if cond.To != 0.0 {
   301→		return query.Where(field+" <= ?", cond.To)
   302→	} else {
   303→		return query
   304→	}
   305→}
   306→
   307→// buildTimeCondition creates time range filters supporting absolute timestamps,
   308→// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
   309→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   310→func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
   311→	if cond.From != nil && cond.To != nil {
   312→		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
   313→	} else if cond.From != nil {
   314→		return query.Where(field+" >= ?", cond.From.Unix())
   315→	} else if cond.To != nil {
   316→		return query.Where(field+" <= ?", cond.To.Unix())
   317→	} else if cond.Range != "" {
   318→		now := time.Now().Unix()
   319→		var then int64
   320→		switch cond.Range {
   321→		case "last6h":
   322→			then = now - (60 * 60 * 6)
   323→		case "last24h":
   324→			then = now - (60 * 60 * 24)
   325→		case "last7d":
   326→			then = now - (60 * 60 * 24 * 7)
   327→		case "last30d":
   328→			then = now - (60 * 60 * 24 * 30)
   329→		default:
   330→			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
   331→			return query
   332→		}
   333→		return query.Where(field+" >= ?", then)
   334→	} else {
   335→		return query
   336→	}
   337→}
   338→
   339→// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column.
   340→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   341→func buildFloatJSONCondition(condName string, condRange *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   342→	query = query.Where("JSON_VALID(footprint)")
   343→	if condRange.From != 0.0 && condRange.To != 0.0 {
   344→		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") BETWEEN ? AND ?", condRange.From, condRange.To)
   345→	} else if condRange.From != 0.0 {
   346→		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") >= ?", condRange.From)
   347→	} else if condRange.To != 0.0 {
   348→		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") <= ?", condRange.To)
   349→	} else {
   350→		return query
   351→	}
   352→}
   353→
   354→// buildStringCondition creates filters for string fields supporting equality,
   355→// inequality, prefix, suffix, substring, and IN list matching.
   356→func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   357→	if cond.Eq != nil {
   358→		return query.Where(field+" = ?", *cond.Eq)
   359→	}
   360→	if cond.Neq != nil {
   361→		return query.Where(field+" != ?", *cond.Neq)
   362→	}
   363→	if cond.StartsWith != nil {
   364→		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   365→	}
   366→	if cond.EndsWith != nil {
   367→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   368→	}
   369→	if cond.Contains != nil {
   370→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   371→	}
   372→	if cond.In != nil {
   373→		queryElements := make([]string, len(cond.In))
   374→		copy(queryElements, cond.In)
   375→		return query.Where(sq.Or{sq.Eq{field: queryElements}})
   376→	}
   377→	return query
   378→}
   379→
   380→// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
   381→func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   382→	query = query.Where("JSON_VALID(meta_data)")
   383→	if cond.Eq != nil {
   384→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
   385→	}
   386→	if cond.Neq != nil {
   387→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
   388→	}
   389→	if cond.StartsWith != nil {
   390→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   391→	}
   392→	if cond.EndsWith != nil {
   393→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   394→	}
   395→	if cond.Contains != nil {
   396→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   397→	}
   398→	return query
   399→}
   400→
   401→// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
   402→// Uses json_each to search within array elements.
   403→func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   404→	query = query.Where("JSON_VALID(resources)")
   405→	if cond.Eq != nil {
   406→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
   407→	}
   408→	if cond.Neq != nil { // Currently Unused
   409→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
   410→	}
   411→	if cond.StartsWith != nil { // Currently Unused
   412→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
   413→	}
   414→	if cond.EndsWith != nil { // Currently Unused
   415→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
   416→	}
   417→	if cond.Contains != nil {
   418→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
   419→	}
   420→	return query
   421→}
   422→
   423→var (
   424→	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
   425→	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
   426→)
   427→
   428→// toSnakeCase converts camelCase strings to snake_case for SQL column names.
   429→// Includes security checks to prevent SQL injection attempts.
   430→// Panics if potentially dangerous characters are detected.
   431→func toSnakeCase(str string) string {
   432→	for _, c := range str {
   433→		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
   434→			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
   435→		}
   436→	}
   437→
   438→	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
   439→	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
   440→	return strings.ToLower(snake)
   441→}
   442→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package repository provides job query functionality with filtering, pagination,
// and security controls. This file contains the main query builders and security
// checks for job retrieval operations.
package repository

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

const (
	// Default initial capacity for job result slices
	defaultJobsCapacity = 50
)

// QueryJobs retrieves jobs from the database with optional filtering, pagination,
// and sorting. Security controls are automatically applied based on the user context.
//
// Parameters:
//   - ctx: Context containing user authentication information
//   - filters: Optional job filters (cluster, state, user, time ranges, etc.)
//   - page: Optional pagination parameters (page number and items per page)
//   - order: Optional sorting specification (column or footprint field)
//
// Returns a slice of jobs matching the criteria, or an error if the query fails.
// The function enforces role-based access control through SecurityCheck.
func (r *JobRepository) QueryJobs(
	ctx context.Context,
	filters []*model.JobFilter,
	page *model.PageRequest,
	order *model.OrderByInput,
) ([]*schema.Job, error) {
	query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
	if qerr != nil {
		return nil, qerr
	}

	if order != nil {
		field := toSnakeCase(order.Field)
		if order.Type == "col" {
			switch order.Order {
			case model.SortDirectionEnumAsc:
				query = query.OrderBy(fmt.Sprintf("job.%s ASC", field))
			case model.SortDirectionEnumDesc:
				query = query.OrderBy(fmt.Sprintf("job.%s DESC", field))
			default:
				return nil, errors.New("invalid sorting order for column")
			}
		} else {
			// Order by footprint JSON field values
			query = query.Where("JSON_VALID(meta_data)")
			switch order.Order {
			case model.SortDirectionEnumAsc:
				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))
			case model.SortDirectionEnumDesc:
				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))
			default:
				return nil, errors.New("invalid sorting order for footprint")
			}
		}
	}

	if page != nil && page.ItemsPerPage != -1 {
		limit := uint64(page.ItemsPerPage)
		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
	}

	for _, f := range filters {
		query = BuildWhereClause(f, query)
	}

	rows, err := query.RunWith(r.stmtCache).Query()
	if err != nil {
		queryString, queryVars, _ := query.ToSql()
		return nil, fmt.Errorf("query failed [%s] %v: %w", queryString, queryVars, err)
	}
	defer rows.Close()

	jobs := make([]*schema.Job, 0, defaultJobsCapacity)
	for rows.Next() {
		job, err := scanJob(rows)
		if err != nil {
			cclog.Warnf("Error scanning job row: %v", err)
			return nil, fmt.Errorf("failed to scan job row: %w", err)
		}
		jobs = append(jobs, job)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("error iterating job rows: %w", err)
	}

	return jobs, nil
}

// CountJobs returns the total number of jobs matching the given filters.
// Security controls are automatically applied based on the user context.
// Uses DISTINCT count to handle tag filters correctly (jobs may appear multiple
// times when joined with the tag table).
func (r *JobRepository) CountJobs(
	ctx context.Context,
	filters []*model.JobFilter,
) (int, error) {
	query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
	if qerr != nil {
		return 0, qerr
	}

	for _, f := range filters {
		query = BuildWhereClause(f, query)
	}

	var count int
	if err := query.RunWith(r.DB).Scan(&count); err != nil {
		return 0, fmt.Errorf("failed to count jobs: %w", err)
	}

	return count, nil
}

// SecurityCheckWithUser applies role-based access control filters to a job query
// based on the provided user's roles and permissions.
//
// Access rules by role:
//   - API role (exclusive): Full access to all jobs
//   - Admin/Support roles: Full access to all jobs
//   - Manager role: Access to jobs in managed projects plus own jobs
//   - User role: Access only to own jobs
//
// Returns an error if the user is nil or has no recognized roles.
func SecurityCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
	if user == nil {
		var qnil sq.SelectBuilder
		return qnil, fmt.Errorf("user context is nil")
	}

	switch {
	case len(user.Roles) == 1 && user.HasRole(schema.RoleAPI):
		return query, nil
	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}):
		return query, nil
	case user.HasRole(schema.RoleManager):
		if len(user.Projects) != 0 {
			return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.hpc_user": user.Username}}), nil
		}
		cclog.Debugf("Manager '%s' has no assigned projects, restricting to personal jobs", user.Username)
		return query.Where("job.hpc_user = ?", user.Username), nil
	case user.HasRole(schema.RoleUser):
		return query.Where("job.hpc_user = ?", user.Username), nil
	default:
		var qnil sq.SelectBuilder
		return qnil, fmt.Errorf("user has no or unknown roles")
	}
}

// SecurityCheck extracts the user from the context and applies role-based access
// control filters to the query. This is a convenience wrapper around SecurityCheckWithUser.
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
	user := GetUserFromContext(ctx)
	return SecurityCheckWithUser(user, query)
}

// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
// them to the query. Supports filtering by job properties (cluster, state, user),
// time ranges, resource usage, tags, and JSON field searches in meta_data,
// footprint, and resources columns.
func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
	// Primary Key
	if filter.DbID != nil {
		dbIDs := make([]string, len(filter.DbID))
		copy(dbIDs, filter.DbID)
		query = query.Where(sq.Eq{"job.id": dbIDs})
	}
	// Explicit indices
	if filter.Cluster != nil {
		query = buildStringCondition("job.cluster", filter.Cluster, query)
	}
	if filter.SubCluster != nil {
		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
	}
	if filter.Partition != nil {
		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
	}
	if filter.State != nil {
		states := make([]string, len(filter.State))
		for i, val := range filter.State {
			states[i] = string(val)
		}
		query = query.Where(sq.Eq{"job.job_state": states})
	}
	if filter.Shared != nil {
		query = query.Where("job.shared = ?", *filter.Shared)
	}
	if filter.Project != nil {
		query = buildStringCondition("job.project", filter.Project, query)
	}
	if filter.User != nil {
		query = buildStringCondition("job.hpc_user", filter.User, query)
	}
	if filter.NumNodes != nil {
		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
	}
	if filter.NumAccelerators != nil {
		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
	}
	if filter.NumHWThreads != nil {
		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
	}
	if filter.ArrayJobID != nil {
		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
	}
	if filter.StartTime != nil {
		query = buildTimeCondition("job.start_time", filter.StartTime, query)
	}
	if filter.Duration != nil {
		query = buildIntCondition("job.duration", filter.Duration, query)
	}
	if filter.Energy != nil {
		query = buildFloatCondition("job.energy", filter.Energy, query)
	}
	// Indices on Tag Table
	if filter.Tags != nil {
		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
	}
	// No explicit Indices
	if filter.JobID != nil {
		query = buildStringCondition("job.job_id", filter.JobID, query)
	}
	// Queries Within JSONs
	if filter.MetricStats != nil {
		for _, ms := range filter.MetricStats {
			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
		}
	}
	if filter.Node != nil {
		query = buildResourceJSONCondition("hostname", filter.Node, query)
	}
	if filter.JobName != nil {
		query = buildMetaJSONCondition("jobName", filter.JobName, query)
	}
	if filter.Schedule != nil {
		interactiveJobname := "interactive"
		switch *filter.Schedule {
		case "interactive":
			iFilter := model.StringInput{Eq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &iFilter, query)
		case "batch":
			sFilter := model.StringInput{Neq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &sFilter, query)
		}
	}

	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
	if filter.MinRunningFor != nil {
		now := time.Now().Unix()
		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
	}
	return query
}

// buildIntCondition creates a BETWEEN clause for integer range filters.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 0 && cond.To != 0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.To != 0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildFloatCondition creates a BETWEEN clause for float range filters.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 0.0 && cond.To != 0.0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 0.0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.To != 0.0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildTimeCondition creates time range filters supporting absolute timestamps,
// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != nil && cond.To != nil {
		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
	} else if cond.From != nil {
		return query.Where(field+" >= ?", cond.From.Unix())
	} else if cond.To != nil {
		return query.Where(field+" <= ?", cond.To.Unix())
	} else if cond.Range != "" {
		now := time.Now().Unix()
		var then int64
		switch cond.Range {
		case "last6h":
			then = now - (60 * 60 * 6)
		case "last24h":
			then = now - (60 * 60 * 24)
		case "last7d":
			then = now - (60 * 60 * 24 * 7)
		case "last30d":
			then = now - (60 * 60 * 24 * 30)
		default:
			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
			return query
		}
		return query.Where(field+" >= ?", then)
	} else {
		return query
	}
}

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildFloatJSONCondition(condName string, condRange *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")
	if condRange.From != 0.0 && condRange.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") BETWEEN ? AND ?", condRange.From, condRange.To)
	} else if condRange.From != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") >= ?", condRange.From)
	} else if condRange.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") <= ?", condRange.To)
	} else {
		return query
	}
}

// buildStringCondition creates filters for string fields supporting equality,
// inequality, prefix, suffix, substring, and IN list matching.
func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.Eq != nil {
		return query.Where(field+" = ?", *cond.Eq)
	}
	if cond.Neq != nil {
		return query.Where(field+" != ?", *cond.Neq)
	}
	if cond.StartsWith != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
	}
	if cond.In != nil {
		queryElements := make([]string, len(cond.In))
		copy(queryElements, cond.In)
		return query.Where(sq.Or{sq.Eq{field: queryElements}})
	}
	return query
}

// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(meta_data)")
	if cond.Eq != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
	}
	if cond.Neq != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
	}
	if cond.StartsWith != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
	}
	return query
}

// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
// Uses json_each to search within array elements.
func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(resources)")
	if cond.Eq != nil {
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
	}
	if cond.Neq != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
	}
	if cond.StartsWith != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
	}
	return query
}

var (
	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
)

// toSnakeCase converts camelCase strings to snake_case for SQL column names.
// Includes security checks to prevent SQL injection attempts.
// Panics if potentially dangerous characters are detected.
func toSnakeCase(str string) string {
	for _, c := range str {
		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
		}
	}

	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
	return strings.ToLower(snake)
}