use strict;
use warnings FATAL => 'all';

use Capture::Tiny qw(capture);
use Cwd qw(abs_path getcwd);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir tempfile);
use IO::Socket::INET;
use LWP::UserAgent;
use Test::More;
use Time::HiRes qw(sleep);

use lib 'lib';
use Developer::Dashboard::JSON qw(json_decode);

my $fixture_path = $ENV{API_DASHBOARD_IMPORT_FIXTURE} || '';
plan skip_all => 'Set API_DASHBOARD_IMPORT_FIXTURE to a Postman collection JSON file to run the import-fixture Playwright repro'
  if $fixture_path eq '';
plan skip_all => "Missing import fixture at $fixture_path"
  if !-f $fixture_path;

my $fixture = json_decode(_read_text($fixture_path));
plan skip_all => 'Import fixture must be a Postman collection object with info.name and at least one nested request'
  if ref($fixture) ne 'HASH'
  || ref( $fixture->{info} ) ne 'HASH'
  || !defined $fixture->{info}{name}
  || $fixture->{info}{name} eq '';

my $collection_name = $fixture->{info}{name};
my ( $first_folder_name, $nested_folder_name, $request_name, $request_url ) = _sample_path_from_collection($fixture);
plan skip_all => 'Import fixture must contain a nested folder/request path that the browser test can open'
  if !defined $first_folder_name || !defined $nested_folder_name || !defined $request_name || !defined $request_url;

my $repo_root      = abs_path('.');
my $repo_lib       = File::Spec->catdir( $repo_root, 'lib' );
my $dashboard_bin  = File::Spec->catfile( $repo_root, 'bin', 'dashboard' );
my $host_home_root = $ENV{HOME} || '';

my $node_bin     = _find_command('node');
my $npx_bin      = _find_command('npx');
my $git_bin      = _find_command('git');
my $chromium_bin = _find_command( qw(chromium chromium-browser google-chrome google-chrome-stable) );

plan skip_all => 'Import-fixture Playwright test requires node, npx, git, and Chromium on PATH'
  if !$node_bin || !$npx_bin || !$git_bin || !$chromium_bin;

my $playwright_dir = eval { _playwright_dir( $npx_bin, $host_home_root ) };
plan skip_all => "Playwright module cache is unavailable: $@"
  if !$playwright_dir;

my $home_root    = tempdir( 'dd-api-import-home-XXXXXX', CLEANUP => 1, TMPDIR => 1 );
my $project_root = tempdir( 'dd-api-import-project-XXXXXX', CLEANUP => 1, TMPDIR => 1 );
my $runtime_root = File::Spec->catdir( $project_root, '.developer-dashboard' );
my $config_root  = File::Spec->catdir( $runtime_root, 'config', 'api-dashboard' );

make_path($runtime_root);
make_path($config_root);

my $dashboard_port = _reserve_port();
my $dashboard_pid;
my $dashboard_log = File::Spec->catfile( $project_root, 'dashboard-serve.log' );

