<?php
# MantisBT - A PHP based bugtracking system

# MantisBT is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# MantisBT is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with MantisBT.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Category API
 *
 * @package CoreAPI
 * @subpackage CategoryAPI
 * @copyright Copyright 2000 - 2002  Kenzaburo Ito - kenito@300baud.org
 * @copyright Copyright 2002  MantisBT Team - mantisbt-dev@lists.sourceforge.net
 * @link http://www.mantisbt.org
 *
 * @uses config_api.php
 * @uses constant_inc.php
 * @uses database_api.php
 * @uses error_api.php
 * @uses helper_api.php
 * @uses history_api.php
 * @uses lang_api.php
 * @uses project_api.php
 * @uses project_hierarchy_api.php
 * @uses utility_api.php
 */

use Mantis\Exceptions\ClientException;

require_api( 'config_api.php' );
require_api( 'constant_inc.php' );
require_api( 'database_api.php' );
require_api( 'error_api.php' );
require_api( 'helper_api.php' );
require_api( 'history_api.php' );
require_api( 'lang_api.php' );
require_api( 'project_api.php' );
require_api( 'project_hierarchy_api.php' );
require_api( 'utility_api.php' );

# Category data cache (to prevent excessive db queries)
$g_category_cache = array();

/**
 * Check whether the category exists globally.
 *
 * @param integer $p_category_id A Category identifier.
 * @return boolean Return true if the category exists, false otherwise
 * @access public
 */
function category_exists( $p_category_id ) {
	$t_category_row = category_get_row( $p_category_id, /* error_if_not_exists */ false );
	return $t_category_row !== false;
}

/**
 * Trigger an error if category does not exist globally.
 *
 * @param integer $p_category_id A Category identifier.
 * @return void
 * @access public
 */
function category_ensure_exists( $p_category_id ) {
	if( !category_exists( $p_category_id ) ) {
		trigger_error( ERROR_CATEGORY_NOT_FOUND, ERROR );
	}
}

/**
 * Check whether the category exists within the project hierarchy.
 *
 * @param int $p_category_id Category identifier.
 * @param int $p_project_id  Project identifier.
 *
 * @return bool True if the category exists, false otherwise.
 */
function category_exists_in_project( $p_category_id, $p_project_id ) {
	if( $p_category_id == 0
		&& config_get( 'allow_no_category', null, null, $p_project_id )
	) {
		return true;
	}
	$t_categories = array_column( category_get_all_rows( $p_project_id ), 'id' );
	return in_array( $p_category_id, $t_categories );
}

/**
 * Trigger an error if the category does not exist within the project hierarchy.
 *
 * @param int $p_category_id Category identifier.
 * @param int $p_project_id  Project identifier.
 *
 * @throws ClientException
 */
function category_ensure_exists_in_project( $p_category_id, $p_project_id ) {
	if( !category_exists_in_project( $p_category_id, $p_project_id ) ) {
		throw new ClientException(
			"Category '$p_category_id' not available in project '$p_project_id'.",
			ERROR_CATEGORY_NOT_FOUND_FOR_PROJECT,
			array( $p_category_id, $p_project_id )
		);
	}
}

/**
 * Check whether the category is unique within a project
 * @param integer $p_project_id A project identifier.
 * @param string  $p_name       Project name.
 * @return boolean Returns true if the category is unique, false otherwise
 * @access public
 */
function category_is_unique( $p_project_id, $p_name ) {
	db_param_push();
	$t_query = 'SELECT COUNT(*) FROM {category}
					WHERE project_id=' . db_param() . ' AND ' . db_helper_like( 'name' );
	$t_count = db_result( db_query( $t_query, array( $p_project_id, $p_name ) ) );

	if( 0 < $t_count ) {
		return false;
	} else {
		return true;
	}
}

/**
 * Check whether the category is unique within a project
 * Trigger an error if it is not
 * @param integer $p_project_id Project identifier.
 * @param string  $p_name       Category Name.
 * @return void
 * @access public
 */
function category_ensure_unique( $p_project_id, $p_name ) {
	if( !category_is_unique( $p_project_id, $p_name ) ) {
		trigger_error( ERROR_CATEGORY_DUPLICATE, ERROR );
	}
}

