import hashlib
import math
import os
import os.path
import re
import struct
import subprocess
import sys

import pytest
from conftest import need_to_install_package_err
from elf_builder import build_elf
from elftools.elf.elffile import ELFFile

TEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "elf2image")

try:
    import esptool
    import esptool.targets
except ImportError:
    need_to_install_package_err()


def try_delete(path):
    try:
        os.remove(path)
    except OSError:
        pass


def segment_matches_section(segment, section):
    """segment is an ImageSegment from an esptool binary.
    section is an elftools ELF section

    Returns True if they match
    """
    sh_size = (section.header.sh_size + 0x3) & ~3  # pad length of ELF sections
    return section.header.sh_addr == segment.addr and sh_size == len(segment.data)


@pytest.mark.host_test
class BaseTestCase:
    @classmethod
    def setup_class(self):
        # Save the current working directory to be restored later
        self.stored_dir = os.getcwd()
        os.chdir(TEST_DIR)

    @classmethod
    def teardown_class(self):
        # Restore the stored working directory
        os.chdir(self.stored_dir)

    def assertEqualHex(self, expected, actual, message=None):
        try:
            expected = hex(expected)
        except TypeError:  # if expected is character
            expected = hex(ord(expected))
        try:
            actual = hex(actual)
        except TypeError:  # if actual is character
            actual = hex(ord(actual))
        assert expected == actual, message

    def assertImageDoesNotContainSection(self, image, elf, section_name):
        """
        Assert an esptool binary image object does not
        contain the data for a particular ELF section.
        """
        with open(elf, "rb") as f:
            e = ELFFile(f)
            section = e.get_section_by_name(section_name)
            assert section, f"{section_name} should be in the ELF"
            sh_addr = section.header.sh_addr
            data = section.data()
            # no section should start at the same address as the ELF section.
            for seg in sorted(image.segments, key=lambda s: s.addr):
                print(
                    f"comparing seg {seg.addr:#x} sec {sh_addr:#x} len {len(data):#x}"
                )
                assert seg.addr != sh_addr, (
                    f"{section_name} should not be in the binary image"
                )

    def assertImageContainsSection(self, image, elf, section_name):
        """
        Assert an esptool binary image object contains
        the data for a particular ELF section.
        """
        with open(elf, "rb") as f:
            e = ELFFile(f)
            section = e.get_section_by_name(section_name)
            assert section, f"{section_name} should be in the ELF"
            sh_addr = section.header.sh_addr
            data = section.data()
            # section contents may be smeared across multiple image segments,
            # so look through each segment and remove it from ELF section 'data'
            # as we find it in the image segments. When we're done 'data' should
            # all be accounted for
            for seg in sorted(image.segments, key=lambda s: s.addr):
                print(
                    f"comparing seg {seg.addr:#x} sec {sh_addr:#x} len {len(data):#x}"
                )
                if seg.addr == sh_addr:
                    overlap_len = min(len(seg.data), len(data))
                    assert data[:overlap_len] == seg.data[:overlap_len], (
                        f"ELF '{section_name}' section has mis-matching bin image data"
                    )
                    sh_addr += overlap_len
                    data = data[overlap_len:]

            # no bytes in 'data' should be left unmatched
            assert len(data) == 0, (
                f"ELF {elf} section '{section_name}' has no encompassing"
                f" segment(s) in bin image (image segments: {image.segments})"
            )

    def assertImageInfo(self, binpath, chip="esp8266", assert_sha=False):
        """
        Run esptool image-info on a binary file,
        assert no red flags about contents.
        """
        cmd = [sys.executable, "-m", "esptool", "--chip", chip, "image-info", binpath]
        try:
            output = subprocess.check_output(cmd)
            output = output.decode("utf-8")
            print(output)
        except subprocess.CalledProcessError as e:
            print(e.output)
            raise
        assert re.search(r"Checksum: 0x[a-fA-F0-9]{2} \(valid\)", output), (
            "Checksum calculation should be valid"
        )
        if assert_sha:
            assert re.search(r"Validation hash: [a-fA-F0-9]{64} \(valid\)", output), (
                "SHA256 should be valid"
            )
        assert "warning" not in output.lower(), (
            "Should be no warnings in image-info output"
        )

    def run_elf2image(
        self, chip, elf_path, version=None, extra_args=[], allow_warnings=False
    ):
        """Run elf2image on elf_path"""
        cmd = [sys.executable, "-m", "esptool", "--chip", chip, "elf2image"]
        if version is not None:
            cmd += ["--version", str(version)]
        cmd += [elf_path] + extra_args
        print("\nExecuting {}".format(" ".join(cmd)))
        try:
            output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
            output = output.decode("utf-8")
            print(output)
            if not allow_warnings:
                assert "warning" not in output.lower(), (
                    "elf2image should not output warnings"
                )
        except subprocess.CalledProcessError as e:
            print(e.output)
            raise

    @staticmethod
    def assertAllFF(some_bytes):
        """Assert that the given bytes are all 0xFF (erased flash state)"""
        assert b"\xff" * len(some_bytes) == some_bytes, (
            "Expected all 0xFF bytes, but found different values"
        )


