<?php

namespace Tests\Unit\AdOps;

use App\Services\AdOps\EventLogService;
use App\Services\AdOps\ListsRepository;
use App\Services\AdOps\ListsService;
use PHPUnit\Framework\TestCase;

/**
 * ListsService now talks to the Admin DB through ListsRepository (no HTTP hop).
 * These tests inject a fake repository to lock in the orchestration contract:
 * argument translation (esp. keyword bid semantics), return-shape wrapping that
 * the Lists controllers depend on, and the empty-delete guard. DB behaviour
 * itself lives in ListsRepository and is exercised against the dev DB.
 */
final class ListsServiceTest extends TestCase
{
    private function make(): array
    {
        $repo = new FakeListsRepository();
        $svc  = new ListsService($repo, new NullEventLog());
        return [$svc, $repo];
    }

    // ───────────────── keyword lists

    public function testCreateKeywordListDelegatesAndWrapsId(): void
    {
        [$svc, $repo] = $this->make();
        $repo->createKeywordListReturn = 9;

        $res = $svc->createKeywordList('My List', ['kw'], 'exact', '0.50');

        $this->assertSame(['My List', ['kw'], 'exact', '0.50'], $repo->calls['createKeywordList']);
        $this->assertTrue($res['status']);
        $this->assertSame(9, $res['body']['id']);
    }

    public function testCreateKeywordListDefaultsTypeToBroad(): void
    {
        [$svc, $repo] = $this->make();
        $svc->createKeywordList('L', ['k']);
        $this->assertSame('broad', $repo->calls['createKeywordList'][2]);
    }

    public function testCreateKeywordListStatusFalseWhenRepoReturnsNull(): void
    {
        [$svc, $repo] = $this->make();
        $repo->createKeywordListReturn = null;
        $this->assertFalse($svc->createKeywordList('L', ['k'])['status']);
    }

    public function testUpdateKeywordListLeavesBidUntouchedByDefault(): void
    {
        [$svc, $repo] = $this->make();
        // No bid arg → $bid defaults to false → setBid must be false.
        $svc->updateKeywordList(7, 'N', ['k'], 'broad');

        [$id, $name, $keywords, $type, $setBid, $bid] = $repo->calls['updateKeywordList'];
        $this->assertSame(7, $id);
        $this->assertSame(['k'], $keywords);
        $this->assertSame('broad', $type);
        $this->assertFalse($setBid, 'No bid arg must not touch the stored bid');
        $this->assertNull($bid);
    }

    public function testUpdateKeywordListEmptyBidClears(): void
    {
        [$svc, $repo] = $this->make();
        $svc->updateKeywordList(7, 'N', null, null, '');

        [, , $keywords, $type, $setBid, $bid] = $repo->calls['updateKeywordList'];
        $this->assertNull($keywords, 'null keywords = keep current');
        $this->assertNull($type, 'null type = keep current');
        $this->assertTrue($setBid);
        $this->assertSame('', $bid);
    }

    public function testUpdateKeywordListSetsExplicitBid(): void
    {
        [$svc, $repo] = $this->make();
        $svc->updateKeywordList(7, 'N', null, null, '0.25');
        [, , , , $setBid, $bid] = $repo->calls['updateKeywordList'];
        $this->assertTrue($setBid);
        $this->assertSame('0.25', $bid);
    }

    public function testDeleteKeywordListReturnsRepoBool(): void
    {
        [$svc, $repo] = $this->make();
        $repo->deleteKeywordListReturn = true;
        $this->assertTrue($svc->deleteKeywordList(3));
        $this->assertSame(3, $repo->calls['deleteKeywordList'][0]);
    }

    // ───────────────── mapping CSV import

    /** Path of the template the Download Template link serves. */
    private function templatePath(): string
    {
        return __DIR__ . '/../../../public/assets/reports/keyword_list_upload_csv_format.csv';
    }

    private function csv(string $body): string
    {
        $path = tempnam(sys_get_temp_dir(), 'kwmap') . '.csv';
        file_put_contents($path, $body);
        return $path;
    }