/**
 * Checks whether the category can be deleted.
 * It is not allowed to delete a category if it is defined as 'default for moves'
 * @see $g_default_category_for_moves
 * @param integer $p_category_id Category identifier.
 * @return boolean True if category can be deleted, false otherwise
 * @access public
 */
function category_can_remove( $p_category_id ) {
	$t_default_category_id = config_get( 'default_category_for_moves', null, ALL_USERS, ALL_PROJECTS );

	return $p_category_id != $t_default_category_id
		&& !config_is_defined( 'default_category_for_moves', $p_category_id );
}

/**
 * Trigger an error if the category cannot be deleted.
 * @param integer $p_category_id Category identifier.
 * @return void
 * @access public
 */
function category_ensure_can_remove( $p_category_id ) {
	if( !category_can_remove( $p_category_id ) ) {
		error_parameters( category_get_name( $p_category_id) );
		trigger_error( ERROR_CATEGORY_CANNOT_UPDATE_DEFAULT, ERROR );
	}
}

/**
 * Add a new category to the project
 * @param integer $p_project_id Project identifier.
 * @param string  $p_name       Category Name.
 * @return integer Category ID
 * @access public
 */
function category_add( $p_project_id, $p_name ) {
	if( is_blank( $p_name ) ) {
		error_parameters( lang_get( 'category' ) );
		trigger_error( ERROR_EMPTY_FIELD, ERROR );
	}

	category_ensure_unique( $p_project_id, $p_name );

	db_param_push();
	$t_query = 'INSERT INTO {category} ( project_id, name )
				  VALUES ( ' . db_param() . ', ' . db_param() . ' )';
	db_query( $t_query, array( $p_project_id, $p_name ) );

	# db_query() errors on failure so:
	return db_insert_id( db_get_table( 'category' ) );
}

/**
 * Update the name and user associated with the category.
 *
 * @param int      $p_category_id Category identifier.
 * @param string   $p_name        Category Name.
 * @param int      $p_assigned_to User ID that category is assigned to.
 * @param int|null $p_status      Optional category status (see CATEGORY_STATUS_* constants)
 *                                or null to leave status unchanged.
 *
 * @return void
 * @access public
 */
function category_update( $p_category_id, $p_name, $p_assigned_to, $p_status = null ) {
	if( is_blank( $p_name ) ) {
		error_parameters( lang_get( 'category' ) );
		trigger_error( ERROR_EMPTY_FIELD, ERROR );
	}

	$t_old_category = category_get_row( $p_category_id );
	$t_project_id = (int)$t_old_category['project_id'];

	# Ensure target user exists and is allowed to handle bugs
	if( $p_assigned_to != NO_USER ) {
		if( user_exists( $p_assigned_to ) ) {
			$t_handle_bugs = config_get( 'handle_bug_threshold' );
			if( !access_has_project_level( $t_handle_bugs, $t_project_id, $p_assigned_to ) ) {
				trigger_error( ERROR_USER_DOES_NOT_HAVE_REQ_ACCESS, ERROR );
			}
		} else {
			error_parameters( $p_assigned_to );
			trigger_error( ERROR_USER_BY_ID_NOT_FOUND, ERROR );
		}
	}

	# Disabling category is not authorized if it is used as default
	$t_default_category_id = config_get_global( 'default_category_for_moves', null );
	if( $p_category_id == $t_default_category_id
		|| config_is_defined( 'default_category_for_moves', $p_category_id )
	) {
		error_parameters( $t_old_category['name'] );
		trigger_error( ERROR_CATEGORY_CANNOT_UPDATE_DEFAULT, ERROR );
	}

	# Keep existing status
	if( $p_status === null ) {
		$p_status = $t_old_category['status'];
	}

	db_param_push();
	$t_query = 'UPDATE {category} SET name=' . db_param() . ', user_id=' . db_param() . ', status=' . db_param() .'
				  WHERE id=' . db_param();
	db_query( $t_query, array( $p_name, $p_assigned_to , $p_status, $p_category_id ) );

	# Add bug history entries if we update the category's name
	if( $t_old_category['name'] != $p_name ) {
		db_param_push();
		$t_query = 'SELECT id FROM {bug} WHERE category_id=' . db_param();
		$t_result = db_query( $t_query, array( $p_category_id ) );

		while( $t_bug_row = db_fetch_array( $t_result ) ) {
			history_log_event_direct( $t_bug_row['id'], 'category', $t_old_category['name'], $p_name );
		}
	}
}