class TestESP8266V1Image(BaseTestCase):
    ELF = "esp8266-nonosssdk20-iotdemo.elf"
    BIN_LOAD = "esp8266-nonosssdk20-iotdemo.elf-0x00000.bin"
    BIN_IROM = "esp8266-nonosssdk20-iotdemo.elf-0x10000.bin"

    @classmethod
    def setup_class(self):
        super().setup_class()
        self.run_elf2image(self, "esp8266", self.ELF, 1)

    @classmethod
    def teardown_class(self):
        try_delete(self.BIN_LOAD)
        try_delete(self.BIN_IROM)
        super().teardown_class()

    def test_irom_bin(self):
        with open(self.ELF, "rb") as f:
            e = ELFFile(f)
            irom_section = e.get_section_by_name(".irom0.text")
            assert irom_section.header.sh_size == os.stat(self.BIN_IROM).st_size, (
                "IROM raw binary file should be same length as .irom0.text section"
            )

    def test_loaded_sections(self):
        image = esptool.bin_image.LoadFirmwareImage("esp8266", self.BIN_LOAD)
        # Adjacent sections are now merged, len(image.segments) should
        # equal 2 (instead of 3).
        assert len(image.segments) == 2
        self.assertImageContainsSection(image, self.ELF, ".data")
        self.assertImageContainsSection(image, self.ELF, ".text")
        # Section .rodata is merged in the binary with the previous one,
        # so it won't be found in the binary image.
        self.assertImageDoesNotContainSection(image, self.ELF, ".rodata")


class TestESP8266V12SectionHeaderNotAtEnd(BaseTestCase):
    """Ref https://github.com/espressif/esptool/issues/197 -
    this ELF image has the section header not at the end of the file"""

    ELF = "esp8266-nonossdkv12-example.elf"
    BIN_LOAD = ELF + "-0x00000.bin"
    BIN_IROM = ELF + "-0x40000.bin"

    @classmethod
    def teardown_class(self):
        try_delete(self.BIN_LOAD)
        try_delete(self.BIN_IROM)

    def test_elf_section_header_not_at_end(self):
        self.run_elf2image("esp8266", self.ELF)
        image = esptool.bin_image.LoadFirmwareImage("esp8266", self.BIN_LOAD)
        assert len(image.segments) == 3
        self.assertImageContainsSection(image, self.ELF, ".data")
        self.assertImageContainsSection(image, self.ELF, ".text")
        self.assertImageContainsSection(image, self.ELF, ".rodata")


class TestESP8266V2Image(BaseTestCase):
    def _test_elf2image(self, elfpath, binpath, mergedsections=[]):
        try:
            self.run_elf2image("esp8266", elfpath, 2)
            image = esptool.bin_image.LoadFirmwareImage("esp8266", binpath)
            print("In test_elf2image", len(image.segments))
            assert 4 - len(mergedsections) == len(image.segments)
            sections = [".data", ".text", ".rodata"]
            # Remove the merged sections from the `sections` list
            sections = [sec for sec in sections if sec not in mergedsections]
            for sec in sections:
                self.assertImageContainsSection(image, elfpath, sec)
            for sec in mergedsections:
                self.assertImageDoesNotContainSection(image, elfpath, sec)

            irom_segment = image.segments[0]
            assert irom_segment.addr == 0, "IROM segment 'load address' should be zero"
            with open(elfpath, "rb") as f:
                e = ELFFile(f)
                sh_size = (
                    e.get_section_by_name(".irom0.text").header.sh_size + 15
                ) & ~15
                assert len(irom_segment.data) == sh_size, (
                    f"irom segment ({len(irom_segment.data):#x}) should be same size "
                    f"(16 padded) as .irom0.text section ({sh_size:#x})"
                )

            # check V2 CRC (for ESP8266 SDK bootloader)
            with open(binpath, "rb") as f:
                f.seek(-4, os.SEEK_END)
                image_len = f.tell()
                crc_stored = struct.unpack("<I", f.read(4))[0]
                f.seek(0)
                crc_calc = esptool.bin_image.esp8266_crc32(f.read(image_len))
                assert crc_stored == crc_calc

            # test imageinfo doesn't fail
            self.assertImageInfo(binpath)

        finally:
            try_delete(binpath)

    def test_nonossdkimage(self):
        ELF = "esp8266-nonossdkv20-at-v2.elf"
        BIN = "esp8266-nonossdkv20-at-v2-0x01000.bin"
        self._test_elf2image(ELF, BIN)

    def test_espopenrtosimage(self):
        ELF = "esp8266-openrtos-blink-v2.elf"
        BIN = "esp8266-openrtos-blink-v2-0x02000.bin"
        # .rodata section is merged with the previous one: .data
        self._test_elf2image(ELF, BIN, [".rodata"])


