<?php

namespace Tests\Unit\Helpers;

use App\Helpers\Helper;
use App\Models\Location;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Session;
use Tests\TestCase;

class HelperTest extends TestCase
{
    /**
     * Regression: `<x-form.row type="datetimepicker">` on transient checkout
     * forms (hardware/checkout, bulk-checkout, kits/checkout, etc.) passes
     * `$item = null` down to `Helper::checkIfRequired`. Without the null guard
     * this hit `null::rules()` and threw "Class name must be a valid object or
     * a string", 500ing the whole page. When there's no bound model to
     * introspect, we treat the field as not required.
     */
    public function test_check_if_required_returns_false_when_class_is_null()
    {
        $this->assertFalse(Helper::checkIfRequired(null, 'name'));
    }

    public function test_check_if_required_detects_required_field_on_a_real_model()
    {
        // Location::$rules declares 'name' => 'required|max:255|unique_undeleted'
        $this->assertTrue(Helper::checkIfRequired(new Location, 'name'));
    }

    public function test_check_if_required_returns_false_for_a_non_required_field()
    {
        // Location's 'address' is 'max:191|nullable' — no required rule.
        $this->assertFalse(Helper::checkIfRequired(new Location, 'address'));
    }

    public function test_check_if_required_returns_false_for_unknown_field()
    {
        $this->assertFalse(Helper::checkIfRequired(new Location, 'no_such_field_on_location'));
    }

    public function test_default_chart_colors_method_handles_high_values()
    {
        $this->assertIsString(Helper::defaultChartColors(1000));
    }

    public function test_default_chart_colors_method_handles_negative_numbers()
    {
        $this->assertIsString(Helper::defaultChartColors(-1));
    }

    public function test_parse_currency_method()
    {
        $this->settings->set(['default_currency' => 'USD', 'digit_separator' => '1,234.56']);
        $this->assertSame(12.34, Helper::ParseCurrency('USD 12.34'));
        $this->assertSame(8888.0, Helper::ParseCurrency('8,888.00'));   // US thousands comma
        $this->assertSame(8888.0, Helper::ParseCurrency('8888.00'));    // US plain

        $this->settings->set(['digit_separator' => '1.234,56']);
        $this->assertSame(12.34, Helper::ParseCurrency('12,34'));
        $this->assertSame(8888.0, Helper::ParseCurrency('8.888,00'));   // EU thousands dot
        $this->assertSame(8888.0, Helper::ParseCurrency('8888,00'));    // EU plain
    }

    public function test_get_redirect_option_method()
    {
        $test_data = [
            'Option target: redirect for user assigned to ' => [
                'request' => (object) ['assigned_user' => 22],
                'id' => 1,
                'checkout_to_type' => 'user',
                'redirect_option' => 'target',
                'table' => 'Assets',
                'route' => route('users.show', 22),
            ],
            'Option target: redirect location assigned to ' => [
                'request' => (object) ['assigned_location' => 10],
                'id' => 2,
                'checkout_to_type' => 'location',
                'redirect_option' => 'target',
                'table' => 'Locations',
                'route' => route('locations.show', 10),
            ],
            'Option target: redirect back to asset assigned to ' => [
                'request' => (object) ['assigned_asset' => 101],
                'id' => 3,
                'checkout_to_type' => 'asset',
                'redirect_option' => 'target',
                'table' => 'Assets',
                'route' => route('hardware.show', 101),
            ],
            'Option item: redirect back to asset ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Assets',
                'route' => route('hardware.show', 999),
            ],
            'Option index: redirect back to asset index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Assets',
                'route' => route('hardware.index'),
            ],

