<?php

declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Cin7 Product To CSV Exporter
|--------------------------------------------------------------------------
|
| This script does one job:
| 1. Fetch products from Cin7 using GET requests only
| 2. Flatten the product data into rows
| 3. Write products.csv in the same folder as this script
|
| It does NOT create, update, or delete anything in Cin7.
|
| Put your real credentials in the config section below, then either:
| - run it in the browser
| - or run it from the command line with PHP
|
*/

/*
|--------------------------------------------------------------------------
| Config
|--------------------------------------------------------------------------
|
| Choose one platform:
| - core = Cin7 Core / DEAR Inventory
| - omni = Cin7 Omni
|
| For Cin7 Core:
| - fill in account_id
| - fill in application_key
|
| For Cin7 Omni:
| - change platform to omni
| - change base_url
| - fill in username
| - fill in api_key
|
*/

$config = [
    'platform' => 'core',
    'base_url' => 'https://inventory.dearsystems.com/ExternalApi/v2',

    // Cin7 Core credentials:
    'account_id' => 'b4b1ece3-12c7-4d62-a98c-b8d867e4b434',
    'application_key' => '7602a2ff-0484-3582-abdb-99ebacaef615',

    // Cin7 Omni credentials:
    'username' => '',
    'api_key' => '',

    // Output file. This becomes the public CSV.
    'output_csv' => __DIR__ . '/products.csv',

    // Page size used when fetching products.
    'page_size' => 100,

    // Optional filters. Leave blank for all products.
    'name_filter' => '',
    'sku_filter' => '',
    'modified_since' => '',

    // Only include these brands in the CSV.
    // Leave this list empty if you want all brands.
    'included_brands' => [
        'Moment',
        'PolarPro',
        'XP-Pen',
    ],

    // Request settings.
    'timeout_seconds' => 30,
    'max_retries' => 4,
    'retry_backoff_seconds' => 2,
];

// These are the only columns that will be written to the CSV.
// That keeps sensitive fields, such as cost price, out of the export.
$csvColumns = ['SKU', 'Barcode', 'Name', 'AdditionalAttribute6', 'Available'];

header('Content-Type: text/plain; charset=utf-8');

try {
    $products = fetch_products($config);
    $availableBySku = fetch_available_by_sku($config);
    $rows = [];

    foreach ($products as $product) {
        if (is_array($product)) {
            $flat = flatten_row($product);

            if (!should_include_product($flat, $config)) {
                continue;
            }

            $sku = isset($flat['SKU']) ? trim((string) $flat['SKU']) : '';
            // Cin7 does not return a productavailability row for every product SKU.
            // Default missing availability rows to 0 in the CSV export.
            $flat['Available'] = isset($availableBySku[$sku]) ? (string) $availableBySku[$sku] : '0';
            $rows[] = keep_only_columns($flat, $csvColumns);
        }
    }

    write_csv($config['output_csv'], $csvColumns, $rows);

    echo 'Done.' . PHP_EOL;
    echo 'Products: ' . count($rows) . PHP_EOL;
    echo 'Columns: ' . count($csvColumns) . PHP_EOL;
    echo 'CSV: ' . $config['output_csv'] . PHP_EOL;
} catch (Throwable $e) {
    http_response_code(500);
    echo 'Error: ' . $e->getMessage() . PHP_EOL;
    exit(1);
}

/*
|--------------------------------------------------------------------------
| Fetch Products
|--------------------------------------------------------------------------
*/

function fetch_products(array $config): array
{
    if ($config['platform'] === 'omni') {
        return fetch_omni_products($config);
    }

    return fetch_core_products($config);
}

function fetch_available_by_sku(array $config): array
{
    if ($config['platform'] === 'omni') {
        return [];
    }

    return fetch_core_available_by_sku($config);
}

function should_include_product(array $flat, array $config): bool
{
    $includedBrands = $config['included_brands'] ?? [];

    if (!is_array($includedBrands) || $includedBrands === []) {
        return true;
    }

    $brand = isset($flat['Brand']) ? trim((string) $flat['Brand']) : '';
    if ($brand === '') {
        return false;
    }

    foreach ($includedBrands as $includedBrand) {
        if (strcasecmp($brand, trim((string) $includedBrand)) === 0) {
            return true;
        }
    }

    return false;
}