class TestESP32Image(BaseTestCase):
    def _test_elf2image(self, elfpath, binpath, extra_args=[]):
        try:
            self.run_elf2image("esp32", elfpath, extra_args=extra_args)
            image = esptool.bin_image.LoadFirmwareImage("esp32", binpath)
            self.assertImageInfo(
                binpath,
                "esp32",
                True if "--ram-only-header" not in extra_args else False,
            )
            return image
        finally:
            try_delete(binpath)

    def test_bootloader(self):
        ELF = "esp32-bootloader.elf"
        BIN = "esp32-bootloader.bin"
        image = self._test_elf2image(ELF, BIN)
        assert len(image.segments) == 3
        for section in [".iram1.text", ".iram_pool_1.text", ".dram0.rodata"]:
            self.assertImageContainsSection(image, ELF, section)

    def test_app_template(self):
        ELF = "esp32-app-template.elf"
        BIN = "esp32-app-template.bin"
        image = self._test_elf2image(ELF, BIN)
        # Adjacent sections are now merged, len(image.segments) should
        # equal 5 (instead of 6).
        assert len(image.segments) == 5
        # the other segment is a padding or merged segment
        for section in [
            ".iram0.vectors",
            ".dram0.data",
            ".flash.rodata",
            ".flash.text",
        ]:
            self.assertImageContainsSection(image, ELF, section)
        # check that merged sections are not in the binary image
        for mergedsection in [".iram0.text"]:
            self.assertImageDoesNotContainSection(image, ELF, mergedsection)

    def test_too_many_sections(self, capsys):
        ELF = "esp32-too-many-sections.elf"
        BIN = "esp32-too-many-sections.bin"
        with pytest.raises(subprocess.CalledProcessError):
            self._test_elf2image(ELF, BIN)
        output = capsys.readouterr().out
        assert "max 16" in output
        assert "linker script" in output

    def test_use_segments(self):
        ELF = "esp32-zephyr.elf"
        BIN = "esp32-zephyr.bin"
        # default behaviour uses ELF sections,
        # this ELF will produce 8 segments in the bin
        image = self._test_elf2image(ELF, BIN)
        # Adjacent sections are now merged, len(image.segments) should
        # equal 4 (instead of 8).
        assert len(image.segments) == 4

        # --use-segments uses ELF segments(phdrs), produces just 2 segments in the bin
        image = self._test_elf2image(ELF, BIN, ["--use-segments"])
        assert len(image.segments) == 2

    def test_ram_only_header(self):
        ELF = "esp32-app-template.elf"
        BIN = "esp32-app-template.bin"
        # --ram-only-header produces just 2 visible segments in the bin
        image = self._test_elf2image(ELF, BIN, ["--ram-only-header"])
        assert len(image.segments) == 2

    @pytest.fixture(scope="class")
    def reference_bin(self):
        BASE_NAME = "esp32-bootloader"
        BIN = f"{BASE_NAME}-reference.bin"
        self.run_elf2image("esp32", f"{BASE_NAME}.elf")
        os.rename(f"{BASE_NAME}.bin", BIN)
        yield BIN
        # Cleanup the reference binary after the test
        try_delete(BIN)

    def test_pad_to_size(self, reference_bin: str):
        """Test that --pad-to-size correctly pads output binary to specified size"""
        ELF = "esp32-bootloader.elf"
        BIN = "esp32-bootloader.bin"

        try:
            # Generate the padded binary with 1MB size
            self.run_elf2image("esp32", ELF, extra_args=["--pad-to-size", "1MB"])

            # Get the size of the reference binary
            normal_size = os.path.getsize(reference_bin)

            # Check that the padded binary is exactly 1MB
            padded_size = os.path.getsize(BIN)
            expected_size = 0x100000  # 1MB in bytes
            assert padded_size == expected_size, (
                f"Expected {expected_size} bytes (1MB), got {padded_size} bytes"
            )

            # Check that the original content is preserved at the beginning
            with open(reference_bin, "rb") as f:
                original_content = f.read()
            with open(BIN, "rb") as f:
                padded_content = f.read()

            assert padded_content[:normal_size] == original_content, (
                "Original content should be preserved at the beginning of padded file"
            )

            # Check that the padding is filled with 0xFF bytes (erased flash state)
            padding = padded_content[normal_size:]
            expected_padding_size = expected_size - normal_size
            assert len(padding) == expected_padding_size, (
                f"Padding should be {expected_padding_size} bytes, got {len(padding)}"
            )
            self.assertAllFF(padding)

        finally:
            try_delete(BIN)

    @pytest.mark.parametrize("size", ["512KB", "2MB", "4MB"])
    def test_pad_to_size_different_sizes(self, size: str, reference_bin: str):
        """Test --pad-to-size with different size values"""
        ELF = "esp32-bootloader.elf"
        BIN = "esp32-bootloader.bin"

        expected_bytes = esptool.util.flash_size_bytes(size)
        try:
            # Generate the padded binary
            self.run_elf2image("esp32", ELF, extra_args=["--pad-to-size", size])

            # Check the file size
            padded_size = os.path.getsize(BIN)
            assert padded_size == expected_bytes, (
                f"Expected {expected_bytes} bytes for {size}, got {padded_size}"
            )

            # Check that padding is 0xFF
            with open(BIN, "rb") as f:
                padded_content = f.read()

            # Get original size from the normal binary and check that padding
            normal_size = os.path.getsize(reference_bin)
            self.assertAllFF(padded_content[normal_size:])

        finally:
            try_delete(BIN)