            'Option item: redirect back to user ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Users',
                'route' => route('users.show', 999),
            ],

            'Option index: redirect back to user index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Users',
                'route' => route('users.index'),
            ],

            'Option item: redirect back to license ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Licenses',
                'route' => route('licenses.show', 999),
            ],

            'Option index: redirect back to license index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Licenses',
                'route' => route('licenses.index'),
            ],

            'Option item: redirect back to accessory list ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Accessories',
                'route' => route('accessories.show', 999),
            ],

            'Option index: redirect back to accessory index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Accessories',
                'route' => route('accessories.index'),
            ],
            'Option item: redirect back to consumable ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Consumables',
                'route' => route('consumables.show', 999),
            ],

            'Option index: redirect back to consumables index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Consumables',
                'route' => route('consumables.index'),
            ],

            'Option item: redirect back to component ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => 999,
                'checkout_to_type' => null,
                'redirect_option' => 'item',
                'table' => 'Components',
                'route' => route('components.show', 999),
            ],

            'Option index: redirect back to component index ' => [
                'request' => (object) ['assigned_asset' => null],
                'id' => null,
                'checkout_to_type' => null,
                'redirect_option' => 'index',
                'table' => 'Components',
                'route' => route('components.index'),
            ],
        ];

        foreach ($test_data as $scenario => $data) {

            Session::put('redirect_option', $data['redirect_option']);
            Session::put('checkout_to_type', $data['checkout_to_type']);

            $redirect = Helper::getRedirectOption($data['request'], $data['id'], $data['table']);

            $this->assertInstanceOf(RedirectResponse::class, $redirect);
            $this->assertEquals($data['route'], $redirect->getTargetUrl(), $scenario.'failed.');
        }
    }

    public function test_get_redirect_option_preserves_query_filters_when_returning_to_index()
    {
        // #15214: a user editing an asset from `hardware?status_type=Deployed`
        // should land back on that filtered view, not the plain
        // hardware.index that drops the side-nav filter context.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', route('hardware.index').'?status_type=Deployed');

        $redirect = Helper::getRedirectOption((object) [], null, 'Assets');

        $this->assertInstanceOf(RedirectResponse::class, $redirect);
        $this->assertEquals(
            route('hardware.index').'?status_type=Deployed',
            $redirect->getTargetUrl(),
        );
    }

    public function test_get_redirect_option_preserves_multiple_query_filters()
    {
        // Query string with more than one filter round-trips cleanly.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', route('hardware.index').'?status_type=RTD&category_id=3');

        $redirect = Helper::getRedirectOption((object) [], null, 'Assets');

        $this->assertEquals(
            route('hardware.index').'?status_type=RTD&category_id=3',
            $redirect->getTargetUrl(),
        );
    }

    public function test_get_redirect_option_falls_back_to_plain_index_when_no_referrer_query()
    {
        // When there's no filter to preserve, behavior matches the
        // pre-#15214 code path.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', route('hardware.index'));

        $redirect = Helper::getRedirectOption((object) [], null, 'Assets');

        $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl());
    }

    public function test_get_redirect_option_ignores_referrer_pointing_at_a_different_path()
    {
        // If the referrer was the show page or the create form (not the
        // index they'd be redirected to), don't smuggle it into the
        // index redirect. Only same-path referrers are preserved.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', route('hardware.show', 42));

        $redirect = Helper::getRedirectOption((object) [], null, 'Assets');

        $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl());
    }

    public function test_get_redirect_option_ignores_offsite_referrer_when_returning_to_index()
    {
        // Off-site protection was already in place for the 'back'
        // option (via the parse_url host check). Verify it also applies
        // on the 'index' path so an attacker-controlled url.intended
        // (see SamlController RelayState) can't smuggle an external URL
        // into an index redirect.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', 'https://evil.example.com/hardware?status_type=Deployed');

        $redirect = Helper::getRedirectOption((object) [], null, 'Assets');

        $this->assertEquals(route('hardware.index'), $redirect->getTargetUrl());
    }

    public function test_get_redirect_option_preserves_filters_for_other_entity_types_too()
    {
        // The #15214 fix lives in Helper::getRedirectOption so it covers
        // every entity that routes through the redirect helper, not just
        // Assets. Users/Licenses/etc. don't have side-nav filters today
        // but might grow them, and generic ?category_id=... links are
        // already common.
        Session::put('redirect_option', 'index');
        Session::put('url.intended', route('users.index').'?company_id=5');

        $redirect = Helper::getRedirectOption((object) [], null, 'Users');

        $this->assertEquals(route('users.index').'?company_id=5', $redirect->getTargetUrl());
    }

    public function test_same_origin_url_returns_null_for_empty_input()
    {
        $this->assertNull(Helper::sameOriginUrl(null));
        $this->assertNull(Helper::sameOriginUrl(''));
    }

    public function test_same_origin_url_returns_relative_url_unchanged()
    {
        $this->assertSame('/maintenances?completed=true', Helper::sameOriginUrl('/maintenances?completed=true'));
        $this->assertSame('home', Helper::sameOriginUrl('home'));
    }

    public function test_same_origin_url_accepts_url_pointing_at_app_host()
    {
        $url = config('app.url').'/hardware/42';
        $this->assertSame($url, Helper::sameOriginUrl($url));
    }

    public function test_same_origin_url_rejects_offsite_host()
    {
        $this->assertNull(Helper::sameOriginUrl('https://evil.example.com/steal-session'));
    }

    public function test_same_origin_url_rejects_dangerous_schemes()
    {
        $this->assertNull(Helper::sameOriginUrl('javascript:alert(1)'));
        $this->assertNull(Helper::sameOriginUrl('data:text/html,<script>alert(1)</script>'));
        $this->assertNull(Helper::sameOriginUrl('file:///etc/passwd'));
    }

    public function test_same_origin_url_strips_crlf_to_prevent_header_injection()
    {
        // A CR/LF in a Location: header would let an attacker split the
        // HTTP response and inject arbitrary headers/body. The helper
        // must strip them before returning.
        $this->assertSame('/foo', Helper::sameOriginUrl("/foo\r\n"));
        $this->assertSame('/foo/bar', Helper::sameOriginUrl("/foo\r\n/bar"));
    }

    public function test_same_origin_url_rejects_scheme_relative_offsite_url()
    {
        // //evil.com/... is a scheme-relative URL that inherits the
        // current scheme and points at evil.com. Must be rejected.
        $this->assertNull(Helper::sameOriginUrl('//evil.example.com/steal-session'));
    }

    /**
     * FD-56673 regression coverage: customFieldFormValue collapses the
     * gate + decrypt + default fallback that every branch of
     * custom_fields_form.blade.php used to hand-roll (and got wrong on
     * seven of them). These unit tests pin the four possible states of
     * the helper without needing to boot a full HTTP request.
     */
    public function test_custom_field_form_value_masks_encrypted_field_when_gate_denies(): void
    {
        \App\Models\CustomField::factory()->testEncrypted()->create();
        $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first();

        // Set the column value via magic-setter rather than mass assignment
        // because CustomField columns are added at runtime and are not in
        // Asset's $fillable, so `new Asset([...])` would silently drop them.
        $asset = new \App\Models\Asset;
        $asset->{$field->db_column} = \Illuminate\Support\Facades\Crypt::encrypt('very-secret-value');

        $model = \App\Models\AssetModel::factory()->make();

        $this->actingAs(\App\Models\User::factory()->editAssets()->create());

        $this->assertSame(
            strtoupper(trans('admin/custom_fields/general.encrypted')),
            Helper::customFieldFormValue($field, $asset, $model)
        );
    }

    public function test_custom_field_form_value_returns_decrypted_value_when_gate_allows(): void
    {
        \App\Models\CustomField::factory()->testEncrypted()->create();
        $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first();

        $asset = new \App\Models\Asset;
        $asset->{$field->db_column} = \Illuminate\Support\Facades\Crypt::encrypt('very-secret-value');

        $model = \App\Models\AssetModel::factory()->make();

        $this->actingAs(\App\Models\User::factory()->superuser()->create());

        $this->assertSame(
            'very-secret-value',
            Helper::customFieldFormValue($field, $asset, $model)
        );
    }

    public function test_custom_field_form_value_returns_raw_value_for_non_encrypted_field(): void
    {
        // Non-encrypted fields short-circuit before the gate check, so the caller
        // permission is irrelevant. Verified with an unauthenticated actor to make
        // the "gate does not gate this" behavior explicit.
        \App\Models\CustomField::factory()->ram()->create();
        $field = \App\Models\CustomField::where('name', 'RAM')->first();

        $asset = new \App\Models\Asset;
        $asset->{$field->db_column} = '16';

        $model = \App\Models\AssetModel::factory()->make();

        $this->assertSame('16', Helper::customFieldFormValue($field, $asset, $model));
    }

    public function test_custom_field_form_value_returns_default_when_item_is_null(): void
    {
        // Create-form path: no bound $item yet, so the helper returns the
        // model-scoped default value for the field.
        \App\Models\CustomField::factory()->ram()->create();
        $field = \App\Models\CustomField::where('name', 'RAM')->first();
        $model = \App\Models\AssetModel::factory()->create();

        $this->assertSame(
            $field->defaultValue($model->id),
            Helper::customFieldFormValue($field, null, $model)
        );
    }

    public function test_custom_field_form_value_masks_default_when_encrypted_field_has_no_item_and_gate_denies(): void
    {
        // Create-form path AND encrypted field AND caller can't view keys.
        // The gate check runs first regardless of whether $item is present,
        // so the mask should win over the default-value fallback.
        \App\Models\CustomField::factory()->testEncrypted()->create();
        $field = \App\Models\CustomField::where('name', 'Test Encrypted')->first();
        $model = \App\Models\AssetModel::factory()->create();

        $this->actingAs(\App\Models\User::factory()->editAssets()->create());

        $this->assertSame(
            strtoupper(trans('admin/custom_fields/general.encrypted')),
            Helper::customFieldFormValue($field, null, $model)
        );
    }
}
