<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\ImportExportService;
use PHPUnit\Framework\TestCase;

/**
 * isApiSuccess exists because the legacy CI3 advertisers7 API routinely
 * returns HTTP 200 with {"status":false,"message":"..."} for business-logic
 * failures. Without this check every call that hits that pattern was recorded
 * as a success in both the UI and Shorty.adcenter_log.
 */
final class ImportExportServiceSuccessTest extends TestCase
{
    public function testHttp2xxWithNoStatusKeyIsSuccess(): void
    {
        $res  = ['status' => 200];
        $body = ['id' => 42];
        $this->assertTrue(ImportExportService::isApiSuccess($res, $body));
    }

    public function testHttp2xxWithApplicationStatusFalseIsFailure(): void
    {
        // The regression this function exists to fix.
        $res  = ['status' => 200];
        $body = ['status' => false, 'message' => 'Invalid bid'];
        $this->assertFalse(ImportExportService::isApiSuccess($res, $body));
    }

    public function testHttp2xxWithApplicationStatusTrueIsSuccess(): void
    {
        $res  = ['status' => 200];
        $body = ['status' => true, 'id' => 42];
        $this->assertTrue(ImportExportService::isApiSuccess($res, $body));
    }

    public function testHttp4xxIsAlwaysFailure(): void
    {
        $res  = ['status' => 422];
        $body = ['status' => true]; // Even if the body lies
        $this->assertFalse(ImportExportService::isApiSuccess($res, $body));
    }

    public function testTransportFailure599IsFailure(): void
    {
        // ApiClient returns status=599 on Throwable so `< 400` success
        // checks reliably treat network errors as failures.
        $res  = ['status' => 599];
        $body = [];
        $this->assertFalse(ImportExportService::isApiSuccess($res, $body));
    }

    public function testHttp201CreatedIsSuccess(): void
    {
        $res  = ['status' => 201];
        $body = ['id' => 99];
        $this->assertTrue(ImportExportService::isApiSuccess($res, $body));
    }

    public function testHttp3xxIsSuccessBoundary(): void
    {
        // The condition is < 400, so redirects are treated as success.
        // This documents the boundary rather than asserts correctness —
        // the legacy API should never redirect, but if it does, callers
        // treat it as success today.
        $res  = ['status' => 302];
        $body = [];
        $this->assertTrue(ImportExportService::isApiSuccess($res, $body));
    }

    /**
     * describeErrors() exists because a bad creative-type upload previously
     * surfaced a raw "HTTP 422" (no top-level `message`, only `errors`) or,
     * on the legacy side, a literal "Array" from concatenating errors[]
     * directly into a string.
     */
    public function testDescribeErrorsFormatsFieldMessagePairs(): void
    {
        $errors = [
            ['field' => 'type', 'message' => 'Invalid creative type'],
        ];
        $this->assertSame('Field-type (Invalid creative type)', ImportExportService::describeErrors($errors));
    }

    public function testDescribeErrorsJoinsMultiplePairs(): void
    {
        $errors = [
            ['field' => 'type', 'message' => 'Invalid creative type'],
            ['field' => 'size', 'message' => 'Required'],
        ];
        $this->assertSame(
            'Field-type (Invalid creative type); Field-size (Required)',
            ImportExportService::describeErrors($errors)
        );
    }

    public function testDescribeErrorsReturnsPlainStringAsIs(): void
    {
        $this->assertSame(
            'Rank field should be blank if parent_creative_id is not present !!',
            ImportExportService::describeErrors('Rank field should be blank if parent_creative_id is not present !!')
        );
    }

    public function testDescribeErrorsReturnsNullWhenEmpty(): void
    {
        $this->assertNull(ImportExportService::describeErrors(null));
        $this->assertNull(ImportExportService::describeErrors([]));
        $this->assertNull(ImportExportService::describeErrors(''));
    }

    public function testDescribeFailureExplainsHowToCreateOnA404Update(): void
    {
        // The API's own "Campaign not found" is accurate but useless to someone
        // who meant to create a campaign and filled in the id column because the
        // template ships one. It must not claim the id is bogus, though: the API
        // also 404s records the advertiser owns but which are archived/hidden.
        $msg = ImportExportService::describeFailure(
            ['status' => 404],
            ['message' => 'Campaign not found'],
            3,
            'Campaign',
            true
        );

        $this->assertSame(
            'no campaign with this id is available for your account (CSV row 3). It may not exist, or it may be '
            . 'archived. To create a new campaign instead, clear the id column for this row.',
            $msg
        );
    }