class TestESP8266FlashHeader(BaseTestCase):
    def test_2mb(self):
        ELF = "esp8266-nonossdkv20-at-v2.elf"
        BIN = "esp8266-nonossdkv20-at-v2-0x01000.bin"
        try:
            self.run_elf2image(
                "esp8266",
                ELF,
                version=2,
                extra_args=["--flash-size", "2MB", "--flash-mode", "dio"],
            )
            with open(BIN, "rb") as f:
                header = f.read(4)
                print(f"header {header}")
                self.assertEqualHex(0xEA, header[0])
                self.assertEqualHex(0x02, header[2])
                self.assertEqualHex(0x30, header[3])
        finally:
            try_delete(BIN)


class TestESP32FlashHeader(BaseTestCase):
    def test_16mb(self):
        ELF = "esp32-app-template.elf"
        BIN = "esp32-app-template.bin"
        try:
            self.run_elf2image(
                "esp32",
                ELF,
                extra_args=[
                    "--flash-size",
                    "16MB",
                    "--flash-mode",
                    "dio",
                    "--min-rev",
                    "1",
                ],
            )
            with open(BIN, "rb") as f:
                header = f.read(24)
                self.assertEqualHex(0xE9, header[0])
                self.assertEqualHex(0x02, header[2])
                self.assertEqualHex(0x40, header[3])
                self.assertEqualHex(0x01, header[14])  # chip revision
        finally:
            try_delete(BIN)


class TestELFSHA256(BaseTestCase):
    ELF = "esp32c6-appdesc.elf"
    SHA_OFFS = 0xB0  # absolute offset of the SHA in the .bin file
    BIN = "esp32c6-appdesc.bin"

    """
    esp32c6-appdesc.elf was built with the following application version info:

    __attribute__((section(".flash.appdesc")))
    esp_app_desc_t my_app_desc = {
        .magic_word = 0xABCD5432,
        .secure_version = 0xffffffff,
        .reserv1 = {0xffffffff, 0xffffffff},
        .version = "_______________________________",
        .project_name = "-------------------------------",
        .time = "xxxxxxxxxxxxxxx",
        .date = "yyyyyyyyyyyyyyy",
        .idf_ver = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
        .app_elf_sha256 = {
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
        },
        .min_efuse_blk_rev_full = 0xffff,
        .max_efuse_blk_rev_full = 0xffff,
        .mmu_page_size = 0,
        .reserv3 = {0xff, 0xff, 0xff},
        .reserv2 = {
            0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff,
            0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff,
            0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff,
            0xffffffff, 0xffffffff, 0xffffffff
        },
    };

    This leaves zeroes only for the fields of SHA-256 and the test will fail
    if the placement of zeroes are tested at the wrong place.

    00000000: e901 0000 2000 0042 ee00 0000 0d00 0000  .... ..B........
    00000010: 00ff ff00 0000 0001 2000 0042 0001 0000  ........ ..B....
    00000020: 3254 cdab ffff ffff ffff ffff ffff ffff  2T..............
    00000030: 5f5f 5f5f 5f5f 5f5f 5f5f 5f5f 5f5f 5f5f  ________________
    00000040: 5f5f 5f5f 5f5f 5f5f 5f5f 5f5f 5f5f 5f00  _______________.
    00000050: 2d2d 2d2d 2d2d 2d2d 2d2d 2d2d 2d2d 2d2d  ----------------
    00000060: 2d2d 2d2d 2d2d 2d2d 2d2d 2d2d 2d2d 2d00  ---------------.
    00000070: 7878 7878 7878 7878 7878 7878 7878 7800  xxxxxxxxxxxxxxx.
    00000080: 7979 7979 7979 7979 7979 7979 7979 7900  yyyyyyyyyyyyyyy.
    00000090: 7a7a 7a7a 7a7a 7a7a 7a7a 7a7a 7a7a 7a7a  zzzzzzzzzzzzzzzz
    000000a0: 7a7a 7a7a 7a7a 7a7a 7a7a 7a7a 7a7a 7a00  zzzzzzzzzzzzzzz.
    000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................    SHA-256 here
    000000c0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    000000d0: ffff ffff 00ff ffff ffff ffff ffff ffff  ................
    000000e0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
    000000f0: ffff ffff ffff ffff ffff ffff ffff ffff  ................
    00000100: ffff ffff ffff ffff ffff ffff ffff ffff  ................
    00000110: ffff ffff ffff ffff ffff ffff ffff ffff  ................

    """

    def verify_sha256(self, elf_path, bin_path):
        image = esptool.bin_image.LoadFirmwareImage("esp32c6", bin_path)
        rodata_segment = image.segments[0]
        bin_sha256 = rodata_segment.data[
            self.SHA_OFFS - 0x20 : self.SHA_OFFS - 0x20 + 32
        ]  # subtract 0x20 byte header here

        with open(elf_path, "rb") as f:
            elf_computed_sha256 = hashlib.sha256(f.read()).digest()

        with open(bin_path, "rb") as f:
            f.seek(self.SHA_OFFS)
            bin_sha256_raw = f.read(len(elf_computed_sha256))

        assert elf_computed_sha256 == bin_sha256
        assert elf_computed_sha256 == bin_sha256_raw

    def test_binary_patched_parameter(self):
        try:
            self.run_elf2image(
                "esp32c6",
                self.ELF,
                extra_args=["--elf-sha256-offset", f"{self.SHA_OFFS:#x}"],
            )
            self.verify_sha256(self.ELF, self.BIN)
        finally:
            try_delete(self.BIN)

    def test_binary_patched(self):
        try:
            self.run_elf2image("esp32c6", self.ELF)
            self.verify_sha256(self.ELF, self.BIN)
        finally:
            try_delete(self.BIN)

    def test_no_overwrite_data(self, capsys):
        with pytest.raises(subprocess.CalledProcessError):
            self.run_elf2image(
                "esp32",
                "esp32-bootloader.elf",
                extra_args=["--elf-sha256-offset", "0xb0"],
            )
        output = capsys.readouterr().out
        assert "SHA256" in output
        assert "zero" in output