function fetch_core_products(array $config): array
{
    if ($config['account_id'] === '' || $config['application_key'] === '') {
        throw new RuntimeException('Please fill in account_id and application_key at the top of the script.');
    }

    $allProducts = [];
    $page = 1;
    $total = null;

    while (true) {
        $response = http_get_json(
            $config,
            '/Product',
            [
                'Page' => $page,
                'Limit' => $config['page_size'],
                'Name' => $config['name_filter'],
                'Sku' => $config['sku_filter'],
                'ModifiedSince' => $config['modified_since'],
                'IncludeDeprecated' => 'true',
            ]
        );

        $pageProducts = isset($response['Products']) && is_array($response['Products'])
            ? $response['Products']
            : [];

        if (isset($response['Total'])) {
            $total = (int) $response['Total'];
        }

        foreach ($pageProducts as $product) {
            if (is_array($product)) {
                $allProducts[] = $product;
            }
        }

        if ($pageProducts === []) {
            break;
        }

        if ($total !== null && count($allProducts) >= $total) {
            break;
        }

        $page++;
    }

    return $allProducts;
}

function fetch_core_available_by_sku(array $config): array
{
    if ($config['account_id'] === '' || $config['application_key'] === '') {
        throw new RuntimeException('Please fill in account_id and application_key at the top of the script.');
    }

    $availableBySku = [];
    $page = 1;
    $total = null;

    while (true) {
        $response = http_get_json(
            $config,
            '/ref/productavailability',
            [
                'Page' => $page,
                'Limit' => $config['page_size'],
                'Name' => $config['name_filter'],
                'Sku' => $config['sku_filter'],
            ]
        );

        $pageRows = isset($response['ProductAvailabilityList']) && is_array($response['ProductAvailabilityList'])
            ? $response['ProductAvailabilityList']
            : [];

        if (isset($response['Total'])) {
            $total = (int) $response['Total'];
        }

        foreach ($pageRows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $sku = isset($row['SKU']) ? trim((string) $row['SKU']) : '';
            if ($sku === '') {
                continue;
            }

            $available = isset($row['Available']) ? (float) $row['Available'] : 0.0;

            if (!isset($availableBySku[$sku])) {
                $availableBySku[$sku] = 0.0;
            }

            // Product availability can be split by location or batch.
            // We add those rows together so the CSV shows total available stock per SKU.
            $availableBySku[$sku] += $available;
        }

        if ($pageRows === []) {
            break;
        }

        if ($total !== null && ($page * (int) $config['page_size']) >= $total) {
            break;
        }

        $page++;
    }

    return $availableBySku;
}

function fetch_omni_products(array $config): array
{
    if ($config['username'] === '' || $config['api_key'] === '') {
        throw new RuntimeException('Please fill in username and api_key at the top of the script.');
    }

    $allProducts = [];
    $page = 1;

    while (true) {
        $response = http_get_json(
            $config,
            '/v1/Products',
            [
                'page' => $page,
                'rows' => $config['page_size'],
                'name' => $config['name_filter'],
                'sku' => $config['sku_filter'],
            ]
        );

        $pageProducts = extract_product_list($response);

        foreach ($pageProducts as $product) {
            if (is_array($product)) {
                $allProducts[] = $product;
            }
        }

        if ($pageProducts === [] || count($pageProducts) < (int) $config['page_size']) {
            break;
        }

        $page++;
    }

    return $allProducts;
}

/*
|--------------------------------------------------------------------------
| HTTP GET Helper
|--------------------------------------------------------------------------
|
| This helper only performs GET requests.
| That is important because we only want to read data from Cin7.
|
*/