    public function testDescribeFailureDoesNotGiveTheClearTheIdAdviceOnACreate(): void
    {
        // A create row has no id to clear, so the advice would be nonsense; fall
        // back to whatever the API said.
        $msg = ImportExportService::describeFailure(
            ['status' => 404],
            ['message' => 'Campaign not found'],
            3,
            'Campaign',
            false
        );

        $this->assertSame('Campaign not found (CSV row 3)', $msg);
        $this->assertStringNotContainsString('clear the id', $msg);
    }

    public function testDescribeFailureWarnsThatA5xxMayHaveSavedPartially(): void
    {
        // The legacy create/update paths commit the record before their follow-up
        // writes with no transaction, so a 5xx can leave a partial record.
        $msg = ImportExportService::describeFailure(['status' => 500], [], 7, 'Campaign', false);

        $this->assertSame(
            'the API returned a server error (HTTP 500) (CSV row 7). The campaign may have been saved '
            . 'incompletely — check it before retrying, so you don\'t create a duplicate.',
            $msg
        );
    }

    public function testDescribeFailureDoesNotClaimCreativesHaveTargeting(): void
    {
        // Creatives have no targeting concept at all in the API, so the 5xx copy
        // must not tell the user to go and check it.
        $msg = ImportExportService::describeFailure(['status' => 502], [], 4, 'creative', false);

        $this->assertStringNotContainsString('targeting', $msg);
        $this->assertStringContainsString('creative may have been saved incompletely', $msg);
    }

    public function testDescribeFailureDistinguishesATransportFailureFromAnApiError(): void
    {
        // ApiClient synthesises 599 for any transport Throwable, so it must not
        // claim "the API returned" anything. It must not promise nothing was
        // written either: a read timeout is a 599, and the API commits the record
        // before its follow-up writes, so a timeout can land after the insert.
        $msg = ImportExportService::describeFailure(
            ['status' => ImportExportService::TRANSPORT_FAILURE_STATUS],
            [],
            9,
            'Campaign',
            false
        );

        $this->assertStringContainsString('the API did not answer', $msg);
        $this->assertStringNotContainsString('the API returned', $msg);
        $this->assertStringNotContainsString('Nothing was saved', $msg);
        $this->assertStringContainsString("don't create a duplicate", $msg);
    }

    public function testDescribeFailureCountsFieldErrorsInsteadOfRepeatingThem(): void
    {
        // The results view renders `errors` as a sub-list under the message.
        // Flattening them into the message as well printed each one twice — once
        // with the API's internal labels and once with the CSV column name.
        $body = ['status' => false, 'errors' => [
            ['field' => 'name',   'message' => 'The Campaign Name field is required.'],
            ['field' => 'budget', 'message' => 'The Daily Budget field is required.'],
            ['field' => 'bid',    'message' => 'The Rate field is required.'],
        ]];

        $msg = ImportExportService::describeFailure(['status' => 422], $body, 3, 'Campaign', false);

        $this->assertSame('3 problems to fix (CSV row 3):', $msg);
        $this->assertStringNotContainsString('Field-name', $msg);
        $this->assertStringNotContainsString('Daily Budget', $msg);
    }

    public function testDescribeFailureCarriesAPlainStringErrorAsTheSentence(): void
    {
        // A string `errors` — "Please do not add child creative.", or the
        // rank-without-parent 400 — has no column to name, so it belongs in the
        // sentence and the view no longer renders a bullet for it. The earlier fix
        // achieved the same single-print the other way round, with a content-free
        // "1 problem to fix" heading over the bullet; this keeps the reason in the
        // line the user reads first and stops the view depending on the service's
        // count to avoid repeating it.
        $msg = ImportExportService::describeFailure(
            ['status' => 422],
            ['status' => false, 'errors' => 'Please do not add child creative.'],
            4,
            'Campaign',
            false
        );

        $this->assertSame('Please do not add child creative. (CSV row 4)', $msg);
        $this->assertStringNotContainsString('problem to fix', $msg);
        // And the count agrees with the list: no bullets, so no heading. That
        // equivalence is now the invariant, for every shape.
        $this->assertSame(0, ImportExportService::countProblems('Please do not add child creative.'));
        $this->assertSame([], ImportExportService::errorBullets('Please do not add child creative.'));
    }