/**
 * Remove a category from the project
 * @param integer $p_category_id     Category identifier.
 * @param integer $p_new_category_id New category id (to replace existing category).
 * @return void
 * @access public
 */
function category_remove( $p_category_id, $p_new_category_id = 0 ) {
	$t_category_row = category_get_row( $p_category_id );

	category_ensure_exists( $p_category_id );
	category_ensure_can_remove( $p_category_id );
	if( 0 != $p_new_category_id ) {
		category_ensure_exists( $p_new_category_id );
	}

	db_param_push();
	$t_query = 'DELETE FROM {category} WHERE id=' . db_param();
	db_query( $t_query, array( $p_category_id ) );

	# update bug history entries
	db_param_push();
	$t_query = 'SELECT id FROM {bug} WHERE category_id=' . db_param();
	$t_result = db_query( $t_query, array( $p_category_id ) );

	while( $t_bug_row = db_fetch_array( $t_result ) ) {
		history_log_event_direct( $t_bug_row['id'], 'category', $t_category_row['name'], category_full_name( $p_new_category_id, false ) );
	}

	# update bug data
	db_param_push();
	$t_query = 'UPDATE {bug} SET category_id=' . db_param() . ' WHERE category_id=' . db_param();
	db_query( $t_query, array( $p_new_category_id, $p_category_id ) );
}

/**
 * Remove all categories associated with a project.
 * This will skip processing of categories that can't be deleted.
 * @param integer $p_project_id      A Project identifier.
 * @param integer $p_new_category_id New category id (to replace existing category).
 * @return boolean
 * @access public
 */
function category_remove_all( $p_project_id, $p_new_category_id = 0 ) {
	project_ensure_exists( $p_project_id );
	if( 0 != $p_new_category_id ) {
		category_ensure_exists( $p_new_category_id );
	}

	# cache category names
	category_get_all_rows( $p_project_id );

	# get a list of affected categories
	db_param_push();
	$t_query = 'SELECT id FROM {category} WHERE project_id=' . db_param();
	$t_result = db_query( $t_query, array( $p_project_id ) );

	$t_category_ids = array();
	while( $t_row = db_fetch_array( $t_result ) ) {
		# Don't add category to the list if it can't be deleted
		if( !category_can_remove( $t_row['id'] ) ) {
			continue;
		}
		$t_category_ids[] = $t_row['id'];
	}

	# Handle projects with no categories
	if( count( $t_category_ids ) < 1 ) {
		return true;
	}

	$t_category_ids = implode( ',', $t_category_ids );

	# update bug history entries
	$t_query = 'SELECT id, category_id FROM {bug} WHERE category_id IN ( ' . $t_category_ids . ' )';
	$t_result = db_query( $t_query );

	while( $t_bug_row = db_fetch_array( $t_result ) ) {
		history_log_event_direct( $t_bug_row['id'], 'category', category_full_name( $t_bug_row['category_id'], false ), category_full_name( $p_new_category_id, false ) );
	}

	# update bug data
	db_param_push();
	$t_query = 'UPDATE {bug} SET category_id=' . db_param() . ' WHERE category_id IN ( ' . $t_category_ids . ' )';
	db_query( $t_query, array( $p_new_category_id ) );

	# delete categories
	db_param_push();
	$t_query = 'DELETE FROM {category} WHERE project_id=' . db_param();
	db_query( $t_query, array( $p_project_id ) );

	return true;
}

/**
 * Return the definition row for the category
 * @param integer $p_category_id Category identifier.
 * @param boolean $p_error_if_not_exists true: error if not exists, otherwise return false.
 * @return array|false An array containing category details.
 * @access public
 */