eval {
    _run_command(
        command => [ $git_bin, 'init', '-q', $project_root ],
        label   => 'git init',
    );

    _run_command(
        command => [ $^X, "-I$repo_lib", $dashboard_bin, 'init' ],
        cwd     => $project_root,
        env     => { HOME => $home_root },
        label   => 'dashboard init',
    );

    $dashboard_pid = _start_dashboard_server(
        cwd           => $project_root,
        home          => $home_root,
        port          => $dashboard_port,
        repo_lib      => $repo_lib,
        dashboard_bin => $dashboard_bin,
        log_file      => $dashboard_log,
    );
    _wait_for_http("http://127.0.0.1:$dashboard_port/app/api-dashboard");

    my ( $script_fh, $script_path ) = tempfile( 'api-dashboard-import-fixture-XXXXXX', SUFFIX => '.js', TMPDIR => 1 );
    print {$script_fh} _playwright_script();
    close $script_fh or die "Unable to close Playwright script $script_path: $!";

    my $stored_collection_file = File::Spec->catfile( $config_root, _safe_filename($collection_name) . '.json' );
    my $playwright_result = _run_command(
        command => [ $node_bin, $script_path ],
        env     => {
            PLAYWRIGHT_DIR         => $playwright_dir,
            CHROMIUM_BIN           => $chromium_bin,
            DASHBOARD_URL          => "http://127.0.0.1:$dashboard_port/app/api-dashboard",
            IMPORT_SOURCE_FILE     => $fixture_path,
            STORED_COLLECTION_FILE => $stored_collection_file,
            COLLECTION_NAME        => $collection_name,
            REQUEST_NAME           => $request_name,
            REQUEST_URL_FRAGMENT   => $request_url,
        },
        label => 'Playwright import-fixture flow',
    );

    is( $playwright_result->{stderr}, '', 'import-fixture Playwright flow does not emit stderr' );
    my $payload = json_decode( $playwright_result->{stdout} );
    ok( $payload->{ok}, 'import-fixture Playwright flow reports success' );
    ok( $payload->{input_imported}, 'Playwright injected file upload triggers the import banner' );
    is( $payload->{collection_name}, $collection_name, 'import-fixture Playwright flow returns the imported collection name' );

    ok( -f $stored_collection_file, 'import-fixture run persists a Postman json file under config/api-dashboard' );
    my $stored = json_decode( _read_text($stored_collection_file) );
    is( $stored->{info}{name}, $collection_name, 'stored imported collection keeps info.name' );
    is( $stored->{info}{schema}, 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', 'stored imported collection keeps the Postman v2.1 schema' );
    is( $stored->{item}[0]{name}, $first_folder_name, 'stored imported collection keeps the first top-level folder name' );
    is( $stored->{item}[0]{item}[0]{name}, $nested_folder_name, 'stored imported collection keeps the nested folder structure' );
    is( $stored->{item}[0]{item}[0]{item}[0]{name}, $request_name, 'stored imported collection keeps the nested request name' );

    1;
} or do {
    my $error = $@ || 'import-fixture Playwright test failed';
    diag $error;
    diag _read_text($dashboard_log) if -f $dashboard_log;
    _stop_dashboard_server(
        cwd           => $project_root,
        home          => $home_root,
        repo_lib      => $repo_lib,
        dashboard_bin => $dashboard_bin,
        pid           => $dashboard_pid,
    ) if $dashboard_pid;
    die $error;
};

_stop_dashboard_server(
    cwd           => $project_root,
    home          => $home_root,
    repo_lib      => $repo_lib,
    dashboard_bin => $dashboard_bin,
    pid           => $dashboard_pid,
) if $dashboard_pid;

done_testing;

# _find_command(@candidates)
# Purpose: resolve the first executable command name that exists on PATH.
# Input: one or more command-name strings to try in order.
# Output: absolute executable path string or undef when none are available.
sub _find_command {
    my @candidates = @_;
    for my $candidate (@candidates) {
        next if !defined $candidate || $candidate eq '';
        for my $dir ( File::Spec->path() ) {
            my $path = File::Spec->catfile( $dir, $candidate );
            return $path if -f $path && -x $path;
        }
    }
    return undef;
}

# _playwright_dir($npx_bin, $home_root)
# Purpose: warm the local npx cache and return the cached Playwright module directory for direct Node usage.
# Input: absolute npx executable path and the real user home path that owns the npx cache.
# Output: absolute Playwright module directory path string.
sub _playwright_dir {
    my ( $npx_bin, $home_root ) = @_;
    my ( $stdout, $stderr, $exit ) = capture {
        system( $npx_bin, 'playwright', '--version' );
    };
    die "Unable to resolve Playwright with npx: $stderr$stdout"
      if $exit != 0;
    my @matches = sort glob( File::Spec->catfile( $home_root, '.npm', '_npx', '*', 'node_modules', 'playwright' ) );
    die "Unable to find cached Playwright module directory under $home_root/.npm/_npx\n"
      if !@matches;
    return $matches[-1];
}

# _reserve_port()
# Purpose: reserve one ephemeral local TCP port number for a temporary test server.
# Input: none.
# Output: integer port number.
sub _reserve_port {
    my $socket = IO::Socket::INET->new(
        LocalAddr => '127.0.0.1',
        LocalPort => 0,
        Proto     => 'tcp',
        Listen    => 1,
        ReuseAddr => 1,
    ) or die "Unable to reserve a local TCP port: $!";
    my $port = $socket->sockport();
    close $socket or die "Unable to close reserved TCP port socket for $port: $!";
    return $port;
}

# _run_command(%args)
# Purpose: run one command with optional cwd and environment overrides while capturing stdout and stderr explicitly.
# Input: hash with command arrayref, optional cwd string, optional env hashref, and optional label string.
# Output: hashref with stdout, stderr, and exit integer fields.
sub _run_command {
    my (%args) = @_;
    my $command = $args{command} || [];
    die "run_command requires a command array reference\n" if ref($command) ne 'ARRAY' || !@{$command};

    my $cwd = getcwd();
    my ( $stdout, $stderr, $exit ) = capture {
        local %ENV = ( %ENV, %{ $args{env} || {} } );
        if ( defined $args{cwd} && $args{cwd} ne '' ) {
            chdir $args{cwd} or die "Unable to chdir to $args{cwd}: $!";
        }
        system( @{$command} );
    };
    chdir $cwd or die "Unable to restore cwd $cwd: $!";

    die( ( $args{label} || 'command' ) . " failed with exit $exit\nSTDOUT:\n$stdout\nSTDERR:\n$stderr" )
      if $exit != 0;

    return {
        stdout => $stdout,
        stderr => $stderr,
        exit   => $exit,
    };
}

# _wait_for_http($url)
# Purpose: poll one URL until it responds successfully.
# Input: URL string.
# Output: none.
sub _wait_for_http {
    my ($url) = @_;
    my $ua = LWP::UserAgent->new( timeout => 2 );
    my $deadline = time() + 20;
    while ( time() < $deadline ) {
        my $response = $ua->get($url);
        return if $response->is_success;
        sleep 0.25;
    }
    die "Timed out waiting for HTTP success at $url\n";
}

# _read_text($path)
# Purpose: read one text file from disk.
# Input: absolute file path string.
# Output: raw file content string.
sub _read_text {
    my ($path) = @_;
    open my $fh, '<:raw', $path or die "Unable to read $path: $!";
    local $/;
    my $text = <$fh>;
    close $fh or die "Unable to close $path: $!";
    return $text;
}

# _safe_filename($name)
# Purpose: derive the same safe collection filename shape used by the bookmark save handler.
# Input: collection name string.
# Output: sanitized filename stem string.
sub _safe_filename {
    my ($name) = @_;
    $name = '' if !defined $name;
    $name =~ s/[\x00-\x1F]+/ /g;
    $name =~ s{[\\/:*?"<>|]+}{-}g;
    $name =~ s/\s+/ /g;
    $name =~ s/^\s+//;
    $name =~ s/\s+\z//;
    die "Collection name is required\n" if $name eq '' || $name eq '.' || $name eq '..';
    return $name;
}

# _sample_path_from_collection($collection)
# Purpose: derive one representative folder/request path from an imported Postman collection fixture.
# Input: decoded Postman collection hash reference.
# Output: first top-level folder name, nested folder name, request name, and request URL string.
sub _sample_path_from_collection {
    my ($collection) = @_;
    my @top = @{ $collection->{item} || [] };
    for my $item (@top) {
        next if ref($item) ne 'HASH' || ref( $item->{item} ) ne 'ARRAY';
        my $top_name = $item->{name};
        for my $nested ( @{ $item->{item} || [] } ) {
            next if ref($nested) ne 'HASH' || ref( $nested->{item} ) ne 'ARRAY';
            my $nested_name = $nested->{name};
            for my $leaf ( @{ $nested->{item} || [] } ) {
                next if ref($leaf) ne 'HASH' || ref( $leaf->{request} ) ne 'HASH';
                my $request_name = $leaf->{name};
                my $url = $leaf->{request}{url};
                my $raw = ref($url) eq 'HASH' ? $url->{raw} : $url;
                next if !defined $request_name || $request_name eq '' || !defined $raw || $raw eq '';
                return ( $top_name, $nested_name, $request_name, $raw );
            }
        }
    }
    return;
}

# _start_dashboard_server(%args)
# Purpose: launch the dashboard web server as a child process for the browser test.
# Input: hash with cwd, home, repo_lib, dashboard_bin, port, and log_file.
# Output: child process pid integer.
sub _start_dashboard_server {
    my (%args) = @_;
    my $pid = fork();
    die "Unable to fork dashboard serve child: $!" if !defined $pid;
    if ( !$pid ) {
        open STDOUT, '>:raw', $args{log_file} or die "Unable to write $args{log_file}: $!";
        open STDERR, '>&STDOUT' or die "Unable to redirect STDERR to dashboard log: $!";
        chdir $args{cwd} or die "Unable to chdir to $args{cwd}: $!";
        local %ENV = ( %ENV, HOME => $args{home} );
        exec( $^X, "-I$args{repo_lib}", $args{dashboard_bin}, 'serve', '--host', '127.0.0.1', '--port', $args{port} )
          or die "Unable to exec dashboard serve: $!";
    }
    return $pid;
}

# _stop_dashboard_server(%args)
# Purpose: stop the dashboard web server cleanly and ensure the child process exits.
# Input: hash with cwd, home, repo_lib, dashboard_bin, and pid.
# Output: none.
sub _stop_dashboard_server {
    my (%args) = @_;
    eval {
        _run_command(
            command => [ $^X, "-I$args{repo_lib}", $args{dashboard_bin}, 'stop' ],
            cwd     => $args{cwd},
            env     => { HOME => $args{home} },
            label   => 'dashboard stop',
        );
        1;
    } or do { };
    return if !$args{pid};
    kill 'TERM', $args{pid} if kill 0, $args{pid};
    waitpid( $args{pid}, 0 );
}

# _playwright_script()
# Purpose: return the embedded Node.js Playwright import flow used by this Perl test.
# Input: none.
# Output: JavaScript source text string.
sub _playwright_script {
    return <<'JAVASCRIPT';
const fs = require('fs');
const { chromium } = require(process.env.PLAYWRIGHT_DIR);

async function waitForCollection(page, name, shouldExist) {
  await page.waitForFunction(
    ({ collectionName, shouldExist: shouldHaveCollection }) => {
      const names = Array.from(document.querySelectorAll('.api-collection-card h3')).map((node) => node.textContent.trim());
      return shouldHaveCollection ? names.includes(collectionName) : !names.includes(collectionName);
    },
    { collectionName: name, shouldExist }
  );
}

async function waitForImportBanner(page, collectionName) {
  const deadline = Date.now() + 10000;
  while (Date.now() < deadline) {
    const snapshot = await page.evaluate((name) => {
      const banner = document.querySelector('#api-banner');
      const input = document.querySelector('#api-import-file');
      const names = Array.from(document.querySelectorAll('.api-collection-card h3')).map((node) => node.textContent.trim());
      return {
        banner_text: banner ? banner.textContent : '',
        files_count: input && input.files ? input.files.length : -1,
        names,
        imported: !!(banner && banner.textContent.includes(`Imported Postman collection "${name}".`)),
      };
    }, collectionName);
    if (snapshot.imported) return snapshot;
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  const snapshot = await page.evaluate((name) => {
    const banner = document.querySelector('#api-banner');
    const input = document.querySelector('#api-import-file');
    const names = Array.from(document.querySelectorAll('.api-collection-card h3')).map((node) => node.textContent.trim());
    return {
      banner_text: banner ? banner.textContent : '',
      files_count: input && input.files ? input.files.length : -1,
      names,
      imported: !!(banner && banner.textContent.includes(`Imported Postman collection "${name}".`)),
    };
  }, collectionName);
  throw new Error(`Import banner did not appear: ${JSON.stringify(snapshot)}`);
}

async function main() {
  const browser = await chromium.launch({
    executablePath: process.env.CHROMIUM_BIN,
    headless: true,
    args: ['--no-sandbox', '--disable-dev-shm-usage'],
  });

  const consoleErrors = [];

  try {
    const context = await browser.newContext();
    const page = await context.newPage();

    page.on('console', (message) => {
      if (message.type() !== 'error') return;
      const text = message.text();
      if (text === 'Failed to load resource: the server responded with a status of 404 (Not Found)') return;
      if (/favicon\.ico/i.test(text)) return;
      consoleErrors.push(text);
    });
    page.on('pageerror', (error) => {
      consoleErrors.push(error && error.stack ? error.stack : String(error));
    });

    await page.goto(process.env.DASHBOARD_URL, { waitUntil: 'domcontentloaded' });
    await page.getByRole('tab', { name: 'Collections' }).click();
    await page.waitForSelector('#api-collection-tree');
    await waitForCollection(page, process.env.COLLECTION_NAME, false);

    await page.locator('#api-import-file').setInputFiles({
      name: process.env.IMPORT_SOURCE_FILE.split(/[\\/]/).pop(),
      mimeType: 'application/json',
      buffer: fs.readFileSync(process.env.IMPORT_SOURCE_FILE),
    });
    await waitForImportBanner(page, process.env.COLLECTION_NAME);
    await waitForCollection(page, process.env.COLLECTION_NAME, true);

    const chooserState = await page.evaluate((collectionName) => {
      const input = document.querySelector('#api-import-file');
      const banner = document.querySelector('#api-banner');
      return {
        files_count: input && input.files ? input.files.length : -1,
        imported: !!(banner && banner.textContent.includes(`Imported Postman collection "${collectionName}".`)),
      };
    }, process.env.COLLECTION_NAME);
    await page.waitForFunction((requestName) => {
      const buttons = Array.from(document.querySelectorAll('button.api-node-button')).map((node) => node.textContent.trim());
      return buttons.includes(requestName);
    }, process.env.REQUEST_NAME);

    await page.locator('.api-collection-card h3').filter({ hasText: process.env.COLLECTION_NAME }).click();
    await page.locator('button.api-node-button').filter({ hasText: process.env.REQUEST_NAME }).click();
    await page.waitForFunction((urlFragment) => {
      const url = document.querySelector('#api-request-url');
      return url && url.value.includes(urlFragment);
    }, process.env.REQUEST_URL_FRAGMENT);

    await page.reload({ waitUntil: 'domcontentloaded' });
    await page.getByRole('tab', { name: 'Collections' }).click();
    await page.waitForSelector('#api-collection-tree');
    await waitForCollection(page, process.env.COLLECTION_NAME, true);

    if (!fs.existsSync(process.env.STORED_COLLECTION_FILE)) {
      throw new Error(`Imported collection file was not persisted: ${process.env.STORED_COLLECTION_FILE}`);
    }

    if (consoleErrors.length) {
      throw new Error(`Browser console errors: ${consoleErrors.join(' | ')}`);
    }

    process.stdout.write(JSON.stringify({
      ok: true,
      input_imported: chooserState.imported,
      collection_name: process.env.COLLECTION_NAME,
    }));
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  process.stderr.write((error && error.stack ? error.stack : String(error)) + '\n');
  process.exit(1);
});
JAVASCRIPT
}

__END__

=head1 NAME

23-api-dashboard-import-fixture-playwright.t - reproduce Postman import through the browser with an external fixture

=head1 DESCRIPTION

This test uses Playwright and an external Postman collection fixture supplied
through C<API_DASHBOARD_IMPORT_FIXTURE> to exercise the seeded
C<api-dashboard> import path through the real browser-visible import control.
It verifies that the chooser-backed upload updates the bookmark UI directly and
persists the imported collection as Postman JSON under C<config/api-dashboard>.

=cut