class TestHashAppend(BaseTestCase):
    ELF = "esp32-bootloader.elf"
    BIN = "esp32-bootloader.bin"

    # 15th byte of the extended header after the 8-byte image header
    HASH_APPEND_OFFSET = 15 + 8

    @classmethod
    def teardown_class(self):
        try_delete(self.BIN)

    def test_hash_append(self):
        self.run_elf2image(
            "esp32",
            self.ELF,
            extra_args=["-o", self.BIN],
        )
        with open(self.BIN, "rb") as f:
            bin_with_hash = f.read()

        assert bin_with_hash[self.HASH_APPEND_OFFSET] == 1

        # drop the last 32 bytes (SHA256 digest)
        expected_bin_without_hash = bytearray(bin_with_hash[:-32])
        # disable the hash append byte in the file header
        expected_bin_without_hash[self.HASH_APPEND_OFFSET] = 0

        try_delete(self.BIN)
        self.run_elf2image(
            "esp32",
            self.ELF,
            extra_args=["--dont-append-digest", "-o", self.BIN],
        )

        with open(self.BIN, "rb") as f:
            bin_without_hash = f.read()

        assert bin_without_hash[self.HASH_APPEND_OFFSET] == 0
        assert bytes(expected_bin_without_hash) == bin_without_hash


class TestELFSectionHandling(BaseTestCase):
    """Test ELF section type handling and related functionality."""

    @staticmethod
    def _modify_section_type(elf_path, section_name, new_type):
        """
        Modify the type of a specific section in the ELF file.
        """
        with open(elf_path, "rb+") as f:
            elf = ELFFile(f)
            section = elf.get_section_by_name(section_name)

            index = elf.get_section_index(section_name)
            # Calculate the section header's position in the file (using section index,
            # the section header table's offset and section header entry size)
            sh_entry_offset = elf.header["e_shoff"] + index * elf.header["e_shentsize"]

            # Modify the section type in the header
            section.header.sh_type = new_type

            f.seek(sh_entry_offset)
            f.write(elf.structs.Elf_Shdr.build(section.header))

    @staticmethod
    def _get_section_type(elf_path, section_name):
        """
        Get the current type of a specific section in the ELF file.
        """
        with open(elf_path, "rb") as f:
            elf = ELFFile(f)
            section = elf.get_section_by_name(section_name)
            return section.header.sh_type

    def test_unknown_section_type_warning(self, capsys):
        """Test that unknown section types generate the expected warning message."""
        ELF = "esp32c6-appdesc.elf"
        BIN = "esp32c6-appdesc.bin"
        SECTION_NAME = ".flash.appdesc"
        UNKNOWN_TYPE = 0x99

        original_sec_type = self._get_section_type(ELF, SECTION_NAME)

        # Modify the section to have an unknown type
        self._modify_section_type(ELF, SECTION_NAME, UNKNOWN_TYPE)

        # Verify the section was actually modified
        modified_type = self._get_section_type(ELF, SECTION_NAME)
        assert modified_type == UNKNOWN_TYPE

        try:
            self.run_elf2image("esp32c6", ELF, allow_warnings=True)
            output = capsys.readouterr().out
            print(output)

            expected_warning = f"Unknown section type {UNKNOWN_TYPE:#04x} in ELF file"
            assert expected_warning in output

        finally:
            self._modify_section_type(ELF, SECTION_NAME, original_sec_type)
            try_delete(BIN)