function category_get_row( $p_category_id, $p_error_if_not_exists = true ) {
	global $g_category_cache;

	$p_category_id = (int)$p_category_id;

	if( isset( $g_category_cache[$p_category_id] ) ) {
		return $g_category_cache[$p_category_id];
	}

	db_param_push();
	$t_query = 'SELECT * FROM {category} WHERE id=' . db_param();
	$t_result = db_query( $t_query, array( $p_category_id ) );
	$t_row = db_fetch_array( $t_result );
	if( !$t_row ) {
		if( $p_error_if_not_exists ) {
			trigger_error( ERROR_CATEGORY_NOT_FOUND, ERROR );
		} else {
			return false;
		}
	}

	$g_category_cache[$p_category_id] = $t_row;
	return $t_row;
}

/**
 * Sort categories based on what project they're in.
 *
 * Call beforehand with a single parameter to set a 'preferred' project.
 *
 * @param int|array $p_category1 Id of preferred project or array containing category details.
 * @param array|null $p_category2 Array containing category details.
 *
 * @return int|null An integer representing sort order.
 * @access public
 */
function category_sort_rows_by_project( $p_category1, ?array $p_category2 = null ) {
	static $s_project_id = null;
	if( is_null( $p_category2 ) ) {
		# Set a target project
		$s_project_id = $p_category1;
		return null;
	}

	if( !is_null( $s_project_id ) ) {
		if( $p_category1['project_id'] == $s_project_id && $p_category2['project_id'] != $s_project_id ) {
			return -1;
		}
		if( $p_category1['project_id'] != $s_project_id && $p_category2['project_id'] == $s_project_id ) {
			return 1;
		}
	}

	$t_proj_cmp = strcasecmp( (string)$p_category1['project_name'], (string)$p_category2['project_name'] );
	if( $t_proj_cmp != 0 ) {
		return $t_proj_cmp;
	}

	return strcasecmp( (string)$p_category1['name'], (string)$p_category2['name'] );
}

$g_cache_category_project = null;

/**
 * Cache categories from multiple projects
 * @param array $p_project_id_array Array of project identifiers.
 * @return void
 */
function category_cache_array_rows_by_project( array $p_project_id_array ) {
	global $g_category_cache, $g_cache_category_project;

	$c_project_id_array = array();

	foreach( $p_project_id_array as $t_project_id ) {
		if( !isset( $g_cache_category_project[(int)$t_project_id] ) ) {
			$c_project_id_array[] = (int)$t_project_id;
			$g_cache_category_project[(int)$t_project_id] = array();
		}
	}

	if( empty( $c_project_id_array ) ) {
		return;
	}

	$t_query = 'SELECT c.*, p.name AS project_name FROM {category} c
				LEFT JOIN {project} p
					ON c.project_id=p.id
				WHERE project_id IN ( ' . implode( ', ', $c_project_id_array ) . ' )
				ORDER BY c.name ';
	$t_result = db_query( $t_query );

	$t_rows = array();
	while( $t_row = db_fetch_array( $t_result ) ) {
		$g_category_cache[(int)$t_row['id']] = $t_row;

		$t_rows[(int)$t_row['project_id']][] = $t_row['id'];
	}

	foreach( $t_rows as $t_project_id => $t_row ) {
		$g_cache_category_project[(int)$t_project_id] = $t_row;
	}
}

/**
 *	Get a distinct array of categories accessible to the current user for
 *	the specified projects.  If no project is specified, use the current project.
 *	If the current project is ALL_PROJECTS get all categories for all accessible projects.
 *	For all cases, get global categories and subproject categories according to configured inheritance settings.
 *	@param integer|null $p_project_id A specific project or null.
 *	@return array A unique array of category names
 */
function category_get_filter_list( $p_project_id = null ) {
	if( null === $p_project_id ) {
		$t_project_id = helper_get_current_project();
	} else {
		$t_project_id = $p_project_id;
	}

	if( $t_project_id == ALL_PROJECTS ) {
		$t_project_ids = current_user_get_accessible_projects();
	} else {
		$t_project_ids = array( $t_project_id );
	}

	$t_subproject_ids = array();
	foreach( $t_project_ids as $t_project_id ) {
		$t_subproject_ids = array_merge( $t_subproject_ids, current_user_get_all_accessible_subprojects( $t_project_id ) );
	}

	$t_project_ids = array_merge( $t_project_ids, $t_subproject_ids );

	$t_categories = array();
	foreach( $t_project_ids as $t_id ) {
		$t_categories = array_merge( $t_categories, category_get_all_rows( $t_id ) );
	}

	$t_unique = array();
	foreach( $t_categories as $t_category ) {
		if( !in_array( $t_category['name'], $t_unique ) ) {
			$t_unique[] = $t_category['name'];
		}
	}

	return $t_unique;
}