    public function testImportMappingCsvBuildsOneListPerBlockFromTheShippedTemplate(): void
    {
        [$svc, $repo] = $this->make();

        $res = $svc->importKeywordMappingCsv($this->templatePath());

        // Three list blocks, each spanning its continuation rows, plus the two
        // rows that only link an existing list to a campaign.
        $this->assertCount(3, $repo->created, 'one create per list block in the file');
        $this->assertSame(5, $res['done']);

        [$name1, $keywords1, $type1] = $repo->created[0];
        $this->assertSame('Last Final Test 1', $name1);
        $this->assertSame('exact', $type1, 'template says "Exact" — must not be downgraded to broad');
        $this->assertCount(8, $keywords1, 'all continuation rows belong to the list above them');
        $this->assertSame('indian interbanc', $keywords1[0]['keyword']);
        $this->assertSame('https://testing.com/', $keywords1[0]['dest_url']);

        $this->assertSame(['Last Final Test 2', 'broad'], [$repo->created[1][0], $repo->created[1][2]]);
        $this->assertCount(7, $repo->created[1][1]);
        $this->assertSame(['Last Final Test 3', 'broad'], [$repo->created[2][0], $repo->created[2][2]]);
        $this->assertCount(8, $repo->created[2][1]);

        // Nothing in this file may replace an existing list's keywords.
        $this->assertArrayNotHasKey('updateKeywordList', $repo->calls);
        $this->assertSame([], $repo->appended);
    }

    public function testImportMappingCsvAppendsToAnExistingListInsteadOfReplacingIt(): void
    {
        [$svc, $repo] = $this->make();
        $repo->existingLists['Promo KWs'] = ['id' => 7, 'name' => 'Promo KWs', 'type' => 'exact'];

        $svc->importKeywordMappingCsv($this->csv(
            "Campaign Id,Keyword List Name,Keyword List Id/Name,Keyword Type,Keywords,Destination url\n"
            . "0,Promo KWs,,Broad,winter boots,https://x.com/\n"
            . ",,,,summer boots,\n"
        ));

        $this->assertSame([], $repo->created, 'an existing name must be reused, not duplicated');
        $this->assertArrayNotHasKey('updateKeywordList', $repo->calls, 'append must never go through the replacing path');
        $this->assertCount(1, $repo->appended);
        [$listId, $appended] = $repo->appended[0];
        $this->assertSame(7, $listId);
        $this->assertSame(['winter boots', 'summer boots'], array_column($appended, 'keyword'));
    }

    public function testImportMappingCsvLinksAnExistingListWithoutTouchingItsKeywords(): void
    {
        [$svc, $repo] = $this->make();
        $repo->existingLists['284'] = ['id' => 284, 'name' => 'Promo KWs', 'type' => 'broad'];

        $res = $svc->importKeywordMappingCsv($this->csv("0,,,,,\n41457,,284,,,\n"));

        $this->assertSame([[284, 41457]], $repo->attached);
        $this->assertSame([], $repo->created);
        $this->assertSame([], $repo->appended, 'a link-only row must not write keywords');
        $this->assertArrayNotHasKey('updateKeywordList', $repo->calls);
        $this->assertSame(['Keyword list "Promo KWs" attached with Campaign Id - 41457'], $res['ok']);
    }

    public function testImportMappingCsvReportsAnUnknownListOrRejectedCampaign(): void
    {
        [$svc, $repo] = $this->make();

        $res = $svc->importKeywordMappingCsv($this->csv("41457,,284,,,\n"));
        $this->assertSame(['No keyword list found for "284" in this account.'], $res['failed']);

        [$svc, $repo] = $this->make();
        $repo->existingLists['284'] = ['id' => 284, 'name' => 'Promo KWs', 'type' => 'broad'];
        $repo->attachReturn = false;

        $res = $svc->importKeywordMappingCsv($this->csv("41457,,284,,,\n"));
        $this->assertStringStartsWith(
            'Keyword list "Promo KWs", but not linked to Campaign Id 41457',
            $res['failed'][0]
        );
    }

