<?php

function parseJpeg(string $file) : array{
    $fp = fopen($file, 'rb');
    if (!$fp){
        throw new \RuntimeException('Unable to open file');
    }

    //First two bytes should be \xFFD8.
    if (fread($fp, 2) !== "\xFF\xD8"){
        throw new \RuntimeException('Invalid image file.');
    }

    $output = [];
    //Find each segment by looking for the marker values \xFF??
    while (!feof($fp) && ($marker = findNextMarker($fp))){
        //If the marker is not the end of image marker.
        $blockData = [
            'marker' => $marker
            , 'offset' => ftell($fp) - 2
        ];
        if ($marker !== 0xD9){
            //Parse the segment data for this marker.
            $blockData['segmentData'] = parseSegmentData($fp);

            if ($marker === 0xDA){ //If the marker is a start of scan marker.
                //Parse the image data that follows.
                $blockData['imageData'] = parseImageData($fp);
            } else if ($marker === 0xE0){ //If the marker is the app0 header.
                $blockData['headerData'] = parseApp0Header($blockData['segmentData']);
            } else if ($marker === 0xE2 && str_starts_with($blockData['segmentData'], 'ICC_PROFILE')){
                $blockData['iccData'] = parseIccData(substr($blockData['segmentData'], strlen('ICC_PROFILE') + 3));
            }

            $output[] = $blockData;
        } else {
            $output[] = $blockData;
            break;
        }
    }

    fclose($fp);

    return $output;
}

function parseSegmentData($fp) : string{
    //Markers indicate the start of a segment which is composed of <length><data> sections.
    //The length is two bytes and is the length of the entire segment including the two bytes
    //used to define the length.

    //Extract the length from the next two bytes.
    $dataLength = unpack('nlength', fread($fp, 2))['length'];

    //Read the remaining data using that length value.  Subtract 2 because
    //$dataLength includes the two bytes we just read to obtain the length
    $data = fread($fp, $dataLength - 2);

    return $data;
}

function parseImageData($fp) : string{
    $imageData = [];
    //Read data until we find another marker or hit end of file.
    while (!feof($fp)){
        $c = fread($fp, 1);
        //We might have found another marker.
        if ($c === "\xFF"){
            //Save our position in the file
            //If we found a marker, we need to rewind to just before it.
            $pos = ftell($fp);

            //We only found a marker if the next byte is not \x00 or \xD0-\xD7
            $next = ord(fread($fp, 1));
            if ($next !== 0x00 && ($next < 0xD0 || $next > 0xD7)){
                //Rewind the file to just before the marker we just found and exit the loop.
                fseek($fp, $pos - 1);
                break;
            } else {
                $imageData[] = $c;
                $imageData[] = chr($next);
            }
        } else {
            $imageData[] = $c;
        }
    }

    $imageData = implode('', $imageData);

    return $imageData;
}

function parseApp0Header(string $data) : ?array{
    $unpacked = unpack('Z5id/c2version/cunits/n2dpi/c2thumb', $data);
    if (!$unpacked){
        return null;
    }

    return [
        'id' => $unpacked['id']
        , 'version' => $unpacked['version1'] . '.' . $unpacked['version2']
        , 'units' => $unpacked['units']
        , 'density' => $unpacked['dpi1'] . 'x' . $unpacked['dpi2']
        , 'thumbnail' => $unpacked['thumb1'] . 'x' . $unpacked['thumb2']
    ];
}

function hexdump(string $data, int $offset) : string{
    $lines = [];
    foreach (str_split($data, 16) as $chunk){
        $hexColumn = $printColumn = [];
        foreach (str_split($chunk) as $byte){
            $hexColumn[] = sprintf('%02X', ord($byte));
            $printColumn[] = ctype_print($byte) ? $byte : '.';
        }
        $hex = implode(' ', $hexColumn);
        $print = implode('', $printColumn);
        $lines[] = sprintf('0x%08X | %-47s | %-16s', $offset, $hex, $print);
        $offset += strlen($chunk);
    }

    return PHP_EOL . implode(PHP_EOL, $lines) . PHP_EOL;
}

function findNextMarker($fp) : int{
    //Scan the file content for the next \xFF?? marker.
    //This scans one byte at a time which is terrible for
    //performance but easy.  Loading more data into memory
    //and using strpos would be better, but since you like
    //low-memory...
    do {
        $markerIndicator = fread($fp, 1);
        if ($markerIndicator === "\xFF"){
            $marker = fread($fp, 1);
            if ($marker !== "\x00"){
                return ord($marker);
            }
        }
    } while (!feof($fp));

    throw new \RuntimeException('Marker not found');
}