/**
 * Return all categories for the specified project id.
 *
 * Obeys project hierarchies and such.
 *
 * @param int  $p_project_id      A Project identifier.
 * @param bool $p_inherit         Indicates whether to inherit categories from parent projects,
 *                                or null to use configuration default.
 * @param bool $p_sort_by_project Whether to sort by project.
 * @param bool $p_enabled_only    False to select all categories or True for just the active ones.
 *
 * @return array array of categories
 * @access public
 */
function category_get_all_rows( $p_project_id, $p_inherit = null, $p_sort_by_project = false, $p_enabled_only = false ) {
	global $g_category_cache, $g_cache_category_project;

	if( isset( $g_cache_category_project[(int)$p_project_id] ) ) {
		$t_categories = array();
		if( !empty( $g_cache_category_project[(int)$p_project_id]) ) {
			foreach( $g_cache_category_project[(int)$p_project_id] as $t_id ) {
				$t_categories[] = category_get_row( $t_id );
			}

			if( $p_sort_by_project ) {
				category_sort_rows_by_project( $p_project_id );
				usort( $t_categories, 'category_sort_rows_by_project' );

				# TODO: passing null may be a bug here
				category_sort_rows_by_project( null );
			}
		}
		return $t_categories;
	}

	$c_project_id = (int)$p_project_id;

	if( $c_project_id == ALL_PROJECTS ) {
		$t_inherit = false;
	} else {
		if( $p_inherit === null ) {
			$t_inherit = config_get( 'subprojects_inherit_categories' );
		} else {
			$t_inherit = $p_inherit;
		}
	}

	if( $t_inherit ) {
		$t_project_ids = project_hierarchy_inheritance( $p_project_id );
		$t_project_where = ' project_id IN ( ' . implode( ', ', $t_project_ids ) . ' ) ';
	} else {
		$t_project_where = ' project_id=' . $p_project_id . ' ';
	}

	if( $p_enabled_only ) {
		$t_project_where .= ' and c.status = ' . CATEGORY_STATUS_ENABLED;
	}
	
	$t_query = 'SELECT c.*, p.name AS project_name FROM {category} c
				LEFT JOIN {project} p
					ON c.project_id=p.id
				WHERE ' . $t_project_where . ' ORDER BY c.name';
	$t_result = db_query( $t_query );
	$t_rows = array();
	while( $t_row = db_fetch_array( $t_result ) ) {
		$t_rows[] = $t_row;
		$g_category_cache[(int)$t_row['id']] = $t_row;
	}

	if( $p_sort_by_project ) {
		category_sort_rows_by_project( $p_project_id );
		usort( $t_rows, 'category_sort_rows_by_project' );

		# TODO: passing null may be a bug here
		category_sort_rows_by_project( null );
	}

	return $t_rows;
}

/**
 * Cache an set of category ids
 * @param array $p_cat_id_array Array of category identifiers.
 * @return void
 * @access public
 */
function category_cache_array_rows( array $p_cat_id_array ) {
	global $g_category_cache;
	$c_cat_id_array = array();

	foreach( $p_cat_id_array as $t_cat_id ) {
		if( !isset( $g_category_cache[(int)$t_cat_id] ) ) {
			$c_cat_id_array[] = (int)$t_cat_id;
		}
	}

	if( empty( $c_cat_id_array ) ) {
		return;
	}

	$t_query = 'SELECT c.*, p.name AS project_name FROM {category} c
				LEFT JOIN {project} p
					ON c.project_id=p.id
				WHERE c.id IN (' . implode( ',', $c_cat_id_array ) . ')';
	$t_result = db_query( $t_query );

	while( $t_row = db_fetch_array( $t_result ) ) {
		$g_category_cache[(int)$t_row['id']] = $t_row;
	}
}