class TestMMUPageSize(BaseTestCase):
    def test_appdesc_aligned(self, capsys):
        ELF = "esp32c6-appdesc.elf"
        BIN = "esp32c6-appdesc.bin"
        try:
            self.run_elf2image("esp32c6", ELF)
            output = capsys.readouterr().out
            print(output)
            assert "MMU page size not specified, set to 64 KB" in output
        finally:
            try_delete(BIN)

    @staticmethod
    def _modify_section_address(elf_path, section_name, address_offset):
        with open(elf_path, "rb+") as f:
            elf = ELFFile(f)
            section = elf.get_section_by_name(section_name)

            if not section:
                raise ValueError(f"Section {section_name} not found")

            # This finds the index of the specified section in the ELF file,
            # compute the section header’s position in the file (using section index,
            # the section header table’s offset and section header entry size) and then
            # modify the section’s address in memory by the specified offset.
            index = elf.get_section_index(section_name)
            sh_entry_offset = elf.header["e_shoff"] + index * elf.header["e_shentsize"]
            section.header.sh_addr += address_offset

            # Write modified header to file
            f.seek(sh_entry_offset)
            f.write(elf.structs.Elf_Shdr.build(section.header))

    def test_appdesc_not_aligned(self, capsys):
        ELF = "esp32c6-appdesc.elf"
        BIN = "esp32c6-appdesc.bin"
        ADDRESS_OFFSET = 4  # 4 bytes is minimum allowed

        self._modify_section_address(ELF, ".flash.appdesc", ADDRESS_OFFSET)
        try:
            self.run_elf2image("esp32c6", ELF, allow_warnings=True)
            output = capsys.readouterr().out
            print(output)
            assert (
                "App description segment is not aligned to MMU page size, probably "
                "linker script issue or wrong MMU page size. "
                "Try to set MMU page size parameter manually." in output
            )
        finally:
            # Restore original address to be able to run other tests
            self._modify_section_address(ELF, ".flash.appdesc", -ADDRESS_OFFSET)
            try_delete(BIN)

    @staticmethod
    def _change_appdesc_mmu_page_size(elf_path, mmu_page_size):
        """
        Change the MMU page size in the appdesc section of the ELF file.
        The following values can be chosen: 0 (empty), 8192, 16384, 32768, 65536.
        The numbers are not valid for all chips, so refer to the documentation
        of the chip being used.
        """
        with open(elf_path, "rb+") as f:
            elf = ELFFile(f)
            section = elf.get_section_by_name(".flash.appdesc")

            if not section:
                raise ValueError("Section .flash.appdesc not found")

            # The mmu_page_size is a power of 2, so we convert it to the corresponding
            # value for the appdesc section. It can also be empty (0).
            if mmu_page_size == 0:
                mmu_page_size_appdesc = 0
            else:
                mmu_page_size_appdesc = int(math.log2(mmu_page_size))

            # Go to the mmu_page_size field in the appdesc section (at offset 0xB4) and
            # modify it
            f.seek(section.header.sh_offset + 0xB4)
            f.write(mmu_page_size_appdesc.to_bytes(4, byteorder="little"))

    def test_appdesc_data(self, capsys):
        ELF = "esp32c6-appdesc.elf"
        BIN = "esp32c6-appdesc.bin"
        MMU_PAGE_SIZE = 65536

        self._change_appdesc_mmu_page_size(ELF, MMU_PAGE_SIZE)
        try:
            self.run_elf2image("esp32c6", ELF)
            output = capsys.readouterr().out
            assert "MMU page size" not in output
            print(output)
        finally:
            self._change_appdesc_mmu_page_size(ELF, 0)
            try_delete(BIN)


# One chip per (BOOTLOADER_FLASH_OFFSET, arch) equivalence group, since that
# pair drives the pad-length math in ESP32FirmwareImage.save(). esp32 is also
# kept for the <0x24 tail-pad workaround in save_flash_segment(), which is
# gated on CHIP_NAME == "ESP32". The arch picks the ELF e_machine value.
RAM_ONLY_HEADER_ARCH = {
    "esp32": "xtensa",
    "esp32s3": "xtensa",
    "esp32c3": "riscv",
    "esp32c5": "riscv",
}


