<?php

namespace App\Http\Controllers\Api;

use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Requests\BulkUpdateAssetsRequest;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetRequest;
use App\Http\Requests\UpdateAssetRequest;
use App\Http\Requests\UploadFileRequest;
use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\AccessoryCheckout;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\ComponentAssignment;
use App\Models\CustomField;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Location;
use App\Models\Setting;
use App\Models\User;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

/**
 * This class controls all actions related to assets for
 * the Snipe-IT Asset Management application.
 *
 * @version    v1.0
 *
 * @author [A. Gianotto] [<snipe@snipe.net>]
 */
class AssetsController extends Controller
{
    use MigratesLegacyAssetLocations;

    /**
     * Returns JSON listing of all assets
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v4.0]
     */
    public function index(FilterRequest $request, $action = null, $upcoming_status = null): JsonResponse|array
    {

        // This handles the legacy audit endpoints :(
        if ($action == 'audit') {
            $action = 'audits';
        }
        $filter_non_deprecable_assets = false;

        /**
         * This looks MAD janky (and it is), but the AssetsController@index does a LOT of heavy lifting throughout the
         * app. This bit here just makes sure that someone without permission to view assets doesn't
         * end up with priv escalations because they asked for a different endpoint.
         *
         * Since we never gave the specification for which transformer to use before, it should default
         * gracefully to just use the AssetTransformer by default, which shouldn't break anything.
         *
         * It was either this mess, or repeating ALL of the searching and sorting and filtering code,
         * which would have been far worse of a mess. *sad face*  - snipe (Sept 1, 2021)
         */
        if (Route::currentRouteName() == 'api.depreciation-report.index') {
            $filter_non_deprecable_assets = true;
            $transformer = 'App\Http\Transformers\DepreciationReportTransformer';
            $this->authorize('reports.view');
        } else {
            $transformer = 'App\Http\Transformers\AssetsTransformer';
            $this->authorize('index', Asset::class);
        }

        $settings = Setting::getSettings();

        $allowed_columns = [
            'id',
            'name',
            'asset_tag',
            'serial',
            'model_number',
            'last_checkout',
            'last_checkin',
            'notes',
            'expected_checkin',
            'order_number',
            'image',
            'assigned_to',
            'created_at',
            'updated_at',
            'purchase_date',
            'purchase_cost',
            'last_audit_date',
            'next_audit_date',
            'warranty_months',
            'checkout_counter',
            'checkin_counter',
            'requests_counter',
            'byod',
            'asset_eol_date',
            'requestable',
            'jobtitle',
            // These are *relationships* so we wouldn't normally include them in this array,
            // since they would normally create a `column not found` error,
            // BUT we account for them in the ordering switch down at the end of this method
            // DO NOT ADD ANYTHING TO THIS LIST WITHOUT CHECKING THE ORDERING SWITCH BELOW!
            'company',
            'model',
            'location',
            'rtd_location',
            'category',
            'manufacturer',
            'supplier',
            'status',
            'jobtitle',
            'assigned_to',
            'created_by',

        ];

        $all_custom_fields = CustomField::all(); // used as a 'cache' of custom fields throughout this page load

        foreach ($all_custom_fields as $field) {
            $allowed_columns[] = $field->db_column_name();
        }

        $assets = Asset::select('assets.*')
//            ->addSelect([
//                'first_checkout_at' => Actionlog::query()
//                    ->select('created_at')
//                    ->whereColumn('item_id', 'assets.id')
//                    ->where('item_type', Asset::class)
//                    ->where('action_type', 'checkout')
//                    ->orderBy('created_at')
//                    ->limit(1),
//            ])
            ->with(
                'model',
                'location',
                'status',
                'company',
                'defaultLoc',
                'assignedTo',
                'adminuser',
                'model.depreciation',
                'model.category',
                'model.manufacturer',
                'model.fieldset',
                'model.depreciation',
                'supplier'
            ); // it might be tempting to add 'assetlog' here, but don't. It blows up update-heavy users.

        if ($filter_non_deprecable_assets) {
            $non_deprecable_models = AssetModel::select('id')->whereNotNull('depreciation_id')->get();
            $assets->InModelList($non_deprecable_models->toArray());
        }

        // This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
        if ($request->filled('filter') || $request->filled('search')) {
            $assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
        }

        /**
         * Handle due and overdue audits and checkin dates
         */
        switch ($action) {
            // Audit (singular) is left over from earlier legacy APIs
            case 'audits':
                switch ($upcoming_status) {
                    case 'due':
                        $assets->DueForAudit($settings);
                        break;
                    case 'overdue':
                        $assets->OverdueForAudit();
                        break;
                    case 'due-or-overdue':
                        $assets->DueOrOverdueForAudit($settings);
                        break;
                }
                break;

            case 'checkins':
                switch ($upcoming_status) {
                    case 'due':
                        $assets->DueForCheckin($settings);
                        break;
                    case 'overdue':
                        $assets->OverdueForCheckin();
                        break;
                    case 'due-or-overdue':
                        $assets->DueOrOverdueForCheckin($settings);
                        break;
                }
                break;
        }

        /**
         * End handling due and overdue audits and checkin dates
         */

        // This is used by the sidenav, mostly

        // This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
        // to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
        // with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
        // their API integration code
        $status_type_key = null;
        if ($request->filled('status_type')) {
            $status_type_key = $request->input('status_type');
        } elseif ($request->filled('status')) {
            $status_type_key = $request->input('status');
        }

        switch ($status_type_key) {
            case 'Deleted':
                $assets->onlyTrashed();
                break;
            case 'Pending':
                $assets->join('status_labels AS status_alias', function ($join) {
                    $join->on('status_alias.id', '=', 'assets.status_id')
                        ->where('status_alias.deployable', '=', 0)
                        ->where('status_alias.pending', '=', 1)
                        ->where('status_alias.archived', '=', 0);
                });
                break;
            case 'RTD':
                $assets->whereNull('assets.assigned_to')
                    ->join('status_labels AS status_alias', function ($join) {
                        $join->on('status_alias.id', '=', 'assets.status_id')
                            ->where('status_alias.deployable', '=', 1)
                            ->where('status_alias.pending', '=', 0)
                            ->where('status_alias.archived', '=', 0);
                    });
                break;
            case 'Undeployable':
                $assets->Undeployable();
                break;
            case 'Archived':
                $assets->join('status_labels AS status_alias', function ($join) {
                    $join->on('status_alias.id', '=', 'assets.status_id')
                        ->where('status_alias.deployable', '=', 0)
                        ->where('status_alias.pending', '=', 0)
                        ->where('status_alias.archived', '=', 1);
                });
                break;
            case 'Requestable':
                $assets->where('assets.requestable', '=', 1)
                    ->join('status_labels AS status_alias', function ($join) {
                        $join->on('status_alias.id', '=', 'assets.status_id')
                            ->where('status_alias.deployable', '=', 1)
                            ->where('status_alias.pending', '=', 0)
                            ->where('status_alias.archived', '=', 0);
                    });

                break;
            case 'Deployed':
                // more sad, horrible workarounds for laravel bugs when doing full text searches
                $assets->whereNotNull('assets.assigned_to');
                break;
            case 'byod':
                // This is kind of redundant, since we already check for byod=1 above, but this keeps the
                // sidebar nav links a little less chaotic
                $assets->where('assets.byod', '=', '1');
                break;
            default:

                if ((! $request->filled('status_id')) && ($settings->show_archived_in_list != '1')) {
                    // terrible workaround for complex-query Laravel bug in fulltext
                    $assets->join('status_labels AS status_alias', function ($join) {
                        $join->on('status_alias.id', '=', 'assets.status_id')
                            ->where('status_alias.archived', '=', 0);
                    });

                    // If there is a status ID, don't take show_archived_in_list into consideration
                } else {
                    $assets->join('status_labels AS status_alias', function ($join) {
                        $join->on('status_alias.id', '=', 'assets.status_id');
                    });
                }
        }

        // Leave these under the TextSearch scope, else the fuzziness will override the specific ID (status ID, etc) requested
        if ($request->filled('status_id')) {
            $assets->where('assets.status_id', '=', $request->input('status_id'));
        }

        if ($request->filled('asset_tag')) {
            $assets->where('assets.asset_tag', '=', $request->input('asset_tag'));
        }

        if ($request->filled('serial')) {
            $assets->where('assets.serial', '=', $request->input('serial'));
        }

        if ($request->input('requestable') == 'true') {
            $assets->where('assets.requestable', '=', '1');
        }

        if ($request->filled('model_id')) {
            // If model_id is already an array, just use it as-is
            if (is_array($request->input('model_id'))) {
                $assets->InModelList($request->input('model_id'));
            } else {
                // Otherwise, turn it into an array
                $assets->InModelList([$request->input('model_id')]);
            }
        }

        if ($request->filled('category_id')) {
            $assets->InCategory($request->input('category_id'));
        }

        if ($request->filled('location_id')) {
            $assets->where('assets.location_id', '=', $request->input('location_id'));
        }

        if ($request->filled('rtd_location_id')) {
            $assets->where('assets.rtd_location_id', '=', $request->input('rtd_location_id'));
        }

        if ($request->filled('supplier_id')) {
            $assets->where('assets.supplier_id', '=', $request->input('supplier_id'));
        }

        if ($request->filled('asset_eol_date')) {
            $assets->where('assets.asset_eol_date', '=', $request->input('asset_eol_date'));
        }

        if (($request->filled('assigned_to')) && ($request->filled('assigned_type'))) {
            $assets->where('assets.assigned_to', '=', $request->input('assigned_to'))
                ->where('assets.assigned_type', '=', $request->input('assigned_type'));
        }

        if ($request->filled('company_id')) {
            // expand_company_hierarchy=1 opts the company show-page tabs into the
            // parent/child rollup so a child shows items inherited from its parent.
            if ($request->boolean('expand_company_hierarchy')) {
                $assets->whereIn('assets.company_id', Company::reachableCompanyIds($request->input('company_id')));
            } else {
                $assets->where('assets.company_id', '=', $request->input('company_id'));
            }
        }

        if ($request->filled('manufacturer_id')) {
            $assets->ByManufacturer($request->input('manufacturer_id'));
        }

        if ($request->filled('depreciation_id')) {
            $assets->ByDepreciationId($request->input('depreciation_id'));
        }

        if ($request->filled('byod')) {
            $assets->where('assets.byod', '=', $request->input('byod'));
        }

        if ($request->filled('order_number')) {
            $assets->where('assets.order_number', '=', strval($request->input('order_number')));
        }

        foreach ($all_custom_fields as $field) {
            if ($field->db_column_name() && $request->filled($field->db_column_name())) {
                $assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
            }
        }

        // This is kinda gross, but we need to do this because the Bootstrap Tables
        // API passes custom field ordering as custom_fields.fieldname, and we have to strip
        // that out to let the default sorter below order them correctly on the assets table.
        $sort_override = str_replace('custom_fields.', '', $request->input('sort'));

        // This handles all of the pivot sorting (versus the assets.* fields
        // in the allowed_columns array)
        $column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets.created_at';

        $order = $request->input('order') === 'asc' ? 'asc' : 'desc';

        switch ($sort_override) {
            case 'model':
                $assets->OrderModels($order);
                break;
            case 'model_number':
                $assets->OrderModelNumber($order);
                break;
            case 'category':
                $assets->OrderCategory($order);
                break;
            case 'manufacturer':
                $assets->OrderManufacturer($order);
                break;
            case 'company':
                $assets->OrderCompany($order);
                break;
            case 'location':
                $assets->OrderLocation($order);
                break;
            case 'rtd_location':
                $assets->OrderRtdLocation($order);
                break;
            case 'status':
                $assets->OrderStatus($order);
                break;
            case 'supplier':
                $assets->OrderSupplier($order);
                break;
            case 'assigned_to':
                $assets->OrderAssigned($order);
                break;
            case 'jobtitle':
                $assets->OrderByJobTitle($order);
                break;
            case 'created_by':
                $assets->OrderByCreatedByName($order);
                break;
            case 'eol':
                $assets->orderBy('assets.asset_eol_date', $order);
                break;
            default:
                $numeric_sort = false;

                // Search through the custom fields array to see if we're sorting on a custom field
                if (array_search($column_sort, $all_custom_fields->pluck('db_column')->toArray()) !== false) {

                    // Check to see if this is a numeric field type
                    foreach ($all_custom_fields as $field) {
                        if (($field->db_column == $sort_override) && ($field->format == 'NUMERIC')) {
                            $numeric_sort = true;
                            break;
                        }
                    }

                    // This may not work for all databases, but it works for MySQL
                    if ($numeric_sort) {
                        $assets->orderByRaw(DB::getTablePrefix().'assets.'.$sort_override.' * 1 '.$order);
                    } else {
                        $assets->orderBy($sort_override, $order);
                    }
                } else {
                    $assets->orderBy($column_sort, $order);
                }
                break;
        }

        // Make sure the offset and limit are actually integers and do not exceed system limits
        $total = $assets->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');

        $assets = $assets->skip($offset)->take($limit)->get();

        /**
         * Include additional associated relationships
         */
        if ($request->input('components')) {
            $assets->loadMissing(['components' => function ($query) {
                $query->orderBy('created_at', 'desc');
            }]);
        }

        return (new $transformer)->transformAssets($assets, $total, $request);
    }