/**
 * Given a category id and a field name, this function returns the field value.
 * An error will be triggered for a non-existent category id or category id = 0.
 * @param integer $p_category_id A category identifier.
 * @param string  $p_field_name  Field name.
 * @return string field value
 * @access public
 */
function category_get_field( $p_category_id, $p_field_name ) {
	$t_row = category_get_row( $p_category_id );
	return $t_row[$p_field_name];
}

/**
 * Given a category id, this function returns the category name.
 * An error will be triggered for a non-existent category id or category id = 0.
 * @param integer $p_category_id A category identifier.
 * @return string category name
 * @access public
 */
function category_get_name( $p_category_id ) {
	return category_get_field( $p_category_id, 'name' );
}

/**
 * Given a category name and project, this function returns the category id.
 * An error will be triggered if the specified project does not have a
 * category with that name.
 * @param string  $p_category_name  Category name to retrieve.
 * @param integer $p_project_id     A project identifier.
 * @param boolean $p_trigger_errors Whether to trigger error on failure.
 * @return boolean
 * @access public
 */
function category_get_id_by_name( $p_category_name, $p_project_id, $p_trigger_errors = true ) {
	$t_project_name = project_get_name( $p_project_id );

	db_param_push();
	$t_query = 'SELECT id FROM {category} WHERE name=' . db_param() . ' AND project_id=' . db_param();
	$t_result = db_query( $t_query, array( $p_category_name, (int)$p_project_id ) );
	$t_id = db_result( $t_result );
	if( $t_id === false ) {
		if( $p_trigger_errors ) {
			error_parameters( $p_category_name, $t_project_name );
			trigger_error( ERROR_CATEGORY_NOT_FOUND_FOR_PROJECT, ERROR );
		} else {
			return false;
		}
	}

	return $t_id;
}

/**
 * Retrieves category name (including project name if required)
 * @param string  $p_category_id     Category identifier.
 * @param boolean $p_show_project    Show project details.
 * @param integer $p_current_project Current project id override.
 * @return string category full name
 * @access public
 */
function category_full_name( $p_category_id, $p_show_project = true, $p_current_project = null ) {
	if( 0 == $p_category_id ) {
		# No Category
		return lang_get( 'no_category' );
	} else if( !category_exists( $p_category_id ) ) {
		return '@' . $p_category_id . '@';
	} else {
		$t_row = category_get_row( $p_category_id );
		$t_project_id = $t_row['project_id'];

		$t_current_project = is_null( $p_current_project ) ? helper_get_current_project() : $p_current_project;

		if( $p_show_project && $t_project_id != $t_current_project ) {
			return '[' . project_get_name( $t_project_id ) . '] ' . $t_row['name'];
		}

		return $t_row['name'];
	}
}

/**
 * Check category can be deleted 
 * @param string $p_category_id Category identifier.
 * @return boolean Return true if the category valid for delete, otherwise false
 * @access public
 */
function category_can_delete( $p_category_id ) {
	db_param_push();
	$t_query = 'SELECT COUNT(id) FROM {bug} WHERE category_id=' . db_param();
	$t_bug_count = db_result( db_query( $t_query, array( $p_category_id ) ) );
	return $t_bug_count == 0;
}

/**
 * Ensure category can be deleted, otherwise raise an error.
 * @param string $p_category_id Category identifier.
 * @return void
 * @access public
 */
function category_ensure_can_delete( $p_category_id ) {
	if( !category_can_delete( $p_category_id ) ) {
		$t_category_name = category_get_name( $p_category_id );
		error_parameters( $t_category_name );
		trigger_error( ERROR_CATEGORY_CANNOT_DELETE_HAS_ISSUES, ERROR );
	}
}

/**
 * Check if category is enabled.
 *
 * Category 0 (no category) is always considered as enabled.
 *
 * @param int $p_category_id Category identifier.
 *
 * @return bool True if enabled, false otherwise
 */
function category_is_enabled( $p_category_id ) {
	return $p_category_id == 0
		|| category_get_field( $p_category_id, 'status' ) == CATEGORY_STATUS_ENABLED;
}