def dram_addr(chip):
    cls = esptool.targets.CHIP_DEFS[chip]
    for start, _end, label in cls.MEMORY_MAP:
        if label == "DRAM":
            return start
    raise RuntimeError(f"No DRAM region in {chip} MEMORY_MAP")


def ram_only_header_layouts(chip):
    cls = esptool.targets.CHIP_DEFS[chip]
    irom = cls.IROM_MAP_START
    dram = dram_addr(chip)
    return {
        "single_aligned": [
            (".dram0.data", dram, 0x100, "rw"),
            (".flash.text", irom + 0x20, 0x2000, "rx"),
        ],
        # 0xFF8 / 0xFFF8 sizes land the file cursor 8 bytes short of an
        # IROM_ALIGN boundary, which used to trip the "already aligned"
        # short-circuit in get_alignment_data_needed() and produce an
        # off-by-8 pad. See fix in ESP32FirmwareImage.save().
        "single_off_by_8": [
            (".dram0.data", dram, 0x100, "rw"),
            (".flash.text", irom + 0x20, 0xFF8, "rx"),
        ],
        "multi_off_by_8": [
            (".dram0.data", dram, 0x40, "rw"),
            (".flash.text", irom, 0xFF8, "rx"),
            (".flash.rodata", irom + 0x10000, 0xFFF8, "ro"),
        ],
        "multi_far_apart": [
            (".dram0.data", dram, 0x80, "rw"),
            (".flash.text", irom + 0x20, 0x800, "rx"),
            (".flash.rodata", irom + 0x40000 + 0x20, 0x800, "ro"),
        ],
        "multi_three_segs": [
            (".dram0.data", dram, 0x100, "rw"),
            (".flash.text", irom + 0x20, 0x1234, "rx"),
            (".flash.rodata", irom + 0x10000 + 0x20, 0x5678, "ro"),
            (".flash.text2", irom + 0x20000 + 0x20, 0x100, "rx"),
        ],
        "large_ram_segment": [
            (".dram0.data", dram, 0x4000, "rw"),
            (".flash.text", irom + 0x20, 0xFFF8, "rx"),
            (".flash.rodata", irom + 0x10000 + 0x20, 0xFFF8, "ro"),
        ],
    }