    public function testImportMappingCsvSaysTheListWasSavedWhenOnlyTheCampaignLinkFails(): void
    {
        // What an operator sees uploading the shipped template unedited: the
        // sample campaign ids belong to the account it was exported from.
        [$svc, $repo] = $this->make();
        $repo->attachReturn = false;

        $res = $svc->importKeywordMappingCsv($this->csv("41456,New List,,Exact,winter boots,\n"));

        $this->assertCount(1, $repo->created, 'the list is still written');
        $this->assertCount(1, $res['failed']);
        $this->assertStringStartsWith('Keyword list "New List" created, but not linked to Campaign Id 41456', $res['failed'][0]);
    }

    public function testImportMappingCsvRejectsAListWithAnUnusableMatchType(): void
    {
        [$svc, $repo] = $this->make();

        $res = $svc->importKeywordMappingCsv($this->csv("0,Typo List,,Phrase,winter boots,\n"));

        $this->assertSame([], $repo->created, 'no list may be written with a guessed match type');
        $this->assertSame(['Please specify correct match type for keyword list - Typo List'], $res['failed']);
    }

    public function testImportMappingCsvAttachesANewListToTheCampaignOnItsFirstRow(): void
    {
        [$svc, $repo] = $this->make();
        $repo->createKeywordListReturn = 42;

        $res = $svc->importKeywordMappingCsv($this->csv("41456,New List,,Exact,winter boots,\n,,,,summer boots,\n"));

        $this->assertSame([[42, 41456]], $repo->attached);
        $this->assertSame(['Keyword list "New List" created & attached with Campaign Id - 41456'], $res['ok']);
    }

    public function testImportMappingCsvReportsNothingDoneForAHeaderOnlyFile(): void
    {
        [$svc, $repo] = $this->make();

        $res = $svc->importKeywordMappingCsv($this->csv(
            "Campaign Id,Keyword List Name,Keyword List Id/Name,Keyword Type,Keywords,Destination url\n"
        ));

        $this->assertSame(0, $res['done']);
        $this->assertSame([], $repo->created);
        $this->assertSame([], $repo->appended);
    }

    public function testImportMappingCsvOnAnUnreadableFileDoesNothing(): void
    {
        [$svc, $repo] = $this->make();

        $res = $svc->importKeywordMappingCsv('/tmp/does-not-exist-' . getmypid() . '.csv');

        $this->assertSame(['done' => 0, 'ok' => [], 'failed' => []], $res);
        $this->assertSame([], $repo->created);
    }

    // ───────────────── flat keywords

    public function testSearchKeywordsDelegates(): void
    {
        [$svc, $repo] = $this->make();
        $repo->searchKeywordsReturn = ['items' => [['id' => '5_broad']], 'total' => 1];

        $res = $svc->searchKeywords('shoe', 20, 50);

        $this->assertSame(['shoe', 20, 50, 'keyword', 'asc'], $repo->calls['searchKeywords']);
        $this->assertSame(1, $res['total']);
        $this->assertSame('5_broad', $res['items'][0]['id']);
    }

    public function testDeleteKeywordsRejectsEmptyWithoutCalling(): void
    {
        [$svc, $repo] = $this->make();
        $this->assertFalse($svc->deleteKeywords([]));
        $this->assertArrayNotHasKey('deleteKeywords', $repo->calls);
    }

    public function testDeleteKeywordsDelegatesCompositeIds(): void
    {
        [$svc, $repo] = $this->make();
        $this->assertTrue($svc->deleteKeywords(['5_broad', '9_exact']));
        $this->assertSame(['5_broad', '9_exact'], $repo->calls['deleteKeywords'][0]);
    }

    public function testFindAndReplaceWrapsAffectedCount(): void
    {
        [$svc, $repo] = $this->make();
        $repo->findReplaceKeywordsReturn = 3;

        $res = $svc->findAndReplaceKeywords('old', 'new', [3, 4]);

        $this->assertSame(['old', 'new', [3, 4]], $repo->calls['findReplaceKeywords']);
        $this->assertTrue($res['status']);
        $this->assertSame(3, $res['body']['affected']);
    }