    /**
     * Returns JSON with information about an asset (by tag) for detail view.
     *
     * @param  string  $tag
     *
     * @since [v4.2.1]
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     */
    public function showByTag(Request $request, $tag): JsonResponse|array
    {
        $this->authorize('index', Asset::class);
        $assets = Asset::where('asset_tag', $tag)->with('status')->with('assignedTo');

        // Check if they've passed ?deleted=true
        if ($request->input('deleted', 'false') == 'true') {
            $assets = $assets->withTrashed();
        }

        if (($assets = $assets->get()) && ($total = $assets->count()) > 0) {

            // If there is exactly one result and the deleted parameter is not passed, we should pull the first (and only)
            // asset from the returned collection, since transformAsset() expects an Asset object, NOT a collection
            if (($total == 1) && ($request->input('deleted') != 'true')) {
                return (new AssetsTransformer)->transformAsset($assets->first());

                // If there is more than one result OR if the endpoint is requesting deleted items (even if there is only one
                // match, return the normal collection transformed.
            } else {
                return (new AssetsTransformer)->transformAssets($assets, $total);
            }
        }

        // If there are 0 results, return the "no such asset" response
        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
    }

    /**
     * Returns JSON with information about an asset (by serial) for detail view.
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  string  $serial
     *
     * @since [v4.2.1]
     *
     * @return JsonResponse
     */
    public function showBySerial(Request $request, $serial): JsonResponse|array
    {
        $this->authorize('index', Asset::class);
        $assets = Asset::where('serial', $serial)->with([
            'status',
            'assignedTo',
            'company',
            'defaultLoc',
            'location',
            'model.category',
            'model.depreciation',
            'model.fieldset',
            'model.manufacturer',
            'supplier',
        ]);

        // Check if they've passed ?deleted=true
        if ($request->input('deleted', 'false') == 'true') {
            $assets = $assets->withTrashed();
        }

        $total = $assets->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');

        $assets = $assets->skip($offset)->take($limit)->get();

        if (($assets) && ($assets->count()) > 0) {
            return (new AssetsTransformer)->transformAssets($assets, $total);
        }

        // If there are 0 results, return the "no such asset" response
        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
    }