class TestRamOnlyHeader(BaseTestCase):
    """Cover the `--ram-only-header` codepath across chips with different
    BOOTLOADER_FLASH_OFFSET values and across flash-segment size combinations
    that historically broke the pad-length math."""

    @pytest.mark.parametrize("chip", sorted(RAM_ONLY_HEADER_ARCH.keys()))
    @pytest.mark.parametrize(
        "scenario",
        [
            "single_aligned",
            "single_off_by_8",
            "multi_off_by_8",
            "multi_far_apart",
            "multi_three_segs",
            "large_ram_segment",
        ],
    )
    def test_layout(self, chip, scenario):
        sections = ram_only_header_layouts(chip)[scenario]
        dram = dram_addr(chip)
        elf = f"{chip}-{scenario}.elf"
        binp = f"{chip}-{scenario}.bin"

        try:
            build_elf(elf, RAM_ONLY_HEADER_ARCH[chip], sections)
            self.run_elf2image(chip, elf, extra_args=["--ram-only-header"])
            image = esptool.bin_image.LoadFirmwareImage(chip, binp)
            ram_sections = [s for s in sections if s[1] == dram]
            assert len(image.segments) == len(ram_sections)
            for seg in image.segments:
                assert seg.addr == dram
            self.assertImageInfo(binp, chip)
        finally:
            try_delete(elf)
            try_delete(binp)

    @pytest.mark.parametrize("chip", sorted(RAM_ONLY_HEADER_ARCH.keys()))
    def test_no_sha256(self, chip):
        sections = ram_only_header_layouts(chip)["multi_three_segs"]
        elf = f"{chip}-nosha.elf"
        binp = f"{chip}-nosha.bin"

        try:
            build_elf(elf, RAM_ONLY_HEADER_ARCH[chip], sections)
            self.run_elf2image(chip, elf, extra_args=["--ram-only-header"])
            image = esptool.bin_image.LoadFirmwareImage(chip, binp)
            assert image.append_digest is False, (
                "Ram-only image must declare append_digest=0 in extended header"
            )
        finally:
            try_delete(elf)
            try_delete(binp)

    @pytest.mark.parametrize("chip", sorted(RAM_ONLY_HEADER_ARCH.keys()))
    def test_segment_count(self, chip):
        sections = ram_only_header_layouts(chip)["multi_three_segs"]
        dram = dram_addr(chip)
        elf = f"{chip}-segcount.elf"
        binp = f"{chip}-segcount.bin"

        try:
            build_elf(elf, RAM_ONLY_HEADER_ARCH[chip], sections)
            self.run_elf2image(chip, elf, extra_args=["--ram-only-header"])
            with open(binp, "rb") as f:
                header = f.read(2)
            assert header[0] == esptool.ESPLoader.ESP_IMAGE_MAGIC
            ram_section_count = sum(1 for s in sections if s[1] == dram)
            assert header[1] == ram_section_count, (
                f"Header segment count = {header[1]}, "
                f"expected {ram_section_count} (RAM segments only)"
            )
        finally:
            try_delete(elf)
            try_delete(binp)

    @pytest.mark.parametrize("chip", sorted(RAM_ONLY_HEADER_ARCH.keys()))
    @pytest.mark.parametrize(
        "scenario",
        [
            "single_off_by_8",
            "multi_off_by_8",
            "multi_far_apart",
            "multi_three_segs",
            "large_ram_segment",
        ],
    )
    def test_flash_segments_present(self, chip, scenario):
        """Walk the image byte-by-byte and verify every flash segment lands
        page-aligned with its ELF payload intact. LoadFirmwareImage only
        surfaces RAM segments in ram-only mode, so we parse the raw bytes."""
        sections = ram_only_header_layouts(chip)[scenario]
        cls = esptool.targets.CHIP_DEFS[chip]
        dram = dram_addr(chip)
        irom_align = esptool.bin_image.ESP32FirmwareImage.IROM_ALIGN
        bootloader_offset = cls.BOOTLOADER_FLASH_OFFSET
        elf = f"{chip}-flashpresent-{scenario}.elf"
        binp = f"{chip}-flashpresent-{scenario}.bin"

        try:
            build_elf(elf, RAM_ONLY_HEADER_ARCH[chip], sections)
            self.run_elf2image(chip, elf, extra_args=["--ram-only-header"])
            with open(binp, "rb") as f:
                data = f.read()

            indexed = list(enumerate(sections))
            input_flash = sorted(
                [(i, s) for i, s in indexed if s[1] != dram], key=lambda x: x[1][1]
            )
            input_flash.reverse()  # save() reverses flash_segments

            pos = self._skip_image_and_extended_header()
            ram_count = sum(1 for s in sections if s[1] == dram)
            pos = self._skip_ram_segments(data, pos, ram_count, dram)
            pos = self._skip_ram_checksum(pos)

            for orig_idx, (name, vaddr, size, _flags) in input_flash:
                pos = self._verify_pad_segment(data, pos)
                pos = self._verify_flash_segment(
                    data,
                    pos,
                    name,
                    vaddr,
                    size,
                    orig_idx,
                    irom_align,
                    bootloader_offset,
                )
        finally:
            try_delete(elf)
            try_delete(binp)

    @staticmethod
    def _skip_image_and_extended_header():
        """Image header (8 bytes) + extended header (16 bytes)."""
        return 8 + 16

    @staticmethod
    def _skip_ram_segments(data, pos, ram_count, dram):
        for _ in range(ram_count):
            addr, size = struct.unpack("<II", data[pos : pos + 8])
            assert addr == dram, f"Expected RAM segment at {dram:#x}, got {addr:#x}"
            pos += 8 + size
        return pos

    @staticmethod
    def _skip_ram_checksum(pos):
        """append_checksum pads to a 16-byte boundary, writes one checksum
        byte, then aligns to 16 again before flash segments begin."""
        pos = (pos + 15) & ~15
        pos += 1
        pos = (pos + 15) & ~15
        return pos

    @staticmethod
    def _verify_pad_segment(data, pos):
        pad_addr, pad_size = struct.unpack("<II", data[pos : pos + 8])
        assert pad_addr == 0, f"Expected pad segment, got addr={pad_addr:#x}"
        return pos + 8 + pad_size

    @staticmethod
    def _verify_flash_segment(
        data,
        pos,
        name,
        vaddr,
        size,
        orig_idx,
        irom_align,
        bootloader_offset,
    ):
        flash_addr, flash_size = struct.unpack("<II", data[pos : pos + 8])
        assert flash_addr == vaddr, (
            f"Flash segment addr {flash_addr:#x} != ELF section {name} at {vaddr:#x}"
        )
        data_offset = pos + 8
        actual_align = (data_offset + bootloader_offset) % irom_align
        expected_align = vaddr % irom_align
        assert actual_align == expected_align, (
            f"Flash segment {name} not page-aligned: "
            f"data_offset={data_offset:#x} (with bootloader offset "
            f"{bootloader_offset:#x}), vaddr={vaddr:#x}"
        )
        expected_data = bytes((vaddr + orig_idx + j) & 0xFF for j in range(size))
        assert data[pos + 8 : pos + 8 + size] == expected_data, (
            f"Flash segment {name} data mismatch"
        )
        return pos + 8 + flash_size