    public function testCountProblemsIgnoresBlankAndUnusableEntries(): void
    {
        // A blank entry renders as an empty bullet, so counting it would promise a
        // problem the sub-list cannot explain.
        $this->assertSame(0, ImportExportService::countProblems(null));
        $this->assertSame(0, ImportExportService::countProblems([]));
        $this->assertSame(0, ImportExportService::countProblems(''));
        $this->assertSame(0, ImportExportService::countProblems('   '));
        $this->assertSame(0, ImportExportService::countProblems(['']));
        // A scalar `errors` renders no bullet — it has no column to name and the
        // sentence carries it — so it counts as no problems. countProblems() is now
        // errorBullets()' count for every shape, which is the whole invariant.
        $this->assertSame(0, ImportExportService::countProblems('Something went wrong'));
        $this->assertSame(2, ImportExportService::countProblems([
            ['field' => 'name', 'message' => 'Required'],
            ['field' => 'bid',  'message' => 'Out of range'],
        ]));
    }

    public function testDescribeFailureFallsThroughWhenErrorsIsBlank(): void
    {
        // errors present but empty must not render "0 problems to fix".
        $msg = ImportExportService::describeFailure(
            ['status' => 422],
            ['status' => false, 'errors' => [], 'message' => 'Budget is too low'],
            2,
            'Campaign',
            false
        );

        $this->assertSame('Budget is too low (CSV row 2)', $msg);
    }

    public function testDescribeFailureUsesTheSingularForOneFieldError(): void
    {
        $body = ['status' => false, 'errors' => [
            ['field' => 'budget', 'message' => 'The Daily Budget field must contain a number greater than or equal to 2.'],
        ]];

        $this->assertSame(
            '1 problem to fix (CSV row 7):',
            ImportExportService::describeFailure(['status' => 422], $body, 7, 'Campaign', false)
        );
    }

    public function testMissingRequiredReportsEveryBlankOrAbsentColumn(): void
    {
        // The API validates `type` inline and exits before its rule set runs, so
        // on its own it only ever reports `type` and the user re-uploads once per
        // field. All four must come back together.
        $required = ['name', 'budget', 'bid', 'type'];

        $this->assertSame($required, ImportExportService::missingRequired([], $required));
        $this->assertSame(
            $required,
            ImportExportService::missingRequired(['name' => '', 'budget' => '  ', 'bid' => '', 'type' => ''], $required),
            'A present-but-blank column is what the API treats as missing'
        );
        $this->assertSame(
            ['bid', 'type'],
            ImportExportService::missingRequired(['name' => 'X', 'budget' => '5'], $required)
        );
    }

    public function testMissingRequiredPassesValueRulesThroughToTheApi(): void
    {
        // Presence only. budget=1 and bid=0.0001 are value-rule failures, and the
        // bid range varies per advertiser, so a client-side copy would reject rows
        // the API would have accepted.
        $this->assertSame([], ImportExportService::missingRequired(
            ['name' => 'X', 'budget' => '1', 'bid' => '0.0001', 'type' => 'display'],
            ['name', 'budget', 'bid', 'type']
        ));
        $this->assertSame([], ImportExportService::missingRequired(
            ['name' => 'X', 'budget' => '0', 'bid' => '0', 'type' => 'display'],
            ['name', 'budget', 'bid', 'type']
        ));
    }

    public function testDescribeFailureFallsBackToTheApiMessage(): void
    {
        $msg = ImportExportService::describeFailure(
            ['status' => 422],
            ['message' => 'Budget is too low'],
            2,
            'Campaign',
            false
        );

        $this->assertSame('Budget is too low (CSV row 2)', $msg);
    }

    public function testDescribeFailureStillReportsAStatusWhenTheBodyIsEmpty(): void
    {
        $msg = ImportExportService::describeFailure(['status' => 400], [], 5, 'Creative', false);

        $this->assertSame('request failed with HTTP 400 (CSV row 5).', $msg);
    }

