#!/usr/bin/env python3
"""Focused regression tests for the deheader command-line tool."""

import importlib.machinery
import importlib.util
import os
import pathlib
import stat
import subprocess
import sys
import tempfile
import unittest


sys.dont_write_bytecode = True

PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
DEHEADER = PROJECT_ROOT / "deheader"


def load_deheader_module():
    """Load the extensionless executable without running its CLI."""
    loader = importlib.machinery.SourceFileLoader("deheader_module", str(DEHEADER))
    spec = importlib.util.spec_from_loader(loader.name, loader)
    module = importlib.util.module_from_spec(spec)
    loader.exec_module(module)
    return module


DEHEADER_MODULE = load_deheader_module()


def run_deheader(*arguments):
    """Run the CLI and capture its output without imposing an expected status."""
    return subprocess.run(
        [sys.executable, str(DEHEADER), *arguments],
        cwd=PROJECT_ROOT,
        check=False,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )


class SaveForModificationTests(unittest.TestCase):
    """Exercise rollback and commit behavior without invoking a compiler."""

    ORIGINAL = b"#include <unused.h>\r\nint answer = 42;\r\n"
    MODIFIED = b"int answer = 42;\r\n"
    INCLUDE = "#include <unused.h>\r\n"

    def setUp(self):
        self.tempdir = tempfile.TemporaryDirectory()
        self.directory = pathlib.Path(self.tempdir.name)

    def tearDown(self):
        self.tempdir.cleanup()

    def make_source(self, name="sample.c"):
        source = self.directory / name
        source.write_bytes(self.ORIGINAL)
        source.chmod(0o640)
        timestamp = 1_600_000_000_123_456_789
        os.utime(source, ns=(timestamp, timestamp))
        return source

    def test_rollback_restores_contents_timestamp_and_inode(self):
        source = self.make_source()
        original_stat = source.stat()

        with DEHEADER_MODULE.SaveForModification(source) as saved:
            saved.remove_headers([self.INCLUDE])
            self.assertEqual(source.read_bytes(), self.MODIFIED)
            self.assertEqual(source.stat().st_ino, original_stat.st_ino)
            self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o640)

        restored_stat = source.stat()
        self.assertEqual(source.read_bytes(), self.ORIGINAL)
        self.assertEqual(restored_stat.st_ino, original_stat.st_ino)
        self.assertEqual(restored_stat.st_mtime_ns, original_stat.st_mtime_ns)
        self.assertEqual(stat.S_IMODE(restored_stat.st_mode), 0o640)

    def test_commit_preserves_symlink_hardlink_and_mode(self):
        source = self.make_source()
        hardlink = self.directory / "hardlink.c"
        symlink = self.directory / "symlink.c"
        os.link(source, hardlink)
        symlink.symlink_to(source.name)
        original_inode = source.stat().st_ino

        with DEHEADER_MODULE.SaveForModification(symlink) as saved:
            saved.remove_headers([self.INCLUDE])
            saved.forget()

        self.assertTrue(symlink.is_symlink())
        self.assertEqual(source.read_bytes(), self.MODIFIED)
        self.assertEqual(hardlink.read_bytes(), self.MODIFIED)
        self.assertEqual(source.stat().st_ino, original_inode)
        self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o640)

    def test_exception_rolls_back_and_does_not_touch_legacy_backup(self):
        source = self.make_source()
        legacy_backup = pathlib.Path(str(source) + "-orig")
        legacy_backup.write_text("keep me", encoding="ascii")

        with self.assertRaisesRegex(RuntimeError, "probe"):
            with DEHEADER_MODULE.SaveForModification(source) as saved:
                saved.remove_headers([self.INCLUDE])
                raise RuntimeError("probe")

        self.assertEqual(source.read_bytes(), self.ORIGINAL)
        self.assertEqual(legacy_backup.read_text(encoding="ascii"), "keep me")

    def test_dependency_scan_retains_crlf_for_exact_removal(self):
        source = self.make_source()
        inclusion_map = DEHEADER_MODULE.InclusionMap([str(source)], None, None, 0)
        self.assertEqual(inclusion_map.depends_on[str(source)], [self.INCLUDE])


class BuildExecutionTests(unittest.TestCase):
    """Check process state around build-command execution."""

    def test_build_exception_restores_working_directory(self):
        original_runner = DEHEADER_MODULE.getstatusoutput
        original_directory = os.getcwd()

        def raise_probe(_command):
            raise RuntimeError("probe")

        with tempfile.TemporaryDirectory() as directory:
            DEHEADER_MODULE.builddir = "."
            DEHEADER_MODULE.getstatusoutput = raise_probe
            try:
                with self.assertRaisesRegex(RuntimeError, "probe"):
                    DEHEADER_MODULE.testcompile(
                        "sample.c", "make %s", subdir=directory
                    )
                self.assertEqual(os.getcwd(), original_directory)
            finally:
                DEHEADER_MODULE.getstatusoutput = original_runner
                os.chdir(original_directory)

    def test_target_substitution_is_shell_quoted(self):
        command = DEHEADER_MODULE.build_command(
            "make -- %s", "objects/a file;name.o"
        )
        self.assertEqual(command, "make -- 'objects/a file;name.o'")

    def test_targetless_build_command_is_unchanged(self):
        command = "ninja -C build"
        self.assertEqual(DEHEADER_MODULE.build_command(command, "unused.o"), command)