    // ───────────────── domains

    public function testCreateDomainListWrapsId(): void
    {
        [$svc, $repo] = $this->make();
        $repo->createDomainListReturn = 5;

        $res = $svc->createDomainList('D', ['a.com', 'b.com']);

        $this->assertSame(['D', ['a.com', 'b.com']], $repo->calls['createDomainList']);
        $this->assertTrue($res['status']);
        $this->assertSame(5, $res['body']['id']);
    }

    public function testUpdateDomainListNullKeepsDomains(): void
    {
        [$svc, $repo] = $this->make();
        $svc->updateDomainList(2, 'D', null);
        $this->assertNull($repo->calls['updateDomainList'][2]);
    }

    public function testUpdateDomainListArrayReplaces(): void
    {
        [$svc, $repo] = $this->make();
        $svc->updateDomainList(2, 'D', ['x.com']);
        $this->assertSame(['x.com'], $repo->calls['updateDomainList'][2]);
    }

    // ───────────────── sources

    public function testCreateSourceListWrapsId(): void
    {
        [$svc, $repo] = $this->make();
        $repo->createSourceListReturn = 11;
        $sources = [['source_id' => 1, 'bid' => 0.25]];

        $res = $svc->createSourceList('Src', $sources);

        $this->assertSame(['Src', $sources], $repo->calls['createSourceList']);
        $this->assertTrue($res['status']);
        $this->assertSame(11, $res['body']['id']);
    }

    public function testUpdateSourceListArrayPassedThrough(): void
    {
        [$svc, $repo] = $this->make();
        $rows = [['source_id' => 2, 'bid' => 0.3]];
        $svc->updateSourceList(4, 'S', $rows);
        $this->assertSame($rows, $repo->calls['updateSourceList'][2]);
    }

    public function testGetSourcesForListDelegates(): void
    {
        [$svc, $repo] = $this->make();
        $rows = [['source_id' => 1, 'bid' => '0.100000']];
        $repo->getSourcesReturn = $rows;
        $this->assertSame($rows, $svc->getSourcesForList(5));
        $this->assertSame(5, $repo->calls['getSources'][0]);
    }

    // ───────────────── list getters

    public function testListGettersDelegateToRepo(): void
    {
        [$svc, $repo] = $this->make();
        $repo->domainListsReturn  = [['id' => 1, 'name' => 'D']];
        $repo->keywordListsReturn = [['id' => 2, 'name' => 'K', 'type' => 'broad', 'bid' => null]];
        $repo->sourceListsReturn  = [['id' => 3, 'name' => 'S']];

        $this->assertSame($repo->domainListsReturn, $svc->listDomainLists());
        $this->assertSame($repo->keywordListsReturn, $svc->listKeywordLists());
        $this->assertSame($repo->sourceListsReturn, $svc->listSources());
    }
}

/**
 * Records the args each method was called with and returns configurable canned
 * values. Skips the real constructor so no session / DB connection is touched.
 */
class FakeListsRepository extends ListsRepository
{
    public array $calls = [];

    public $createKeywordListReturn = 1;
    public $deleteKeywordListReturn = true;
    public $searchKeywordsReturn = ['items' => [], 'total' => 0];
    public $findReplaceKeywordsReturn = 0;
    public $createDomainListReturn = 1;
    public $createSourceListReturn = 1;
    public $getSourcesReturn = [];
    public $domainListsReturn = [];
    public $keywordListsReturn = [];
    public $sourceListsReturn = [];

    /** Lists findKeywordListByIdOrName() should resolve, keyed by name or id. */
    public array $existingLists = [];
    public $attachReturn = true;

    /** Full call history for the mapping import, which writes several lists. */
    public array $created  = [];
    public array $appended = [];
    public array $attached = [];

    public function __construct()
    {
        // Intentionally does not call parent::__construct().
    }