    /**
     * Returns JSON with information about an asset for detail view.
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v4.0]
     *
     * @return JsonResponse
     */
    public function show(Request $request, $id): JsonResponse|array
    {
        if ($asset = Asset::with('status')
            ->with('assignedTo')->withTrashed()
            ->withCount('checkins as checkins_count', 'checkouts as checkouts_count', 'userRequests as user_requests_count')->find($id)
        ) {
            $this->authorize('view', $asset);

            return (new AssetsTransformer)->transformAsset($asset, $request->input('components'));
        }

        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
    }

    public function licenses(Asset $asset): array
    {
        $this->authorize('view', $asset);
        $this->authorize('view', License::class);
        $licenses = $asset->licenseseats()->get();

        return (new AssetsTransformer)->transformLicensesCheckedToAsset($licenses, $licenses->count());
    }

    /**
     * Gets a paginated collection for the select2 menus
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @since [v4.0.16]
     * @see SelectlistTransformer
     */
    public function selectlist(Request $request): array
    {
        $this->authorize('view.selectlists');

        $assets = Asset::select([
            'assets.id',
            'assets.name',
            'assets.asset_tag',
            'assets.model_id',
            'assets.assigned_to',
            'assets.assigned_type',
            'assets.status_id',
        ])->with('model', 'status', 'assignedTo')
            ->NotArchived();

        // When FMCS is enabled, automatically scope to companies the acting user belongs to.
        // scopeCompanyables is a no-op for superusers and when FMCS is disabled.
        $assets = Company::scopeCompanyables($assets);

        // Allow further narrowing to a specific company passed via data-company-id on the select.
        // Superusers MUST bypass this filter — they manage across companies and need to see every
        // asset on checkout dropdowns. Scoping superusers to the item's company breaks the umbrella-
        // corp / service-provider workflow where one admin checks items out across sub-companies.
        // See: https://github.com/snipe/snipe-it/issues/ (v8.6.3 regression report)
        if ((Setting::getSettings()->full_multiple_companies_support == '1')
            && $request->filled('companyId')
            && ! auth()->user()->isSuperUser()) {
            $companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
            if (! empty($companyIds)) {
                if (Setting::getSettings()->null_company_is_floater) {
                    // Floater mode: include null-company (floater) assets too,
                    // matching the "items from any company can be checked out
                    // to targets with no company assignment" policy. Without
                    // this the whereIn below hid all floaters from the
                    // checkout dropdown while server-side canCheckoutTo still
                    // permitted the checkout (#19394).
                    $assets->where(function ($q) use ($companyIds) {
                        $q->whereIn('assets.company_id', $companyIds)
                            ->orWhereNull('assets.company_id');
                    });
                } else {
                    $assets->whereIn('assets.company_id', $companyIds);
                }
            }
        }

        if ($request->filled('excludeId')) {
            $assets->where('assets.id', '!=', (int) $request->input('excludeId'));
        }

        if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
            $assets = $assets->RTD();
        }

        if ($request->filled('search')) {
            $assets = $assets->AssignedSearch($request->input('search'));
        }

        $assets = $assets->paginate(50);

        // Loop through and set some custom properties for the transformer to use.
        // This lets us have more flexibility in special cases like assets, where
        // they may not have a ->name value but we want to display something anyway
        foreach ($assets as $asset) {

            $asset->use_text = $asset->present()->fullName;

            if (($asset->checkedOutToUser()) && ($asset->assigned)) {
                $asset->use_text .= ' → '.$asset->assigned->display_name;
            }

            if ($asset->status->getStatuslabelType() == 'pending') {
                $asset->use_text .= '('.$asset->status->getStatuslabelType().')';
            }

            $asset->use_image = ($asset->getImageUrl()) ? $asset->getImageUrl() : null;
        }