class PortabilityRequirementsTests(unittest.TestCase):
    """Keep the portability database normalized and internally consistent."""

    def test_requirement_triggers_are_unique(self):
        triggers = [trigger for trigger, _headers in DEHEADER_MODULE.requirements]
        duplicates = sorted(
            trigger for trigger in set(triggers) if triggers.count(trigger) > 1
        )
        self.assertEqual(duplicates, [])

    def test_header_references_are_normalized(self):
        malformed = []
        for trigger, headers in DEHEADER_MODULE.requirements:
            for header in headers:
                if not (header.startswith("<") and header.endswith(">")):
                    malformed.append((trigger, header))
        self.assertEqual(malformed, [])

    def test_corrected_function_header_mappings(self):
        requirements = dict(DEHEADER_MODULE.requirements)
        expected = {
            "div()": ["<stdlib.h>"],
            "fputwc()": ["<stdio.h>", "<wchar.h>"],
            "fputws()": ["<stdio.h>", "<wchar.h>"],
            "getgrnam()": ["<sys/types.h>", "<grp.h>"],
            "getgrnam_r()": ["<sys/types.h>", "<grp.h>"],
            "mbrlen()": ["<wchar.h>"],
            "mbstowcs()": ["<stdlib.h>"],
            "mbtowc()": ["<stdlib.h>"],
            "realloc()": ["<stdlib.h>"],
            "swab()": ["<unistd.h>"],
            "symlink()": ["<unistd.h>"],
            "sync()": ["<unistd.h>"],
            "tdelete()": ["<search.h>"],
            "tfind()": ["<search.h>"],
            "times()": ["<sys/times.h>"],
            "tsearch()": ["<search.h>"],
            "truncate()": ["<unistd.h>"],
            "twalk()": ["<search.h>"],
            "wctype()": ["<wctype.h>"],
        }
        for trigger, headers in expected.items():
            with self.subTest(trigger=trigger):
                self.assertEqual(requirements[trigger], headers)

        self.assertNotIn("mblren()", requirements)
        self.assertNotIn("mbtowcs()", requirements)


class ScannerTests(unittest.TestCase):
    """Cover preprocessing boundaries and lexical false positives."""

    def test_indented_include_directive_is_recognized(self):
        line = " \t# include <example.h>\n"
        self.assertEqual(
            DEHEADER_MODULE.match_preproc("include", line), "<example.h>"
        )
        self.assertEqual(DEHEADER_MODULE.trim(line), "<example.h>")

    def test_include_next_is_not_misclassified_as_include(self):
        line = "#include_next <example.h>\n"
        self.assertIs(DEHEADER_MODULE.match_preproc("include", line), False)

    def test_angle_and_quote_headers_have_the_same_bare_name(self):
        angle = DEHEADER_MODULE.supertrim("#include <example.h> // system\n")
        quote = DEHEADER_MODULE.supertrim('#include "example.h" // local\n')
        self.assertEqual(angle, "example.h")
        self.assertEqual(angle, quote)

    def test_scanner_ignores_comments_and_literals_but_matches_at_bol(self):
        source_text = (
            "/* abort();\n"
            "#include <comment-only.h>\n"
            "*/\n"
            "  # include <stdlib.h>\n"
            'const char *text = "div();";\n'
            "abort();\n"
        )
        with tempfile.TemporaryDirectory() as directory:
            source = pathlib.Path(directory) / "scanner.c"
            source.write_bytes(source_text.encode("ascii"))
            inclusion_map = DEHEADER_MODULE.InclusionMap(
                [str(source)], None, None, 0
            )

        self.assertEqual(
            inclusion_map.depends_on[str(source)], ["  # include <stdlib.h>\n"]
        )
        self.assertEqual(
            inclusion_map.requires[str(source)], [(["<stdlib.h>"], "abort()")]
        )


class InstallationTests(unittest.TestCase):
    """Check staged install commands without changing the filesystem."""

    def test_install_builds_and_installs_manpage_with_data_mode(self):
        result = subprocess.run(
            [
                "make",
                "-n",
                "-B",
                "install",
                "DESTDIR=/tmp/deheader-install-test",
                "PREFIX=/usr",
            ],
            cwd=PROJECT_ROOT,
            check=False,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        self.assertEqual(result.returncode, 0, result.stderr)
        commands = result.stdout.splitlines()
        make_manpage = "asciidoctor -D. -a nofooter -b manpage deheader.adoc"
        make_mandir = "install -d /tmp/deheader-install-test/usr/share/man/man1"
        install_manpage = (
            "install -m 644 deheader.1 "
            "/tmp/deheader-install-test/usr/share/man/man1/deheader.1"
        )
        self.assertIn(make_manpage, commands)
        self.assertIn(make_mandir, commands)
        self.assertIn(install_manpage, commands)
        self.assertLess(commands.index(make_mandir), commands.index(install_manpage))


class ExitStatusTests(unittest.TestCase):
    """Keep command statuses useful to CI and pre-release checks."""

    def test_clean_analysis_exits_zero(self):
        result = run_deheader("-q", "-m", "true %s", "tests/noheaders.c")
        self.assertEqual(result.returncode, 0, result.stderr)

    def test_removable_header_exits_one(self):
        result = run_deheader("-q", "-m", "true %s", "tests/string.c")
        self.assertEqual(result.returncode, 1, result.stderr)
        self.assertIn("remove <string.h>", result.stdout)

    def test_failed_basic_compilation_exits_two(self):
        result = run_deheader("-q", "-m", "false %s", "tests/noheaders.c")
        self.assertEqual(result.returncode, 2, result.stdout)
        self.assertIn("basic compilation failed", result.stderr)

    def test_targetless_builder_runs_without_formatting_error(self):
        result = run_deheader("-q", "-m", "true", "tests/noheaders.c")
        self.assertEqual(result.returncode, 0, result.stderr)


if __name__ == "__main__":
    unittest.main()