function parseIccData(string $iccBytes) : array{
    if (strlen($iccBytes) < 128){
        return [];
    }

    $createByteString = function(array $bytes){
        return implode('', array_map('chr', $bytes));
    };

    $headerBytes = substr($iccBytes, 0, 128);
    $fields = unpackStructFields($headerBytes, [
        'profileSize' => 'N',
        'cmmType' => ['c4', $createByteString],
        'versionAndSubversion' => ['c4', function(array $values){
            $majorVersion = $values[0];
            $minor = ($values[1] & 0xF0) >> 4;
            $patch = $values[1] & 0x0F;

            return sprintf('%d.%d.%d', $majorVersion, $minor, $patch);
        }],
        'class' => ['c4', $createByteString],
        'colorSpace' => ['c4', $createByteString],
        'PCS' => ['c4', $createByteString],
        'dateTime' => ['c12', function(array $values) use ($createByteString){
            return unpackStructFields($createByteString($values),
                array_fill_keys(['year', 'month', 'day', 'hour', 'minute', 'second'], 'n')
            );
        }],
        'acsp' => ['c4', $createByteString],
        'primaryPlatform' => ['c4', $createByteString],
        'profileFlags' => ['N', function(int $value){
            return [
                'embedded' => isBitSet($value, 0) ? 'Yes' : 'No',
                'independent' => isBitSet($value, 1) ? 'No' : 'Yes'
            ];
        }],
        'deviceManufacturer' => ['c4', $createByteString],
        'deviceModel' => ['c4', $createByteString],
        'deviceAttributes' => ['J', function(int $value){
            return [
                0 => isBitSet($value, 0) ? 'Transparency' : 'Reflective',
                1 => isBitSet($value, 1) ? 'Matte' : 'Glossy',
                2 => isBitSet($value, 2) ? 'negative' : 'positive',
                3 => isBitSet($value, 3) ? 'Black & white media' : 'Colour media',
            ];
        }],
        'renderingIntent' => ['N', function(int $value){
            return match ($value & 0xFF) {
                0 => 'Perceptual',
                1 => 'Media-relative colorimetric',
                2 => 'Saturation',
                3 => 'ICC-absolute colorimetric'
            };
        }],
        'nCIEXYZ' => ['c12', function(array $values) use ($createByteString){
            return parseXYZNumber($createByteString($values));
        }],
        'creatorSignature' => ['c4', $createByteString],
        'id' => ['c16', function(array $bytes){
            return implode('', array_map('dechex', $bytes));
        }]
    ]);

    return $fields;
}

function unpackStructFields(string $bytes, array $definition) : array{
    $formatList = [];
    $formatMethodList = [];
    foreach ($definition as $fieldName => $fieldDefinition){
        if (is_array($fieldDefinition)){
            $fieldFormat = $fieldDefinition[0];
            $formatMethodList[$fieldName] = $fieldDefinition[1];
        } else {
            $fieldFormat = $fieldDefinition;
        }

        $formatList[] = $fieldFormat . $fieldName;
    }

    $rawUnpackedData = unpack(implode('/', $formatList), $bytes);
    //Multibyte fields are returned a $fieldName1, $fieldName2, $fieldName3, ...
    //A better format would be an array, $fieldName=>[1,2,3].
    $fixedUnpackedData = [];
    foreach (array_keys($definition) as $fieldName){
        if (isset($rawUnpackedData[$fieldName])){
            $fixedUnpackedData[$fieldName] = $rawUnpackedData[$fieldName];
        } else {
            $counter = 1;
            do {
                $fixedUnpackedData[$fieldName][] = $rawUnpackedData[$fieldName . $counter++];
            } while (isset($rawUnpackedData[$fieldName . $counter]));
        }
    }

    foreach ($formatMethodList as $fieldName => $callback){
        $fixedUnpackedData[$fieldName] = call_user_func($callback, $fixedUnpackedData[$fieldName]);
    }

    return $fixedUnpackedData;
}

function parseXYZNumber(string $bytes) : array{
    $byteGroups = str_split($bytes, 4);

    return [
        'x' => parses15Fixed16Number($byteGroups[0]),
        'y' => parses15Fixed16Number($byteGroups[1]),
        'z' => parses15Fixed16Number($byteGroups[2]),
    ];
}

function parses15Fixed16Number(string $bytes){
    $parts = unpack('nw/nf', $bytes);

    $fraction = $parts['f'];
    $whole = $parts['w'];
    if ($whole >= 0x8000){
        if ($whole > 0x8000){
            $whole = ((~$whole) + 1) & 0x7fff;
        }
        $whole = -$whole;
    }
    $final = $whole + ($fraction / 0x10000);

    return $final;
}

function isBitSet($value, $bitNumber) : bool{
    $mask = pow(2, $bitNumber);

    return (bool)($value & $mask);
}