    // domains
    public function domainLists(): array { $this->calls['domainLists'] = []; return $this->domainListsReturn; }
    public function getDomainList(int $id): ?array { $this->calls['getDomainList'] = [$id]; return ['id' => $id, 'name' => 'D']; }
    public function getDomains(int $id, int $offset = 0, int $limit = 5000): array { $this->calls['getDomains'] = [$id, $offset, $limit]; return []; }
    public function createDomainList(string $name, array $domains): ?int { $this->calls['createDomainList'] = [$name, $domains]; return $this->createDomainListReturn; }
    public function updateDomainList(int $id, string $name, ?array $domains): bool { $this->calls['updateDomainList'] = [$id, $name, $domains]; return true; }
    public function deleteDomainList(int $id): bool { $this->calls['deleteDomainList'] = [$id]; return true; }

    // keyword lists
    public function keywordLists(): array { $this->calls['keywordLists'] = []; return $this->keywordListsReturn; }
    public function getKeywordList(int $id): ?array { $this->calls['getKeywordList'] = [$id]; return ['id' => $id, 'name' => 'K', 'type' => 'broad', 'bid' => null]; }
    public function createKeywordList(string $name, array $keywords, string $type, $bid = null): ?int { $this->calls['createKeywordList'] = [$name, $keywords, $type, $bid]; $this->created[] = [$name, $keywords, $type, $bid]; return $this->createKeywordListReturn; }
    public function findKeywordListByIdOrName(string $detail): ?array { $this->calls['findKeywordListByIdOrName'] = [$detail]; return isset($this->existingLists[$detail]) ? $this->existingLists[$detail] : null; }
    public function appendKeywordsToList(int $id, array $keywords): bool { $this->appended[] = [$id, $keywords]; return true; }
    public function attachKeywordListToCampaign(int $listId, int $campaignId): bool { if (!$this->attachReturn) { return false; } $this->attached[] = [$listId, $campaignId]; return true; }
    public function updateKeywordList(int $id, string $name, ?array $keywords, ?string $type, bool $setBid, $bid = null): bool { $this->calls['updateKeywordList'] = [$id, $name, $keywords, $type, $setBid, $bid]; return true; }
    public function deleteKeywordList(int $id): bool { $this->calls['deleteKeywordList'] = [$id]; return $this->deleteKeywordListReturn; }
    public function searchKeywords(string $search, int $offset, int $limit, string $sort = 'keyword', string $dir = 'asc'): array { $this->calls['searchKeywords'] = [$search, $offset, $limit, $sort, $dir]; return $this->searchKeywordsReturn; }
    public function deleteKeywords(array $compositeIds): int { $this->calls['deleteKeywords'] = [$compositeIds]; return count($compositeIds); }
    public function findReplaceKeywords(string $find, string $replace, array $listIds = []): int { $this->calls['findReplaceKeywords'] = [$find, $replace, $listIds]; return $this->findReplaceKeywordsReturn; }

    // sources
    public function sourceLists(): array { $this->calls['sourceLists'] = []; return $this->sourceListsReturn; }
    public function getSourceList(int $id): ?array { $this->calls['getSourceList'] = [$id]; return ['id' => $id, 'name' => 'S']; }
    public function getSources(int $id): array { $this->calls['getSources'] = [$id]; return $this->getSourcesReturn; }
    public function createSourceList(string $name, array $sources): ?int { $this->calls['createSourceList'] = [$name, $sources]; return $this->createSourceListReturn; }
    public function updateSourceList(int $id, string $name, ?array $sources): bool { $this->calls['updateSourceList'] = [$id, $name, $sources]; return true; }
    public function deleteSourceList(int $id): bool { $this->calls['deleteSourceList'] = [$id]; return true; }
}

/** Audit logger stub — swallows writes so tests never touch the DB. */
class NullEventLog extends EventLogService
{
    public function __construct()
    {
        // Intentionally does not call parent::__construct().
    }

    public function add(array $data)
    {
        return true;
    }
}