        return (new SelectlistTransformer)->transformSelectlist($assets);
    }

    /**
     * Accepts a POST request to create a new asset
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  ImageUploadRequest  $request
     *
     * @since [v4.0]
     */
    public function store(StoreAssetRequest $request): JsonResponse
    {
        $asset = new Asset;
        $asset->model()->associate(AssetModel::find((int) $request->input('model_id')));

        $asset->fill($request->validated());
        $asset->created_by = auth()->id();

        /**
         * this is here just legacy reasons. Api\AssetController
         * used image_source  once to allow encoded image uploads.
         */
        if ($request->has('image_source')) {
            $request->offsetSet('image', $request->offsetGet('image_source'));
        }

        $asset = $request->handleImages($asset);

        // Update custom fields in the database.
        $model = AssetModel::find($request->input('model_id'));

        // Check that it's an object and not a collection
        // (Sometimes people send arrays here and they shouldn't
        if (($model) && ($model instanceof AssetModel) && ($model->fieldset)) {
            foreach ($model->fieldset->fields as $field) {

                // Set the field value based on what was sent in the request
                $field_val = $request->input($field->db_column, null);

                // If input value is null, use custom field's default value
                if ($field_val == null) {
                    Log::debug('Field value for '.$field->db_column.' is null');
                    $field_val = $field->defaultValue($request->input('model_id'));
                    Log::debug('Use the default fieldset value of '.$field->defaultValue($request->input('model_id')));
                }

                // if the field is set to encrypted, make sure we encrypt the value
                if ($field->field_encrypted == '1') {
                    Log::debug('This model field is encrypted in this fieldset.');

                    if (Gate::allows('assets.view.encrypted_custom_fields')) {

                        // If input value is null, use custom field's default value
                        if (($field_val == null) && ($request->has('model_id') != '')) {
                            $field_val = Crypt::encrypt($field->defaultValue($request->input('model_id')));
                        } else {
                            $field_val = Crypt::encrypt($request->input($field->db_column));
                        }
                    } else {
                        continue;
                    }
                }
                if ($field->element == 'checkbox') {
                    if (is_array($field_val)) {
                        $field_val = implode(',', $field_val);
                    }
                }

                $asset->{$field->db_column} = $field_val;
            }
        }

        $target = $this->resolveCheckoutTargetForAssetMutation($request);
        $requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');

        if ($requestedCheckout && (! $target)) {
            return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
        }

        if ($requestedCheckout) {
            $companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
            if ($companyMismatchResponse) {
                return $companyMismatchResponse;
            }
        }

        $stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
            if (! $asset->save()) {
                return false;
            }

            if ($requestedCheckout) {
                // Keep create + optional checkout side effects atomic.
                return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
            }

            return true;
        });

        if ($stored) {

            if ($asset->image) {
                $asset->image = $asset->getImageUrl();
            }

            return response()->json(Helper::formatStandardApiResponse('success', $asset, trans('admin/hardware/message.create.success')));

            // below is what we want the _eventual_ return to look like - in a more standardized format.
            // return response()->json(Helper::formatStandardApiResponse('success', (new AssetsTransformer)->transformAsset($asset), trans('admin/hardware/message.create.success')));

        }

        return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
    }

    /**
     * Update a single asset. Route-model-binding on {asset} guarantees the
     * caller is either operating on a real Asset or 404'd at routing.
     * Response shape is the legacy `{status, messages, payload}` body every
     * integration already knows.
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @since [v4.0]
     */
    public function update(UpdateAssetRequest $request, Asset $asset): JsonResponse
    {
        $result = $this->applyAssetUpdate($asset, $request);

        return response()->json($this->formatSingleAssetResponse($result));
    }

    /**
     * Bulk update: `ids` in the request body names which assets to touch,
     * every other field is the update payload applied to each of them.
     * Per-row envelope response so callers can see which ids succeeded and
     * which failed without looping the endpoint themselves.
     */
    public function bulkUpdate(BulkUpdateAssetsRequest $request): JsonResponse
    {
        $ids = array_values(array_unique(array_map('intval', (array) $request->input('ids', []))));

        $assets = Asset::whereIn('id', $ids)->get()->keyBy('id');

        $results = [];
        $success_count = 0;
        $error_count = 0;

        foreach ($ids as $id) {
            if (! $assets->has($id)) {
                $results[] = [
                    'id' => $id,
                    'status' => 'error',
                    'messages' => trans('admin/hardware/message.does_not_exist'),
                    'payload' => null,
                ];
                $error_count++;

                continue;
            }

            $asset = $assets->get($id);

            // Per-row auth: a caller who can update one asset may not be able
            // to update another (FMCS, tightened policy). Deny one row rather
            // than 403ing the whole batch.
            if (! Gate::allows('update', $asset)) {
                $results[] = [
                    'id' => $id,
                    'status' => 'error',
                    'messages' => trans('general.unauthorized'),
                    'payload' => null,
                ];
                $error_count++;

                continue;
            }

            $result = $this->applyAssetUpdate($asset, $request);
            $row = $this->buildRowResult($id, $result);
            $results[] = $row;

            $row['status'] === 'success' ? $success_count++ : $error_count++;
        }

        return response()->json($this->assembleBulkResponse($results, $success_count, $error_count));
    }

    /**
     * Turn a single applyAssetUpdate() result into the per-row shape used by the
     * bulk response. Reuses formatSingleAssetResponse() so the row's payload +
     * error handling stays byte-identical to the legacy singular response.
     */
    private function buildRowResult(int $id, array $result): array
    {
        $formatted = $this->formatSingleAssetResponse($result);

        return [
            'id' => $id,
            'status' => $formatted['status'],
            'messages' => $formatted['messages'],
            'payload' => $formatted['payload'],
        ];
    }

    /**
     * Compute the overall status + summary message for a batch and return the
     * full bulk response envelope. Used by bulkUpdate() and by update() when
     * the caller opts into the bulk shape via `?response=bulk`.
     */
    private function assembleBulkResponse(array $results, int $success_count, int $error_count): array
    {
        $overall_status = match (true) {
            $error_count === 0 => 'success',
            $success_count === 0 => 'error',
            default => 'partial',
        };

        $message = match ($overall_status) {
            'success' => trans_choice('admin/hardware/message.bulk_update.success', $success_count, ['count' => $success_count]),
            'partial' => trans('admin/hardware/message.bulk_update.partial', ['success' => $success_count, 'failed' => $error_count]),
            'error' => trans('admin/hardware/message.bulk_update.error'),
        };

        return [
            'status' => $overall_status,
            'messages' => $message,
            'results' => $results,
        ];
    }

    /**
     * Format an applyAssetUpdate() result into the legacy single-asset API
     * response body. Used by update() (default shape) and by buildRowResult()
     * to shape each row of a bulk response.
     *
     * Kept as a flat asset payload (not through AssetsTransformer) so the
     * response continues to match the shape existing integrations rely on.
     */
    private function formatSingleAssetResponse(array $result): array
    {
        if (! empty($result['error_response_key'])) {
            return Helper::formatStandardApiResponse('error', null, trans($result['error_response_key']));
        }

        if (! $result['success']) {
            return Helper::formatStandardApiResponse('error', null, $result['errors']);
        }

        return Helper::formatStandardApiResponse('success', $result['asset'], trans($result['message']));
    }

    /**
     * Apply a validated UpdateAssetRequest payload to a single Asset.
     *
     * Shared between update() and bulkUpdate(), so the transaction, checkout
     * side-effects, and encrypted-custom-field gating live in one place.
     * Returns the neutral result shape that formatSingleAssetResponse() /
     * buildRowResult() turn into the legacy or bulk-envelope response body.
     *
     * @return array{
     *   success: bool,
     *   asset: Asset|null,
     *   message: string|null,
     *   errors: mixed,
     *   encrypted_field_warning: bool,
     *   error_response_key: string|null,
     * }
     *
     * `error_response_key` is set for early-exit conditions that need a
     * specific translation key rather than a validation-errors payload
     * (e.g. an unresolvable checkout target). Callers translate.
     */
    private function applyAssetUpdate(Asset $asset, UpdateAssetRequest $request): array
    {
        $validated = $request->validated();

        $asset->fill($validated);

        if ($request->has('model_id')) {
            $asset->model()->associate(AssetModel::find($validated['model_id']));
        }
        if ($request->has('company_id')) {
            $asset->company_id = Company::getIdForCurrentUser($validated['company_id']);
        }
        if ($request->has('rtd_location_id') && ! $request->has('location_id')) {
            $asset->location_id = $validated['rtd_location_id'];
        }
        if ($request->input('last_audit_date')) {
            $asset->last_audit_date = Carbon::parse($request->input('last_audit_date'))->startOfDay()->format('Y-m-d H:i:s');
        }

        // Legacy shim: an older release accepted image_source in place of image.
        if ($request->has('image_source')) {
            $request->offsetSet('image', $request->offsetGet('image_source'));
        }

        $asset = $request->handleImages($asset);
        $model = $asset->model;

        $problems_updating_encrypted_custom_fields = false;
        if ($model && isset($model->fieldset)) {
            foreach ($model->fieldset->fields as $field) {
                if (! $request->has($field->db_column)) {
                    continue;
                }

                $field_val = $request->input($field->db_column, null);

                if ($field->element === 'checkbox' && is_array($field_val)) {
                    $field_val = implode(',', $field_val);
                }

                if ($field->field_encrypted == '1') {
                    if (Gate::allows('assets.view.encrypted_custom_fields')) {
                        $field_val = Crypt::encrypt($field_val);
                    } else {
                        // Skip encrypted-field updates for callers without the
                        // permission, but flag it so the response can nudge them.
                        $problems_updating_encrypted_custom_fields = true;

                        continue;
                    }
                }

                $asset->{$field->db_column} = $field_val;
            }
        }

        $target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
        $requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');

        if ($requestedCheckout && ! $target) {
            return [
                'success' => false,
                'asset' => null,
                'message' => null,
                'errors' => null,
                'encrypted_field_warning' => false,
                'error_response_key' => 'admin/hardware/message.does_not_exist',
            ];
        }

        if ($requestedCheckout && ! $asset->canCheckoutTo($target)) {
            return [
                'success' => false,
                'asset' => null,
                'message' => null,
                'errors' => null,
                'encrypted_field_warning' => false,
                'error_response_key' => 'general.error_user_company',
            ];
        }

        // The PATCH/PUT update path accepts assigned_user / assigned_asset /
        // assigned_location for backwards compatibility with older callers,
        // but that meant a role with only assets.edit (and an explicit deny
        // on assets.checkout) could still trigger $asset->checkOut() and
        // record a checkout event, bypassing the permission entirely.
        // Enforce the same authorization the dedicated checkout endpoint
        // requires whenever the update payload asks for a checkout.
        if ($requestedCheckout && ! Gate::allows('checkout', $asset)) {
            return [
                'success' => false,
                'asset' => null,
                'message' => null,
                'errors' => null,
                'encrypted_field_warning' => false,
                'error_response_key' => 'general.unauthorized',
            ];
        }

        // Same as the dedicated checkout endpoint: an asset is only eligible
        // for checkout when it is not currently assigned to anyone and its
        // status is deployable / not archived. Rejecting here blocks
        //   (a) reassigning an already-checked-out asset without an
        //       intervening checkin (which the pre-fix code allowed and
        //       recorded as two consecutive checkout events), and
        //   (b) checking out an asset in a Pending / non-deployable status.
        // availableForCheckout() covers both. See App\Models\Asset::availableForCheckout.
        if ($requestedCheckout && ! $asset->availableForCheckout()) {
            return [
                'success' => false,
                'asset' => null,
                'message' => null,
                'errors' => null,
                'encrypted_field_warning' => false,
                'error_response_key' => 'admin/hardware/message.checkout.not_available',
            ];
        }

        $updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
            if (! $asset->save()) {
                return false;
            }

            if ($requestedCheckout) {
                // Concurrency guard, same shape as Api\AssetsController::checkout.
                // availableForCheckout() at line 1067 ran outside the transaction;
                // without a row lock, two racing PATCH requests that both include
                // assigned_user / assigned_asset / assigned_location could each
                // pass that check and both proceed through checkOut(), producing
                // duplicate checkout-history rows and a doubled checkout_counter.
                // Re-fetch the row under lockForUpdate and re-check availability
                // against the locked snapshot before invoking checkOut.
                $locked = Asset::whereKey($asset->id)->lockForUpdate()->first();
                if (! $locked || ! $locked->availableForCheckout()) {
                    return false;
                }

                // Preserve the asset name if the name wasn't in the payload.
                $asset_name = $request->has('name') ? $request->input('name') : $asset->name;

                $location = null;
                if ($request->filled('assigned_user')) {
                    $location = $target->location_id;
                } elseif ($request->filled('assigned_asset')) {
                    $location = $target->location_id;
                } elseif ($request->filled('assigned_location')) {
                    $location = $target->id;
                }

                if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
                    return false;
                }

                if ($request->filled('assigned_asset')) {
                    Asset::where('assigned_type', Asset::class)
                        ->where('assigned_to', $asset->id)
                        ->update(['location_id' => $target->location_id]);
                }
            }

            return true;
        });

        if (! $updated) {
            return [
                'success' => false,
                'asset' => null,
                'message' => null,
                'errors' => $asset->getErrors(),
                'encrypted_field_warning' => false,
                'error_response_key' => null,
            ];
        }

        if ($asset->image) {
            $asset->image = $asset->getImageUrl();
        }

        return [
            'success' => true,
            'asset' => $asset,
            'message' => $problems_updating_encrypted_custom_fields
                ? 'admin/hardware/message.update.encrypted_warning'
                : 'admin/hardware/message.update.success',
            'errors' => null,
            'encrypted_field_warning' => $problems_updating_encrypted_custom_fields,
            'error_response_key' => null,
        ];
    }

    /**
     * Resolve the assignee target from the request payload. Used by
     * applyAssetUpdate() as well as store() and checkout().
     */
    private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
    {
        if ($request->filled('assigned_user')) {
            return User::withoutGlobalScopes()->find($request->input('assigned_user'));
        }

        if ($request->filled('assigned_asset')) {
            return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
        }

        if ($request->filled('assigned_location')) {
            return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
        }

        return null;
    }

    /**
     * Company-mismatch check used by store() and checkout(). applyAssetUpdate()
     * inlines the equivalent check because it needs to return the neutral
     * result shape (not a JsonResponse) so the bulk envelope can wrap it.
     */
    private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
    {
        if (! $asset->canCheckoutTo($target)) {
            return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
        }

        return null;
    }

    /**
     * Delete a given asset (mark as deleted).
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v4.0]
     */
    public function destroy($id): JsonResponse
    {
        $this->authorize('delete', Asset::class);

        if ($asset = Asset::find($id)) {
            $this->authorize('delete', $asset);

            if ($asset->assignedTo) {

                $target = $asset->assignedTo;
                $checkin_at = date('Y-m-d H:i:s');
                $originalValues = $asset->getRawOriginal();
                event(new CheckoutableCheckedIn($asset, $target, auth()->user(), 'Checkin on delete', $checkin_at, $originalValues));
                DB::table('assets')
                    ->where('id', $asset->id)
                    ->update(['assigned_to' => null]);
            }

            $asset->delete();

            return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.delete.success')));
        }

        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
    }

    /**
     * Restore a soft-deleted asset.
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v5.1.18]
     */
    public function restore(Request $request, $assetId = null): JsonResponse
    {

        if ($asset = Asset::withTrashed()->find($assetId)) {
            $this->authorize('delete', $asset);

            if ($asset->deleted_at == '') {
                return response()->json(Helper::formatStandardApiResponse('error', trans('general.not_deleted', ['item_type' => trans('general.asset')])), 200);
            }

            if ($asset->restore()) {
                return response()->json(Helper::formatStandardApiResponse('success', trans('admin/hardware/message.restore.success')), 200);
            }

            // Check validation to make sure we're not restoring an asset with the same asset tag (or unique attribute) as an existing asset
            return response()->json(Helper::formatStandardApiResponse('error', trans('general.could_not_restore', ['item_type' => trans('general.asset'), 'error' => $asset->getErrors()->first()])), 200);
        }

        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
    }

    /**
     * Checkout an asset by its tag.
     *
     * @author [N. Butler]
     *
     * @param  string  $tag
     *
     * @since [v6.0.5]
     */
    public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
    {
        // Use the same hardened checkout path as ID-based checkout.
        if ($asset = Asset::where('asset_tag', $tag)->first()) {
            return $this->checkout($request, $asset->id);
        }

        return response()->json(Helper::formatStandardApiResponse('error', null, 'Asset not found'), 200);
    }

    /**
     * Checkout an asset
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v4.0]
     */
    public function checkout(AssetCheckoutRequest $request, $asset_id): JsonResponse
    {
        $this->authorize('checkout', Asset::class);
        $asset = Asset::findOrFail($asset_id);

        if (! $asset->availableForCheckout()) {
            return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.not_available')));
        }

        $this->authorize('checkout', $asset);

        $error_payload = [];
        $error_payload['asset'] = [
            'id' => $asset->id,
            'asset_tag' => $asset->asset_tag,
        ];

        // This item is checked out to a location
        if (request('checkout_to_type') == 'location') {
            // Resolve unscoped target first so FMCS mismatch can be handled explicitly.
            $target = Location::withoutGlobalScopes()->find(request('assigned_location'));
            $asset->location_id = ($target) ? $target->id : '';
            $error_payload['target_id'] = $request->input('assigned_location');
            $error_payload['target_type'] = 'location';
        } elseif (request('checkout_to_type') == 'asset') {
            // Resolve unscoped target first so FMCS mismatch can be handled explicitly.
            $target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
            // Override with the asset's location_id if it has one
            $asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
            $error_payload['target_id'] = $request->input('assigned_asset');
            $error_payload['target_type'] = 'asset';
        } elseif (request('checkout_to_type') == 'user') {
            // Fetch the target and set the asset's new location_id
            // Resolve unscoped target first so FMCS mismatch can be handled explicitly.
            $target = User::withoutGlobalScopes()->find(request('assigned_user'));
            $asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
            $error_payload['target_id'] = $request->input('assigned_user');
            $error_payload['target_type'] = 'user';
        }

        // withoutGlobalScopes above bypasses the SoftDeletes scope so we
        // can distinguish "target not found" from "target in another company"
        // for FMCS error messaging. Trashed targets must not be treated as
        // valid checkout destinations though, so exclude them here.
        if (isset($target) && ! empty($target->deleted_at)) {
            $target = null;
        }

        if ($request->filled('status_id')) {
            $asset->status_id = $request->input('status_id');
        }

        // Preserve existing requestable state unless API caller explicitly includes the field.
        if ($request->has('requestable')) {
            $asset->requestable = $request->boolean('requestable');
        }

        if (! isset($target)) {
            return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
        }

        // In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
        if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
            return $mismatch;
        }

        $checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
        $expected_checkin = request('expected_checkin', null);
        $note = request('note', null);
        // Using `->has` preserves the asset name if the name parameter was not included in request.
        $asset_name = request()->has('name') ? request('name') : $asset->name;

        // Set the location ID to the RTD location id if there is one
        // Wait, why are we doing this? This overrides the stuff we set further up, which makes no sense.
        // TODO: Follow up here. WTF. Commented out for now.

        //        if ((isset($target->rtd_location_id)) && ($asset->rtd_location_id!='')) {
        //            $asset->location_id = $target->rtd_location_id;
        //        }

        // Concurrency guard. availableForCheckout() above ran on an
        // unlocked read, so two simultaneous checkout requests can both
        // observe the asset as available and both proceed through
        // checkOut(), producing duplicate checkout-history rows and
        // double-incrementing checkout_counter on a single-assignment
        // asset. Re-fetch the row under lockForUpdate INSIDE the
        // transaction and re-check availability against the locked
        // snapshot. Any concurrent checkout blocks on the row lock until
        // this transaction commits, then sees the asset as no longer
        // available. Mirrors the pattern in ConsumablesController::store
        // (GHSA-x4g2-87xc-m5jm).
        $wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
            $locked = Asset::whereKey($asset->id)->lockForUpdate()->first();
            if (! $locked || ! $locked->availableForCheckout()) {
                return false;
            }

            return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
        });

        if ($wasCheckedOut) {
            return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
        }

        return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.error')));
    }

    /**
     * Checkin an asset
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $assetId
     *
     * @since [v4.0]
     */
    public function checkin(Request $request, $asset_id): JsonResponse
    {
        $asset = Asset::with('model')->findOrFail($asset_id);
        $this->authorize('checkin', $asset);

        $target = $asset->assignedTo;
        if (is_null($target)) {
            return response()->json(Helper::formatStandardApiResponse('error', [
                'asset_tag' => e($asset->asset_tag),
                'model' => e($asset->model->name),
                'model_number' => e($asset->model->model_number),
            ], trans('admin/hardware/message.checkin.already_checked_in')));
        }

        $asset->expected_checkin = null;
        // $asset->last_checkout = null;
        $asset->last_checkin = now();
        $asset->assignedTo()->disassociate($asset);
        $asset->accepted = null;

        if ($request->input('clear_name') == '1') {
            $asset->name = null;
        } elseif ($request->has('name')) {
            $asset->name = $request->input('name');
        }

        $this->migrateLegacyLocations($asset);

        $asset->location_id = $asset->rtd_location_id;

        if ($request->filled('location_id')) {
            $asset->location_id = $request->input('location_id');

            if ($request->input('update_default_location')) {
                $asset->rtd_location_id = $request->input('location_id');
            }
        }

        if ($request->filled('status_id')) {
            $asset->status_id = $request->input('status_id');
        }

        $checkin_at = $request->filled('checkin_at') ? $request->input('checkin_at').' '.date('H:i:s') : date('Y-m-d H:i:s');
        $originalValues = $asset->getRawOriginal();

        if (($request->filled('checkin_at')) && ($request->input('checkin_at') != date('Y-m-d'))) {
            $originalValues['action_date'] = $checkin_at;
        }

        $asset->licenseseats->each(function (LicenseSeat $seat) {
            $seat->update(['assigned_to' => null]);
        });

        // Get all pending Acceptances for this asset and delete them
        CheckoutAcceptance::pending()
            ->whereHasMorph(
                'checkoutable',
                [Asset::class],
                function (Builder $query) use ($asset) {
                    $query->where('id', $asset->id);
                }
            )
            ->get()
            ->map(function ($acceptance) {
                $acceptance->delete();
            });

        if ($asset->save()) {

            // Update the location of any child assets
            Asset::where('assigned_type', Asset::class)
                ->where('assigned_to', $asset->id)
                ->update(['location_id' => $asset->location_id]);

            event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));

            return response()->json(Helper::formatStandardApiResponse('success', [
                'asset_tag' => e($asset->asset_tag),
                'model' => e($asset->model->name),
                'model_number' => e($asset->model->model_number),
            ], trans('admin/hardware/message.checkin.success')));
        }

        return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkin.error')));
    }

    /**
     * Checkin an asset by asset tag
     *
     * @author [A. Janes] [<ajanes@adagiohealth.org>]
     *
     * @since [v6.0]
     */
    public function checkinByTag(Request $request, $tag = null): JsonResponse
    {
        $this->authorize('checkin', Asset::class);

        $asset = $this->resolveCheckinAssetFromBody($request, $tag);

        if ($asset) {
            return $this->checkin($request, $asset->id);
        }

        $displayKey = $tag
            ?? $request->input('checkin_key')
            ?? $request->input('asset_tag');

        return response()->json(Helper::formatStandardApiResponse('error', [
            'asset' => e($displayKey),
        ], 'Asset with tag '.e($displayKey).' not found'));
    }

    /**
     * Body-based asset lookup for the quickscan-checkin path. Mirrors the
     * audit-side resolveAuditAssetFromBody: prefer checkin_key +
     * checkin_by_field (serial gated on unique_serial), fall back to the
     * legacy `asset_tag` field, otherwise return null. The URL-bound tag
     * path still wins when the caller uses the /bytag/{tag}/checkin route.
     */
    private function resolveCheckinAssetFromBody(Request $request, ?string $tag): ?Asset
    {
        if ($tag !== null) {
            return Asset::where('asset_tag', $tag)->first();
        }

        $settings = Setting::getSettings();
        $checkin_by_field = $request->input('checkin_by_field', 'asset_tag');
        $checkin_key = $request->input('checkin_key', null);

        if ($settings->unique_serial == '1' && $checkin_by_field === 'serial' && $checkin_key) {
            return Asset::where('serial', '=', trim($checkin_key))->first();
        }

        if ($checkin_by_field === 'asset_tag' && $checkin_key) {
            return Asset::where('asset_tag', '=', trim($checkin_key))->first();
        }

        // Legacy body-based lookup: kept so callers that still send
        // `asset_tag` (e.g. the pre-serial quickscan-checkin blade, third-
        // party integrations built against the older API shape) continue
        // to work without change.
        if ($request->filled('asset_tag')) {
            return Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
        }

        return null;
    }

    /**
     * Mark an asset as audited
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @param  int  $id
     *
     * @since [v4.0]
     */
    public function audit(UploadFileRequest $request, ?Asset $asset = null): JsonResponse
    {
        $this->authorize('audit', Asset::class);

        // On the legacy route the URL has no {asset} — resolve from the body
        // via audit_key + audit_by_field (or the fallback asset_tag field).
        // On the newer route the Asset is bound by RMB, so body-based lookup
        // is skipped entirely.
        $resolvedAsset = $asset ?: $this->resolveAuditAssetFromBody($request);

        if (! $resolvedAsset) {
            return response()->json(Helper::formatStandardApiResponse(
                'error',
                $this->auditFailPayload($request),
                trans('admin/hardware/message.does_not_exist')
            ), 200);
        }

        // Per-instance authorize so the policy layer independently
        // enforces FMCS scoping on the resolved asset, regardless of
        // whether it came from route-model binding or body lookup.
        // Without this, FMCS enforcement depends solely on
        // CompanyableScope firing on the underlying Asset::where /
        // route-binding lookup.
        $this->authorize('audit', $resolvedAsset);

        $result = $this->applyAssetAudit($resolvedAsset, $request);

        return response()->json($this->formatSingleAuditResponse($result));
    }

    /**
     * Bulk audit: `ids` in the request body names which assets to audit,
     * everything else on the request body is the shared audit payload
     * (note, next_audit_date, location, uploaded image, etc.) applied to
     * each. Per-row envelope response.
     */
    public function bulkAudit(UploadFileRequest $request): JsonResponse
    {
        $this->authorize('audit', Asset::class);

        $ids = array_values(array_unique(array_map('intval', (array) $request->input('ids', []))));

        if ($ids === []) {
            return response()->json([
                'status' => 'error',
                'messages' => ['ids' => [trans('validation.required', ['attribute' => 'ids'])]],
                'results' => [],
            ]);
        }

        $assets = Asset::whereIn('id', $ids)->get()->keyBy('id');

        $results = [];
        $success_count = 0;
        $error_count = 0;

        foreach ($ids as $id) {
            if (! $assets->has($id)) {
                $results[] = [
                    'id' => $id,
                    'status' => 'error',
                    'messages' => trans('admin/hardware/message.does_not_exist'),
                    'payload' => null,
                ];
                $error_count++;

                continue;
            }

            $asset = $assets->get($id);

            // Per-row FMCS/authorization gate. The class-level authorize()
            // above is only a coarse "you have assets.audit" check; this
            // catches FMCS mismatches and any policy tightening that lands
            // later, surfacing them as row errors rather than a whole 403.
            if (! Gate::allows('audit', $asset)) {
                $results[] = [
                    'id' => $id,
                    'status' => 'error',
                    'messages' => trans('general.unauthorized'),
                    'payload' => null,
                ];
                $error_count++;

                continue;
            }

            $result = $this->applyAssetAudit($asset, $request);
            $row = $this->buildAuditRowResult($id, $result);
            $results[] = $row;

            $row['status'] === 'success' ? $success_count++ : $error_count++;
        }

        return response()->json($this->assembleBulkResponse($results, $success_count, $error_count));
    }

    /**
     * Apply an audit to a single Asset.
     *
     * Shared between audit() and bulkAudit(), so validation, the observer
     * bypass, and the logAudit side-effect live in one place. Returns the
     * neutral result shape that formatSingleAuditResponse() /
     * buildAuditRowResult() turn into the legacy or bulk-envelope response
     * body.
     *
     * @return array{
     *   success: bool,
     *   asset: Asset|null,
     *   payload: array,
     *   message: string|null,
     *   errors: mixed,
     * }
     */
    private function applyAssetAudit(Asset $asset, UploadFileRequest $request): array
    {
        $settings = Setting::getSettings();

        $dt = null;
        if (! is_null($settings->audit_interval)) {
            $dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
        }

        $audit_by_field = $request->input('audit_by_field', 'asset_tag');
        $audit_key = $request->input('audit_key', null);

        $originalValues = $asset->getRawOriginal();

        $asset->next_audit_date = $dt;

        if ($request->filled('next_audit_date')) {
            $asset->next_audit_date = $request->input('next_audit_date');
        }

        // "update_location" flag actually writes the location instead of
        // only recording it in the audit notes.
        if ($request->input('update_location') == '1') {
            $asset->location_id = $request->input('location_id');
        }

        $asset->last_audit_date = date('Y-m-d H:i:s');

        if ($request->input('clear_name') == '1') {
            $asset->name = null;
        }

        $payload = [
            'id' => $asset->id,
            'asset_tag' => e($asset->asset_tag),
            'audit_by_field' => e(Str::headline($audit_by_field)),
            'audit_key' => e($audit_key),
            'note' => $request->filled('note') ? e($request->input('note')) : null,
            'status_label' => e($asset->status?->display_name),
            'status_type' => $asset->status?->getStatuslabelType(),
            'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
        ];

        if ($asset->model && $asset->model->fieldset) {
            $payload['custom_fields'] = [];
            foreach ($asset->model->fieldset->fields as $field) {
                if ($field->display_audit != '1' || ! $request->has($field->db_column)) {
                    continue;
                }

                $value = $request->input($field->db_column);
                $stored = is_array($value) ? implode(', ', $value) : $value;

                if ($field->field_encrypted == '1') {
                    // Only writers with the encrypted-view permission can
                    // set encrypted fields; other callers get the payload
                    // echo but no persisted change.
                    if (Gate::allows('assets.view.encrypted_custom_fields')) {
                        $asset->{$field->db_column} = Crypt::encrypt($stored);
                    }
                } else {
                    $asset->{$field->db_column} = $stored;
                }

                $payload['custom_fields'][$field->db_column] = $value;
            }
        }

        // Merge the model-level base rules with the custom-field rules so
        // uploaded audit data is checked before we bypass the observer below.
        $asset->setRules($asset->getRules() + $asset->customFieldValidationRules());

        if ($asset->isInvalid()) {
            return [
                'success' => false,
                'asset' => $asset,
                'payload' => $payload,
                'message' => null,
                'errors' => $asset->getErrors(),
            ];
        }

        // We save() further down but don't want an "update" log entry (the
        // observer would emit one because de-normed fields like
        // next_audit_date change here) in addition to the "audit" log entry
        // we create below. unsetEventDispatcher() bypasses the observer;
        // isValid() above already ran the Watson validation manually.
        //
        // @see \App\Observers\AssetObserver::updating()
        // @see \App\Models\Asset::save()
        $asset->unsetEventDispatcher();

        if ($asset->isValid() && $asset->save()) {
            // Persist an audit image if the caller uploaded one, matching
            // the web audit form's behavior. Filename is stored on the
            // action log so it renders in the audit history.
            $file_name = null;
            if ($request->hasFile('image')) {
                $file_name = $request->handleFile('private_uploads/audits/', 'audit-'.$asset->id, $request->file('image'));
                $payload['image'] = $file_name;
            }

            $asset->logAudit($request->input('note'), $request->input('location_id'), $file_name, $originalValues);

            return [
                'success' => true,
                'asset' => $asset,
                'payload' => $payload,
                'message' => 'admin/hardware/message.audit.success',
                'errors' => null,
            ];
        }

        return [
            'success' => false,
            'asset' => $asset,
            'payload' => $payload,
            'message' => null,
            'errors' => $asset->getErrors(),
        ];
    }

    /**
     * Body-based asset lookup for the legacy audit path. Mirrors the pre-
     * refactor behavior: prefer audit_key + audit_by_field (serial or
     * asset_tag), fall back to the legacy `asset_tag` field, otherwise
     * return null so the caller can try the URL-bound id.
     */
    private function resolveAuditAssetFromBody(Request $request): ?Asset
    {
        $settings = Setting::getSettings();
        $audit_by_field = $request->input('audit_by_field', 'asset_tag');
        $audit_key = $request->input('audit_key', null);

        if ($settings->unique_serial == '1' && $audit_by_field === 'serial' && $audit_key) {
            return Asset::where('serial', '=', trim($audit_key))->first();
        }

        if ($audit_by_field === 'asset_tag' && $audit_key) {
            return Asset::where('asset_tag', '=', trim($audit_key))->first();
        }

        if ($request->filled('asset_tag')) {
            return Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
        }

        return null;
    }

    /**
     * The "no matching asset" fail payload the legacy audit response emits.
     */
    private function auditFailPayload(Request $request): array
    {
        return [
            'audit_by_field' => e(Str::headline($request->input('audit_by_field', 'asset_tag'))),
            'audit_key' => e($request->input('audit_key', null)),
        ];
    }

    /**
     * Format an applyAssetAudit() result into the legacy single-asset audit
     * response body. `payload` here is the audit-specific structure
     * (asset_tag, audit_by_field, custom_fields, ...) built inside
     * applyAssetAudit(), NOT the full Asset model.
     */
    private function formatSingleAuditResponse(array $result): array
    {
        if (! $result['success']) {
            return Helper::formatStandardApiResponse('error', $result['payload'], $result['errors']);
        }

        return Helper::formatStandardApiResponse('success', $result['payload'], trans($result['message']));
    }

    /**
     * Turn an applyAssetAudit() result into a per-row bulk-envelope entry.
     */
    private function buildAuditRowResult(int $id, array $result): array
    {
        $formatted = $this->formatSingleAuditResponse($result);

        return [
            'id' => $id,
            'status' => $formatted['status'],
            'messages' => $formatted['messages'],
            'payload' => $formatted['payload'],
        ];
    }

    /**
     * Returns JSON listing of all requestable assets
     *
     * @author [A. Gianotto] [<snipe@snipe.net>]
     *
     * @since [v4.0]
     */
    public function requestable(Request $request): JsonResponse|array
    {
        $this->authorize('viewRequestable', Asset::class);

        $allowed_columns = [
            'name',
            'asset_tag',
            'serial',
            'model_number',
            'image',
            'purchase_cost',
            'expected_checkin',
        ];

        $all_custom_fields = CustomField::all(); // used as a 'cache' of custom fields throughout this page load

        foreach ($all_custom_fields as $field) {
            $allowed_columns[] = $field->db_column_name();
        }

        $assets = Asset::select('assets.*')
            ->with(
                'location',
                'status',
                'assetlog',
                'company',
                'assignedTo',
                'model.category',
                'model.manufacturer',
                'model.fieldset',
                'supplier',
                'requests'
            );

        if ($request->filled('search')) {
            $assets->TextSearch($request->input('search'));
        }

        // Search custom fields by column name
        foreach ($all_custom_fields as $field) {
            if ($request->filled($field->db_column_name())) {
                $assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
            }
        }

        $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
        $sort_override = str_replace('custom_fields.', '', $request->input('sort'));

        // This handles all the pivot sorting (versus the assets.* fields
        // in the allowed_columns array)
        $column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets.created_at';

        switch ($request->input('sort')) {
            case 'model':
                $assets->OrderModels($order);
                break;
            case 'model_number':
                $assets->OrderModelNumber($order);
                break;
            case 'location':
                $assets->OrderLocation($order);
                break;
            default:
                $assets->orderBy($column_sort, $order);
                break;
        }

        $assets->requestableAssets();

        // Make sure the offset and limit are actually integers and do not exceed system limits
        $total = $assets->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');

        $assets = $assets->skip($offset)->take($limit)->get();

        return (new AssetsTransformer)->transformRequestedAssets($assets, $total);
    }

    public function assignedAssets(Request $request, Asset $asset): JsonResponse|array
    {
        $this->authorize('view', Asset::class);
        $this->authorize('view', $asset);

        $query = Asset::where([
            'assigned_to' => $asset->id,
            'assigned_type' => Asset::class,
        ]);

        $total = $query->count();

        $assets = $query->applyOffsetAndLimit($total)->get();

        return (new AssetsTransformer)->transformAssets($assets, $total);
    }

    public function assignedAccessories(Request $request, Asset $asset): JsonResponse|array
    {
        $this->authorize('view', $asset);
        $accessory_checkouts = AccessoryCheckout::AssetsAssigned()
            ->where('assigned_to', $asset->id)
            ->with('adminuser')
            ->with('accessories');

        $total = $accessory_checkouts->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');

        $accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();

        return (new AssetsTransformer)->transformCheckedoutAccessories($accessory_checkouts, $total);
    }

    public function assignedComponents(Request $request, Asset $asset): JsonResponse|array
    {
        $this->authorize('view', $asset);
        $asset->loadCount('components');

        $allowed_columns = [
            'created_at',
            'assigned_qty',
            'note',
        ];

        $component_checkouts = ComponentAssignment::where('asset_id', $asset->id)->with('adminuser')->with('component');

        $sort_override = $request->input('sort');
        $column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
        $order = $request->input('order') === 'asc' ? 'asc' : 'desc';

        switch ($sort_override) {
            case 'created_by':
                $component_checkouts = $component_checkouts->OrderByCreatedByName($order);
                break;
            case 'name':
                $component_checkouts = $component_checkouts->OrderByComponentName($order);
                break;
            default:
                $component_checkouts = $component_checkouts->orderBy($column_sort, $order);
                break;
        }

        $total = $component_checkouts->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');
        $component_checkouts = $component_checkouts->skip($offset)->take($limit)->get();

        return (new AssetsTransformer)->transformCheckedoutComponents($component_checkouts, $total);
    }

    /**
     * Generate asset labels by tag
     *
     * @author [Nebelkreis] [https://github.com/NebelKreis]
     *
     * @param  Request  $request  Contains asset_tags array of asset tags to generate labels for
     * @return JsonResponse Returns base64 encoded PDF on success, error message on failure
     */
    public function getLabels(Request $request): JsonResponse
    {
        try {
            $this->authorize('view', Asset::class);

            // Validate that asset tags were provided in the request
            if (! $request->filled('asset_tags')) {
                return response()->json(Helper::formatStandardApiResponse('error', null,
                    trans('admin/hardware/message.no_assets_selected')), 400);
            }

            // Convert asset tags from request into collection and fetch matching assets
            $asset_tags = collect($request->input('asset_tags'));
            $assets = Asset::whereIn('asset_tag', $asset_tags)->get();

            // Return error if no assets were found for the provided tags
            if ($assets->isEmpty()) {
                return response()->json(Helper::formatStandardApiResponse('error', null,
                    trans('admin/hardware/message.does_not_exist')), 404);
            }

            try {
                $settings = Setting::getSettings();

                // Check if logo file exists in storage and disable logo if not found
                // This prevents errors when trying to include a non-existent logo in the PDF
                $settings->label_logo = ($original_logo = $settings->label_logo) && ! Storage::disk('public')->exists('/'.$original_logo) ? null : $settings->label_logo;

                $label = new Label;

                if (! $label) {
                    throw new \Exception(trans('admin/labels/message.label_not_created'));
                }

                // Configure label with assets and settings
                // bulkedit=false and count=0 are default values for label generation
                $label = $label->with('assets', $assets)
                    ->with('settings', $settings)
                    ->with('bulkedit', false)
                    ->with('count', 0);

                // Generate PDF using callback function
                // The callback captures the PDF content in $pdf_content variable
                $pdf_content = '';
                $label->render(function ($pdf) use (&$pdf_content) {
                    $pdf_content = $pdf->Output('', 'S');

                    return $pdf;
                });

                // Verify PDF was generated successfully
                if (empty($pdf_content)) {
                    throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
                }

                $encoded_content = base64_encode($pdf_content);

                return response()->json(Helper::formatStandardApiResponse('success', [
                    'pdf' => $encoded_content,
                ], trans('admin/hardware/message.labels_generated')));

            } catch (\Exception $e) {
                return response()->json(Helper::formatStandardApiResponse('error', [
                    'error_message' => $e->getMessage(),
                    'error_line' => $e->getLine(),
                    'error_file' => $e->getFile(),
                ], trans('admin/hardware/message.error_generating_labels')), 500);
            }
        } catch (\Exception $e) {
            return response()->json(Helper::formatStandardApiResponse('error', [
                'error_message' => $e->getMessage(),
                'error_line' => $e->getLine(),
                'error_file' => $e->getFile(),
            ], $e->getMessage()), 500);
        }
    }

    public function history(Request $request, Asset $asset): JsonResponse|array
    {
        $this->authorize('history', $asset);
        $historyQuery = $asset->getHistory($request);
        $total = (clone $historyQuery)->count();
        $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
        $limit = app('api_limit_value');
        $history = (clone $historyQuery)->skip($offset)->take($limit)->get();

        return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
    }
}