    public function testMessageTextRendersAnErrorListAsTextNotJson(): void
    {
        // The reported bug: an array-valued `message` was json_encode()d and the
        // raw JSON printed at whoever uploaded the CSV.
        $text = ImportExportService::messageText([
            ['field' => 'status', 'message' => 'The Status field must be one of: A,S.'],
        ]);

        $this->assertSame('Field-status (The Status field must be one of: A,S.)', $text);
        $this->assertStringNotContainsString('{', $text);
    }

    public function testMessageTextPassesAStringThrough(): void
    {
        $this->assertSame('Campaign has been added', ImportExportService::messageText('Campaign has been added'));
    }

    public function testMessageTextFallsBackToTheTextInAnUnrecognisedArray(): void
    {
        // No field/message pairs to describe, so show the text it does carry
        // rather than encoding the structure.
        $this->assertSame(
            'Invalid bid; Invalid budget',
            ImportExportService::messageText(['Invalid bid', 'Invalid budget'])
        );
    }

    public function testMessageTextIsEmptyForAValueItCannotRender(): void
    {
        $this->assertSame('', ImportExportService::messageText(null));
        $this->assertSame('', ImportExportService::messageText(new \stdClass()));
    }

    public function testDescribeFailureExplainsWhatMadeACreativeADuplicate(): void
    {
        // The API's own text names neither the columns it compared nor the remedy,
        // so the natural next move is to re-upload the identical file.
        $res  = ['status' => 400];
        $body = ['status' => false, 'message' => 'Duplicate creative already exists.'];

        $msg = ImportExportService::describeFailure($res, $body, 4, 'creative', false);

        $this->assertStringContainsString('name and destination', $msg);
        $this->assertStringContainsString('id column', $msg);
        $this->assertStringContainsString('CSV row 4', $msg);
    }

    public function testDescribeFailureDoesNotGiveTheIdAdviceOnAnUpdate(): void
    {
        // precheck_store only runs on create. A row that already carries an id
        // must not be told to put its id in the id column.
        $res  = ['status' => 400];
        $body = ['status' => false, 'message' => 'Duplicate creative already exists.'];

        $msg = ImportExportService::describeFailure($res, $body, 4, 'creative', true);

        $this->assertStringNotContainsString('id column', $msg);
        $this->assertStringContainsString('Duplicate creative already exists.', $msg);
    }

    public function testTheDuplicateAdviceIsWithheldFromCampaigns(): void
    {
        // The campaigns sheet has no destination column, so this advice would name
        // a field the user cannot find. Campaigns have no duplicate check anyway.
        $res  = ['status' => 400];
        $body = ['status' => false, 'message' => 'Duplicate entry for key'];

        $msg = ImportExportService::describeFailure($res, $body, 4, 'Campaign', false);

        $this->assertStringNotContainsString('destination', $msg);
        $this->assertSame('Duplicate entry for key (CSV row 4)', $msg);
    }

    public function testDescribeFailureSalvagesANonStringApiMessageWithoutPretendingItIsASentence(): void
    {
        // Casting an array message would raise a conversion warning, which CI4
        // turns into a thrown ErrorException. It used to be dropped instead,
        // which was safe but threw away the only reason on offer; now the text is
        // read out, and the status stays because salvaged text is not a sentence.
        $res  = ['status' => 400];
        $body = ['status' => false, 'message' => ['duplicate' => 'creative']];

        $msg = ImportExportService::describeFailure($res, $body, 4, 'creative', false);

        $this->assertStringNotContainsString('name and destination', $msg);
        $this->assertStringNotContainsString('Array', $msg);
        $this->assertSame('request failed with HTTP 400 (CSV row 4): creative', $msg);
    }

    public function testDescribeFailureSurfacesTheFrameworksErrorKey(): void
    {
        // REST_Controller answers auth and rate-limit refusals under `error`.
        $msg = ImportExportService::describeFailure(
            ['status' => 401],
            ['status' => false, 'error' => 'Invalid credentials'],
            2,
            'Campaign',
            false
        );

        $this->assertStringContainsString('Invalid credentials', $msg);
        $this->assertStringNotContainsString('request failed with HTTP 401', $msg);
    }
}