function http_get_json(array $config, string $path, array $query): array
{
    $baseUrl = rtrim((string) $config['base_url'], '/');
    $url = $baseUrl . '/' . ltrim($path, '/');

    $query = array_filter(
        $query,
        static function ($value): bool {
            return $value !== '' && $value !== null;
        }
    );

    if ($query !== []) {
        $url .= '?' . http_build_query($query);
    }

    $headers = ['Accept: application/json'];

    if ($config['platform'] === 'core') {
        $headers[] = 'api-auth-accountid: ' . $config['account_id'];
        $headers[] = 'api-auth-applicationkey: ' . $config['application_key'];
    } else {
        $headers[] = 'Authorization: Basic ' . base64_encode(
            $config['username'] . ':' . $config['api_key']
        );
    }

    $attempts = (int) $config['max_retries'] + 1;

    for ($attempt = 1; $attempt <= $attempts; $attempt++) {
        $ch = curl_init($url);
        curl_setopt_array(
            $ch,
            [
                CURLOPT_CUSTOMREQUEST => 'GET',
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_FOLLOWLOCATION => true,
                CURLOPT_TIMEOUT => (int) $config['timeout_seconds'],
                CURLOPT_HTTPHEADER => $headers,
            ]
        );

        $body = curl_exec($ch);
        $curlError = curl_error($ch);
        $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($body === false) {
            if ($attempt < $attempts) {
                sleep((int) ceil((float) $config['retry_backoff_seconds'] * $attempt));
                continue;
            }

            throw new RuntimeException('Request failed: ' . $curlError);
        }

        if (in_array($statusCode, [429, 500, 502, 503, 504], true) && $attempt < $attempts) {
            sleep((int) ceil((float) $config['retry_backoff_seconds'] * $attempt));
            continue;
        }

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new RuntimeException(
                'Cin7 returned HTTP ' . $statusCode . ' for ' . $url . ' with body: ' . substr($body, 0, 500)
            );
        }

        $decoded = json_decode($body, true);
        if (!is_array($decoded)) {
            throw new RuntimeException('Cin7 did not return valid JSON.');
        }

        return $decoded;
    }

    throw new RuntimeException('Request failed after retries.');
}

/*
|--------------------------------------------------------------------------
| Response Shape Helpers
|--------------------------------------------------------------------------
*/

function extract_product_list(array $response): array
{
    foreach (['Products', 'products', 'data', 'results'] as $key) {
        if (isset($response[$key]) && is_array($response[$key])) {
            return $response[$key];
        }
    }

    $arrayValues = [];
    foreach ($response as $value) {
        if (is_array($value)) {
            $arrayValues[] = $value;
        }
    }

    if (count($arrayValues) === 1) {
        return $arrayValues[0];
    }

    throw new RuntimeException('Could not find the product list in the API response.');
}

function keep_only_columns(array $row, array $columns): array
{
    $filtered = [];

    foreach ($columns as $column) {
        $filtered[$column] = isset($row[$column]) ? $row[$column] : '';
    }

    return $filtered;
}

/*
|--------------------------------------------------------------------------
| Flatten Products For CSV
|--------------------------------------------------------------------------
|
| Nested arrays become:
| - dotted keys for nested objects
| - pipe-separated text for lists of simple values
| - JSON text for lists of objects
|
*/

function flatten_row(array $data, string $prefix = ''): array
{
    $flat = [];

    foreach ($data as $key => $value) {
        $column = $prefix === '' ? (string) $key : $prefix . '.' . $key;

        if (is_array($value)) {
            if (is_assoc($value)) {
                $flat = array_merge($flat, flatten_row($value, $column));
                continue;
            }

            if ($value === []) {
                $flat[$column] = '';
                continue;
            }

            $allSimple = true;
            foreach ($value as $item) {
                if (is_array($item)) {
                    $allSimple = false;
                    break;
                }
            }

            if ($allSimple) {
                $flat[$column] = implode(
                    '|',
                    array_map(
                        static function ($item): string {
                            return $item === null ? '' : (string) $item;
                        },
                        $value
                    )
                );
            } else {
                $flat[$column] = (string) json_encode($value, JSON_UNESCAPED_SLASHES);
            }

            continue;
        }

        $flat[$column] = $value === null ? '' : (string) $value;
    }

    return $flat;
}

function is_assoc(array $array): bool
{
    return array_keys($array) !== range(0, count($array) - 1);
}

/*
|--------------------------------------------------------------------------
| CSV Writing
|--------------------------------------------------------------------------
*/

function write_csv(string $path, array $columns, array $rows): void
{
    $handle = fopen($path, 'wb');
    if ($handle === false) {
        throw new RuntimeException('Could not open CSV file for writing: ' . $path);
    }

    fputcsv($handle, $columns);

    foreach ($rows as $row) {
        $line = [];
        foreach ($columns as $column) {
            $line[] = isset($row[$column]) ? $row[$column] : '';
        }
        fputcsv($handle, $line);
    }

    fclose($handle);
}
