---
- name: PXE Server Setup (Ubuntu with dnsmasq)
  hosts: localhost
  connection: local
  become: yes
  gather_facts: yes

  pre_tasks:
    - name: "Verify required packages are installed (pre-installed from offline .debs)"
      command: dpkg -s {{ item }}
      loop:
        - dnsmasq
        - apache2
        - samba
        - unzip
        - ufw
        - cron
        - ansible
        - wimtools
        - conntrack
      register: pkg_check
      failed_when: false
      changed_when: false

    - name: "Warn about missing packages"
      debug:
        msg: "WARNING: {{ item.item }} is not installed! Install offline .debs first."
      loop: "{{ pkg_check.results }}"
      when: item.rc != 0

  vars:
    tftp_dir: "/srv/tftp"
    web_root: "/var/www/html"
    samba_share: "/srv/samba/winpeapps"
    usb_mount: "/mnt/usb/playbook"     # playbook location on USB
    usb_root: "/mnt/usb"               # CIDATA partition root
    image_types:
      - gea-standard
      - gea-engineer
      - gea-shopfloor
      - ge-standard
      - ge-engineer
      - ge-shopfloor-lockdown
      - ge-shopfloor-mce
    shopfloor_types:
      - gea-shopfloor
    # Image variants that share the generic Win10/Win11 unattend
    # (gea-standard is now Win11, gea-engineer is Win10 — same unattend file
    # drives both because the RunSynchronous entries are OS-version-agnostic).
    standard_types:
      - gea-standard
      - gea-engineer
      - ge-standard
      - ge-engineer
    deploy_subdirs:
      - Applications
      - Control
      - "Operating Systems"
      - "Out-of-box Drivers"
      - Packages
      - Tools

  tasks:
    - name: "Gather minimal network facts"
      ansible.builtin.setup:
        filter:
          - ansible_interfaces
          - ansible_default_ipv4

    - name: "Bring up all ethernet-like interfaces"
      command: ip link set dev {{ item }} up
      loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
      ignore_errors: yes

    - name: "Find interface with 10.9.100.1 already configured"
      set_fact:
        preconfigured_iface: >-
          {{ ansible_interfaces
             | select('match','^e(th|n)')
             | map('regex_replace','^(.*)$','ansible_\1')
             | map('extract', hostvars[inventory_hostname])
             | selectattr('ipv4','defined')
             | selectattr('ipv4.address','equalto','10.9.100.1')
             | map(attribute='device')
             | list
             | first
             | default('') }}
      ignore_errors: yes

    - name: "Determine PXE interface"
      set_fact:
        pxe_iface: >-
          {{ preconfigured_iface | default('',true)
             or (ansible_interfaces
                  | select('match','^e(th|n)')
                  | reject('equalto','lo')
                  | reject('equalto', ansible_default_ipv4.interface | default(''))
                  | list
                )
                | first
                | default(ansible_default_ipv4.interface | default(
                    ansible_interfaces | select('match','^e(th|n)') | first | default('eth0')
                  )) }}

    - name: "Debug: final pxe_iface choice"
      debug:
        msg: "Using {{ pxe_iface }} for DHCP/TFTP"

    - name: "Expand root partition and filesystem to use full disk"
      args:
        executable: /bin/bash
      shell: |
        # Find the root LV device
        ROOT_DEV=$(findmnt -n -o SOURCE /)
        ROOT_DISK=$(lsblk -n -o PKNAME $(readlink -f "$ROOT_DEV") | tail -1)
        if [ -z "$ROOT_DISK" ]; then
          echo "Could not determine root disk, skipping"
          exit 0
        fi
        # Find the partition number for the LVM PV
        PV_PART=$(pvs --noheadings -o pv_name | tr -d ' ' | head -1)
        if [ -z "$PV_PART" ]; then
          echo "No LVM PV found, skipping"
          exit 0
        fi
        PART_NUM=$(echo "$PV_PART" | grep -o '[0-9]*$')
        echo "Expanding /dev/${ROOT_DISK} partition ${PART_NUM} (${PV_PART})..."
        growpart "/dev/${ROOT_DISK}" "${PART_NUM}" 2>&1 || true
        pvresize "$PV_PART" 2>&1
        lvextend -l +100%FREE "$ROOT_DEV" 2>&1 || true
        resize2fs "$ROOT_DEV" 2>&1
        echo "Disk: $(df -h / | tail -1)"
      register: disk_expand
      changed_when: "'CHANGED' in disk_expand.stdout or 'resized' in disk_expand.stdout"

    - name: "Deploy dnsmasq dhcp-script for per-lease state cleanup"
      copy:
        src: "{{ usb_mount }}/pxe-server-helpers/pxe-dhcp-hook.sh"
        dest: /usr/local/sbin/pxe-dhcp-hook.sh
        mode: '0755'

    - name: "Configure dnsmasq for DHCP and TFTP"
      copy:
        dest: /etc/dnsmasq.conf
        backup: yes
        content: |
          port=0
          interface={{ pxe_iface }}
          bind-interfaces
          dhcp-range=10.9.100.10,10.9.100.100,12h
          dhcp-option=3,10.9.100.1
          dhcp-option=6,8.8.8.8
          enable-tftp
          tftp-root={{ tftp_dir }}
          dhcp-boot=ipxe.efi
          log-dhcp
          # Per-lease state cleanup: flush conntrack + port-445 sockets for
          # the client IP on add/del. Prevents "System error 53" when a PXE
          # client re-images the same machine and hits a stale SMB session.
          # Script runs as root by default (dnsmasq --dhcp-scriptuser default).
          dhcp-script=/usr/local/sbin/pxe-dhcp-hook.sh

    - name: "Create TFTP directory"
      file:
        path: "{{ tftp_dir }}"
        state: directory
        mode: '0755'
        owner: nobody
        group: nogroup

    - name: "Create Win11 directory structure"
      file:
        path: "{{ web_root }}/win11/{{ item }}"
        state: directory
        mode: '0755'
      loop:
        - "EFI/Boot"
        - "EFI/Microsoft/Boot"
        - "Boot"
        - "sources"

    - name: "Create Altiris iPXE directory"
      file:
        path: "{{ web_root }}/Altiris/iPXE"
        state: directory
        mode: '0755'

    - name: "Create boot tool directories"
      file:
        path: "{{ web_root }}/{{ item }}"
        state: directory
        mode: '0755'
      loop:
        - clonezilla
        - blancco
        - memtest

    - name: "Create GetPxeScript.aspx (iPXE boot menu)"
      copy:
        dest: "{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
        backup: yes
        content: |
          #!ipxe

          set server 10.9.100.1

          :menu
          menu GE Aerospace PXE Boot Menu
          item --gap --             ---- Windows Deployment ----
          item winpe                Windows PE (Image Deployment)
          item --gap --             ---- Utilities (Secure Boot must be DISABLED) ----
          item blancco              Blancco Drive Eraser
          item clonezilla           Clonezilla Live (Disk Imaging)
          item memtest              Memtest86+ (Memory Diagnostics)
          item --gap --             ----
          item reboot               Reboot
          item exit                 Exit to BIOS
          choose --default winpe --timeout 30000 target && goto ${target}

          :winpe
          echo
          echo   Windows deployment requires Secure Boot to be ENABLED.
          echo   If you disabled it for Blancco/Clonezilla, re-enable it now.
          echo
          prompt --timeout 5000 Press any key to continue (auto-boot in 5s)... && goto winpe_boot || goto winpe_boot

          :winpe_boot
          kernel http://${server}/win11/wimboot gui
          initrd http://${server}/win11/EFI/Microsoft/Boot/boot.stl  EFI/Microsoft/Boot/Boot.stl
          initrd http://${server}/win11/EFI/Microsoft/Boot/BCD       EFI/Microsoft/Boot/BCD
          initrd http://${server}/win11/EFI/Boot/bootx64.efi         EFI/Boot/bootx64.efi
          initrd http://${server}/win11/Boot/boot.sdi                Boot/boot.sdi
          initrd http://${server}/win11/sources/boot.wim             sources/boot.wim
          boot

          :clonezilla
          set base http://${server}/clonezilla
          kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto secureboot_warn
          initrd ${base}/initrd.img
          boot

          :blancco
          chain http://${server}/blancco/grubx64.efi || goto secureboot_warn

          :memtest
          kernel http://${server}/memtest/memtest.efi || goto secureboot_warn
          boot

          :secureboot_warn
          echo
          echo ======================================================
          echo   This option requires Secure Boot to be DISABLED.
          echo
          echo   1. Reboot this machine
          echo   2. Press F2 / Del to enter BIOS Setup
          echo   3. Disable Secure Boot
          echo   4. Save and exit BIOS
          echo   5. PXE boot again and select this option
          echo
          echo   Re-enable Secure Boot after completing the task.
          echo ======================================================
          echo
          prompt Press any key to return to menu...
          goto menu

          :reboot
          reboot

          :exit
          exit

    - name: "Ensure Apache listens on port 4433"
      lineinfile:
        path: /etc/apache2/ports.conf
        line: "Listen 4433"
        backup: yes
        state: present

    - name: "Create VirtualHost for Altiris iPXE on 4433"
      copy:
        dest: /etc/apache2/sites-available/altiris-ipxe.conf
        backup: yes
        content: |
          <VirtualHost *:4433>
              DocumentRoot {{ web_root }}
              <Directory "{{ web_root }}/Altiris/iPXE">
                  Options Indexes FollowSymLinks
                  AllowOverride None
                  Require all granted
                  AddType text/plain .aspx
              </Directory>
          </VirtualHost>

    - name: "Enable Altiris iPXE site"
      command: a2ensite altiris-ipxe.conf
      args:
        creates: /etc/apache2/sites-enabled/altiris-ipxe.conf

    - name: "Reload Apache to apply changes"
      systemd:
        name: apache2
        state: reloaded

    - name: "Create Samba share root"
      file:
        path: "{{ samba_share }}"
        state: directory
        mode: '0777'

    - name: "Create Clonezilla backup share directory"
      file:
        path: /srv/samba/clonezilla
        state: directory
        mode: '0777'

    - name: "Create Blancco reports share directory"
      file:
        path: /srv/samba/blancco-reports
        state: directory
        mode: '0777'

    - name: "Create enrollment share with internal taxonomy"
      file:
        path: "/srv/samba/enrollment/{{ item }}"
        state: directory
        mode: '0777'
      loop:
        - ""
        - ppkgs
        - scripts
        - config
        - shopfloor-setup
        - pre-install
        - pre-install/bios
        - pre-install/installers
        - installers-post
        - installers-post/cmm
        - blancco
        - logs

    - name: "Deploy enrollment share README"
      copy:
        dest: /srv/samba/enrollment/README.md
        mode: '0644'
        content: |
          # Enrollment Share Layout
          Single SMB share mounted by WinPE as Y: during imaging. Subdir layout:
          - ppkgs/             GCCH bulk-enrollment PPKGs
          - scripts/           run-enrollment.ps1, wait-for-internet.ps1, migrate-to-wifi.ps1
          - config/            site-config.json, FlatUnattendW10*.xml, per-site overrides
          - shopfloor-setup/   Per-PC-type post-imaging scripts
          - pre-install/       WinPE-phase content (bios/, installers/, preinstall.json)
          - installers-post/   Post-OOBE app installers (cmm/PCDMIS, etc.)
          - blancco/           Blancco custom images / configs
          - logs/              Client log uploads

    - name: "Deploy PPKG enrollment packages to ppkgs/"
      shell: |
        set +e
        # Copy any whole PPKGs (small enough to fit on FAT32)
        cp -f {{ usb_root }}/enrollment/*.ppkg /srv/samba/enrollment/ppkgs/ 2>/dev/null
        # Reassemble any split files (foo.ppkg.part.00, .01, ... -> foo.ppkg)
        for first in {{ usb_root }}/enrollment/*.part.00; do
          [ -e "$first" ] || continue
          base="${first%.part.00}"
          name="$(basename "$base")"
          echo "Reassembling $name from chunks..."
          cat "${base}.part."* > "/srv/samba/enrollment/ppkgs/$name"
        done
        ls -lh /srv/samba/enrollment/ppkgs/*.ppkg 2>/dev/null
      ignore_errors: yes

    - name: "Deploy enrollment scripts to scripts/"
      copy:
        src: "{{ item.src }}"
        dest: "/srv/samba/enrollment/scripts/{{ item.dest }}"
        mode: '0644'
      loop:
        - { src: "{{ usb_mount }}/shopfloor-setup/run-enrollment.ps1", dest: "run-enrollment.ps1" }
        - { src: "{{ usb_mount }}/wait-for-internet.ps1",              dest: "wait-for-internet.ps1" }
        - { src: "{{ usb_mount }}/migrate-to-wifi.ps1",                dest: "migrate-to-wifi.ps1" }
      ignore_errors: yes

    - name: "Deploy site-config.json to config/"
      copy:
        src: "{{ usb_mount }}/shopfloor-setup/site-config.json"
        dest: /srv/samba/enrollment/config/site-config.json
        mode: '0644'
      ignore_errors: yes

    - name: "Deploy Dell driver packs to shared Out-of-box Drivers"
      args:
        executable: /bin/bash
      shell: |
        set +e
        SRC="{{ usb_root }}/drivers"
        DEST="/srv/samba/winpeapps/_shared/Out-of-box Drivers/Dell_11"
        if [ ! -d "$SRC" ]; then
          echo "No drivers/ on USB - skipping"
          exit 0
        fi
        mkdir -p "$DEST"
        # Copy everything except split chunks
        rsync -a --exclude='*.part.*' "$SRC/" "$DEST/"
        # Reassemble any split driver files
        while IFS= read -r first; do
          base="${first%.part.00}"
          rel="${base#$SRC/}"
          out="$DEST/$rel"
          mkdir -p "$(dirname "$out")"
          echo "Reassembling $rel from chunks..."
          cat "${base}.part."* > "$out"
        done < <(find "$SRC" -name '*.part.00')
        echo "Deployed Dell drivers from USB"
      ignore_errors: yes

    - name: "Deploy shopfloor setup scripts to enrollment share"
      copy:
        src: "{{ usb_mount }}/shopfloor-setup/"
        dest: /srv/samba/enrollment/shopfloor-setup/
        mode: '0755'
        directory_mode: '0755'
      ignore_errors: yes

    - name: "Deploy preinstall.json to pre-install/"
      copy:
        src: "{{ usb_mount }}/preinstall/preinstall.json"
        dest: /srv/samba/enrollment/pre-install/preinstall.json
        mode: '0644'
      ignore_errors: yes

    # Oracle Client 11.2 is the first app the preinstall runner executes, so it
    # has to be present on-disk before the first client images. The 686 MB
    # Oracle_OracleDatabase_11r2_V03.zip doesn't belong in git; it rides on the
    # USB under oracle/ alongside the BIOS exes. Install-Oracle11r2.cmd is
    # tracked at playbook/preinstall/oracle/ and arrives via usb_mount.
    - name: "Create pre-install/installers/oracle/ staging dir"
      file:
        path: /srv/samba/enrollment/pre-install/installers/oracle
        state: directory
        mode: '0755'
      ignore_errors: yes

    - name: "Deploy Install-Oracle11r2.cmd to pre-install/installers/oracle/"
      copy:
        src: "{{ usb_mount }}/preinstall/oracle/Install-Oracle11r2.cmd"
        dest: /srv/samba/enrollment/pre-install/installers/oracle/Install-Oracle11r2.cmd
        mode: '0755'
      ignore_errors: yes

    - name: "Deploy Oracle 11.2 zip (686 MB) from USB to pre-install/installers/oracle/"
      shell: >
        if [ -f "{{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip" ]; then
          cp -f "{{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip" \
                /srv/samba/enrollment/pre-install/installers/oracle/
          echo "Deployed Oracle 11.2 zip ($(stat -c %s /srv/samba/enrollment/pre-install/installers/oracle/Oracle_OracleDatabase_11r2_V03.zip) bytes)"
        else
          echo "WARNING: {{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip not on USB - preinstall runner will fail on Oracle step until this file is staged at /srv/samba/enrollment/pre-install/installers/oracle/"
        fi
      ignore_errors: yes

    - name: "Deploy BIOS check script and manifest to pre-install/bios/"
      copy:
        src: "{{ usb_mount }}/shopfloor-setup/BIOS/{{ item }}"
        dest: "/srv/samba/enrollment/pre-install/bios/{{ item }}"
        mode: '0644'
      loop:
        - check-bios.cmd
        - models.txt
      ignore_errors: yes

    - name: "Deploy BIOS update binaries from USB to pre-install/bios/"
      shell: >
        if [ -d "{{ usb_root }}/bios" ]; then
          cp -f {{ usb_root }}/bios/*.exe /srv/samba/enrollment/pre-install/bios/ 2>/dev/null || true
          count=$(find /srv/samba/enrollment/pre-install/bios -name '*.exe' | wc -l)
          echo "Deployed $count BIOS binaries"
        else
          echo "No bios/ on USB - skipping"
        fi
      ignore_errors: yes

    - name: "Create image upload staging directory"
      file:
        path: /home/pxe/image-upload
        state: directory
        mode: '0777'
        owner: pxe
        group: pxe

    - name: "Enable Samba symlink following (shared image dirs)"
      blockinfile:
        path: /etc/samba/smb.conf
        backup: yes
        marker: "# {mark} MANAGED - GLOBAL SYMLINKS"
        insertafter: '\[global\]'
        block: |
          follow symlinks = yes
          wide links = yes
          unix extensions = no

    - name: "Samba SMB session handling for WinPE re-image robustness"
      blockinfile:
        path: /etc/samba/smb.conf
        backup: yes
        marker: "# {mark} MANAGED - PXE REIMAGE FIX"
        insertafter: "# END MANAGED - GLOBAL SYMLINKS"
        block: |
          # Reduce the chance a WinPE client rebooting mid-imaging leaves a
          # stale session on the server that blocks its next connection
          # attempt with "System error 53 network path not found". Combined
          # with /etc/sysctl.d/99-pxe-conntrack.conf (shorter nf_conntrack
          # TCP timeouts) this keeps the conntrack + smbd state in sync with
          # the short-lived flows that PXE imaging produces.
             socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY
             keepalive = 30
             deadtime = 5

    - name: "Configure Samba shares"
      blockinfile:
        path: /etc/samba/smb.conf
        backup: yes
        block: |
          [winpeapps]
          path = {{ samba_share }}
          browseable = yes
          read only = no
          guest ok = no
          valid users = pxe-upload
          force user = root

          [clonezilla]
          path = /srv/samba/clonezilla
          browseable = yes
          read only = no
          guest ok = no
          valid users = pxe-upload
          force user = root
          comment = Clonezilla backup images

          [blancco-reports]
          path = /srv/samba/blancco-reports
          browseable = yes
          read only = no
          guest ok = no
          valid users = pxe-upload blancco
          force user = root
          comment = Blancco Drive Eraser reports

          [enrollment]
          path = /srv/samba/enrollment
          browseable = yes
          read only = no
          guest ok = no
          valid users = pxe-upload
          force user = root
          comment = GCCH bulk enrollment packages

          [image-upload]
          path = /home/pxe/image-upload
          browseable = yes
          read only = no
          guest ok = no
          valid users = pxe-upload
          force user = pxe
          force group = pxe
          comment = PXE image upload staging area
          oplocks = no
          level2 oplocks = no
          strict sync = yes

    - name: "Create Samba users (pxe-upload and blancco)"
      shell: |
        id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
        echo -e 'pxe\npxe' | smbpasswd -a pxe-upload -s
        id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco
        echo -e 'blancco\nblancco' | smbpasswd -a blancco -s
      args:
        executable: /bin/bash
      changed_when: false

    - name: "Deploy nf_conntrack TCP timeout sysctl for PXE workload"
      copy:
        src: "{{ usb_mount }}/pxe-server-helpers/99-pxe-conntrack.conf"
        dest: /etc/sysctl.d/99-pxe-conntrack.conf
        mode: '0644'
      notify: reload sysctl

    - name: "Deploy SMB diagnostic + soft-reset helper scripts"
      copy:
        src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
        dest: "/usr/local/sbin/{{ item }}"
        mode: '0755'
      loop:
        - smb-diag.sh
        - smb-soft-reset.sh

    - name: "Create image-type top-level directories"
      file:
        path: "{{ samba_share }}/{{ item }}"
        state: directory
        mode: '0777'
      loop: "{{ image_types }}"

    - name: "Create Deploy subdirectories for each image type"
      file:
        path: "{{ samba_share }}/{{ item.0 }}/Deploy/{{ item.1 }}"
        state: directory
        mode: '0777'
      with_nested:
        - "{{ image_types }}"
        - "{{ deploy_subdirs }}"

    - name: "Create Media.tag for FlatSetupLoader.exe drive detection"
      copy:
        content: ""
        dest: "{{ samba_share }}/{{ item }}/Deploy/Control/Media.tag"
        mode: '0644'
        force: no
      loop: "{{ image_types }}"

    - name: "Deploy shopfloor unattend.xml template"
      copy:
        src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml"
        dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
        mode: '0644'
        force: no
      loop: "{{ shopfloor_types }}"
      ignore_errors: yes

    # gea-standard (Win11) + gea-engineer (Win10) + ge-standard/engineer all use
    # the same unattend. force: yes because drift between this file and the
    # shared copies is what caused the Win10/Win11 search-cleanup regression
    # earlier this session (d49f516) — prefer repo as source of truth.
    - name: "Deploy standard/engineer unattend.xml"
      copy:
        src: "{{ usb_mount }}/FlatUnattendW10.xml"
        dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
        mode: '0644'
        force: yes
      loop: "{{ standard_types }}"
      ignore_errors: yes

    - name: "Daily cron to create/refresh Media.tag for all images"
      copy:
        content: |
          # Create Media.tag in any image with Deploy/Control/ and refresh existing ones
          @reboot root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
          0 0 * * * root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
        dest: /etc/cron.d/media-tag-refresh
        mode: '0644'

    - name: "Copy WinPE & boot files from USB (skipped if not present)"
      copy:
        src: "{{ usb_root }}/{{ item.src }}"
        dest: "{{ web_root }}/win11/{{ item.dest }}"
        mode: '0644'
      loop:
        - { src: "wimboot",     dest: "wimboot" }
        - { src: "boot.stl",    dest: "EFI/Microsoft/Boot/boot.stl" }
        - { src: "BCD",         dest: "EFI/Microsoft/Boot/BCD" }
        - { src: "bootx64.efi", dest: "EFI/Boot/bootx64.efi" }
        - { src: "boot.sdi",    dest: "Boot/boot.sdi" }
        - { src: "boot.wim",    dest: "sources/boot.wim" }
      ignore_errors: yes

    - name: "Inject startnet.cmd into boot.wim (virtual BOOT/MEDIA volumes)"
      shell: |
        WIM="{{ web_root }}/win11/sources/boot.wim"
        STARTNET="{{ usb_mount }}/startnet.cmd"
        if [ -f "$WIM" ] && [ -f "$STARTNET" ]; then
          echo "add $STARTNET /Windows/System32/startnet.cmd" | wimupdate "$WIM" 1
          echo "Updated startnet.cmd in boot.wim"
        else
          echo "Skipped: boot.wim or startnet.cmd not found"
        fi
      args:
        executable: /bin/bash
      ignore_errors: yes

    - name: "Copy iPXE binaries from USB (skipped if not present)"
      copy:
        src: "{{ usb_root }}/{{ item }}"
        dest: "{{ tftp_dir }}/{{ item }}"
        mode: '0755'
      loop:
        - ipxe.efi
      ignore_errors: yes

    - name: "Copy boot tool files from USB (Clonezilla, Blancco, Memtest)"
      shell: >
        cp -r "{{ usb_root }}/boot-tools/{{ item }}/"* "{{ web_root }}/{{ item }}/" 2>/dev/null || true
      loop:
        - clonezilla
        - blancco
        - memtest

    # --- Blancco PXE boot via Ubuntu kernel + switch_root ---
    # Blancco's own kernel freezes on Dell Precision towers. Workaround:
    # Boot Ubuntu kernel, download Blancco rootfs, overlay mount, switch_root.

    - name: "Build Blancco PXE initramfs"
      # The narrow hand-picked NIC driver list used before 2026-04-22 produced
      # a 2 MB initramfs that hung on "waiting for network interface" for any
      # hardware outside e1000e/igb/tg3/bnx2/bnxt_en/b44. This rewrite sweeps
      # the full drivers/net/ tree (ethernet + phy + mdio + usb + fddi + wan)
      # plus overlay / squashfs / loop / ptp / libphy / mii deps, runs depmod
      # so blancco-init.sh can use modprobe with proper dependency resolution,
      # and produces a ~20 MB initramfs. Size isn't a concern - HTTP loads it
      # in under two seconds at gigabit, versus the many minutes of hangtime
      # the narrow build cost us when a NIC was unsupported.
      args:
        executable: /bin/bash
        creates: "{{ web_root }}/blancco/kexec-initrd.img"
      shell: |
        set -e
        WORK=$(mktemp -d)
        KVER=$(uname -r)
        mkdir -p "$WORK"/{bin,lib/modules/$KVER/kernel,lib64,sbin,usr/share/udhcpc,etc,run,proc,sys,dev}

        # Busybox (static) - bundled on USB at playbook/busybox-static
        if [ -f /bin/busybox ]; then
            cp /bin/busybox "$WORK/bin/busybox"
        elif [ -f "{{ usb_root }}/playbook/busybox-static" ]; then
            cp "{{ usb_root }}/playbook/busybox-static" "$WORK/bin/busybox"
        else
            echo "ERROR: No busybox available (not at /bin/busybox or on USB)"
            exit 1
        fi
        chmod +x "$WORK/bin/busybox"
        # All applets blancco-init.sh uses: modprobe, insmod, dmesg, find, env
        # and export added vs the old narrow list.
        for cmd in sh ash awk cat chmod cp dd echo grep gunzip ifconfig ip insmod ln losetup ls mkdir mknod modprobe mount mv reboot rm rmdir route sed sleep switch_root tar udhcpc umount wget cpio dmesg env export find; do
            ln -sf busybox "$WORK/bin/$cmd"
        done

        # Full drivers/net/ tree - ethernet + phy + mdio + usb + fddi + wan.
        # Preserve path under /lib/modules/$KVER so depmod can resolve deps.
        NETDIR=/lib/modules/$KVER/kernel/drivers/net
        for sub in ethernet mdio phy usb fddi wan; do
            if [ -d "$NETDIR/$sub" ]; then
                mkdir -p "$WORK/lib/modules/$KVER/kernel/drivers/net/$sub"
                cp -r "$NETDIR/$sub/"* "$WORK/lib/modules/$KVER/kernel/drivers/net/$sub/" 2>/dev/null || true
            fi
        done
        # Overlay + squashfs + loop + usb + hid + ptp + mii + net/core deps
        for modpath in fs/overlayfs fs/squashfs drivers/block drivers/usb/core drivers/usb/host drivers/hid drivers/ptp drivers/net/mii.ko net/core; do
            if [ -e "/lib/modules/$KVER/kernel/$modpath" ]; then
                mkdir -p "$WORK/lib/modules/$KVER/kernel/$(dirname $modpath)"
                cp -r "/lib/modules/$KVER/kernel/$modpath" "$WORK/lib/modules/$KVER/kernel/$modpath" 2>/dev/null || true
            fi
        done

        # Decompress zstd modules in-place (busybox insmod can't handle .zst)
        find "$WORK/lib/modules" -name '*.ko.zst' -print0 | \
            xargs -0 -I {} sh -c 'zstd -d --rm -o "${1%.zst}" "$1" 2>/dev/null' _ {} || true

        # Preserve modules.builtin / modules.order, regenerate modules.dep so
        # modprobe can resolve dependencies inside the initramfs.
        cp /lib/modules/$KVER/modules.builtin "$WORK/lib/modules/$KVER/" 2>/dev/null || true
        cp /lib/modules/$KVER/modules.order   "$WORK/lib/modules/$KVER/" 2>/dev/null || true
        (cd "$WORK" && depmod -b . $KVER) 2>/dev/null || echo "depmod warning (non-fatal)"

        # Init script
        cp "{{ usb_root }}/playbook/blancco-init.sh" "$WORK/init"
        chmod +x "$WORK/init"

        # Build CPIO
        cd "$WORK"
        find . | cpio -o -H newc --quiet | gzip -1 > "{{ web_root }}/blancco/kexec-initrd.img"
        cd /
        rm -rf "$WORK"
        echo "Built kexec-initrd.img: $(stat -c %s '{{ web_root }}/blancco/kexec-initrd.img') bytes"
      ignore_errors: yes

    - name: "Copy Ubuntu kernel for Blancco PXE boot"
      copy:
        src: "/boot/vmlinuz-{{ ansible_kernel }}"
        dest: "{{ web_root }}/blancco/vmlinuz-ubuntu"
        remote_src: yes
        mode: '0644'

    - name: "Create TFTP blancco directory"
      file:
        path: "{{ tftp_dir }}/blancco"
        state: directory
        owner: nobody
        group: nogroup
        mode: '0755'

    - name: "Create TFTP symlinks for Blancco kernel/initrd (GRUB HTTP times out on large files; TFTP is reliable)"
      file:
        src: "{{ web_root }}/blancco/{{ item }}"
        dest: "{{ tftp_dir }}/blancco/{{ item }}"
        state: link
        force: yes
        owner: nobody
        group: nogroup
      loop:
        - vmlinuz-bde-linux
        - initramfs-bde-linux.img
        - intel-ucode.img
        - amd-ucode.img
        - config.img

    - name: "Build Ubuntu kernel modules tarball for Blancco"
      shell: |
        set -e
        KVER=$(uname -r)
        tar czf "{{ web_root }}/blancco/kmod.tar.gz" -C / "lib/modules/$KVER"
        echo "Built kmod.tar.gz: $(du -h '{{ web_root }}/blancco/kmod.tar.gz' | cut -f1)"
      args:
        creates: "{{ web_root }}/blancco/kmod.tar.gz"

    - name: "Deploy Blancco config and preferences (null-stripped)"
      shell: |
        # Strip null bytes from config.img files and deploy
        if [ -f "{{ web_root }}/blancco/config.img" ]; then
            WORK=$(mktemp -d)
            cd "$WORK"
            cpio -id < "{{ web_root }}/blancco/config.img" 2>/dev/null
            tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml"
            rm -rf "$WORK"
        fi
        # Deploy preferences from playbook (pre-configured with network share)
        cp "{{ usb_root }}/playbook/blancco-preferences.xml" "{{ web_root }}/blancco/preferences.xml"
      args:
        creates: "{{ web_root }}/blancco/config-clean.xml"

    - name: "Ensure Samba user for Blancco reports exists (idempotent)"
      shell: |
        id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco
        pdbedit -L 2>/dev/null | grep -q '^blancco:' || (echo -e "blancco\nblancco" | smbpasswd -a -s blancco)
      changed_when: false

    - name: "Check for WinPE deployment content on USB"
      stat:
        path: "{{ usb_root }}/images"
      register: usb_images_dir

    - name: "Import WinPE deployment content from USB (if present)"
      shell: >
        cp -rn "{{ usb_root }}/images/{{ item }}/"* "{{ samba_share }}/{{ item }}/" 2>/dev/null || true
      loop: "{{ image_types }}"
      when: usb_images_dir.stat.exists

    - name: "Restart and enable services"
      systemd:
        name: "{{ item }}"
        state: restarted
        enabled: yes
      loop:
        - dnsmasq
        - apache2
        - smbd

    - name: "Allow necessary firewall ports (UFW)"
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: "{{ 'udp' if item in ['67','69'] else 'tcp' }}"
      loop:
        - "22"
        - "67"
        - "69"
        - "80"
        - "4433"
        - "445"
        - "9009"

    - name: "Enable UFW firewall"
      ufw:
        state: enabled
        policy: deny

    - name: "Schedule dnsmasq restart 15s after reboot"
      copy:
        dest: /etc/cron.d/dnsmasq-restart
        mode: '0644'
        content: |
          @reboot root /bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service

    # --- Web Management App (Flask) ---
    - name: "Create webapp directory"
      file:
        path: /opt/pxe-webapp
        state: directory
        mode: '0755'

    - name: "Copy webapp from USB"
      shell: >
        cp -r "{{ usb_root }}/webapp/"* /opt/pxe-webapp/ 2>/dev/null || true
      args:
        creates: /opt/pxe-webapp/app.py

    - name: "Install webapp Python dependencies (offline wheels)"
      args:
        executable: /bin/bash
      shell: |
        # Find the pip-wheels directory on the CIDATA mount
        export WHEEL_DIR=""
        for d in "{{ usb_root }}/pip-wheels" "{{ usb_mount }}/pip-wheels"; do
          if [ -d "$d" ] && compgen -G "$d/*.whl" > /dev/null; then
            export WHEEL_DIR="$(cd "$d" && pwd)"
            break
          fi
        done
        if [ -n "$WHEEL_DIR" ]; then
          # Install wheels directly using Python zipfile (bypasses pip entirely)
          # This avoids the pip3/distutils incompatibility between Ubuntu 22.04 debs and Python 3.12
          python3 << 'PYEOF'
        import zipfile, sysconfig, glob, os
        site = sysconfig.get_path('platlib')
        wheel_dir = os.environ['WHEEL_DIR']
        for whl in sorted(glob.glob(os.path.join(wheel_dir, '*.whl'))):
            name = os.path.basename(whl)
            print(f'  Installing {name}')
            with zipfile.ZipFile(whl) as z:
                z.extractall(site)
        print('All wheels installed to ' + site)
        PYEOF
        else
          # Fallback: try system pip (works if system has internet and compatible pip)
          python3 -m pip install --break-system-packages flask lxml 2>/dev/null ||
          pip3 install --break-system-packages flask lxml
        fi

    - name: "Create systemd service for PXE webapp"
      copy:
        dest: /etc/systemd/system/pxe-webapp.service
        content: |
          [Unit]
          Description=PXE Server Web Management
          After=network.target apache2.service

          [Service]
          Type=simple
          User=root
          WorkingDirectory=/opt/pxe-webapp
          Environment=SAMBA_SHARE={{ samba_share }}
          Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
          Environment=WEB_ROOT={{ web_root }}
          Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
          Environment=ENROLLMENT_SHARE=/srv/samba/enrollment
          Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
          ExecStart=/usr/bin/python3 app.py
          Restart=always
          RestartSec=5

          [Install]
          WantedBy=multi-user.target

    - name: "Enable and start PXE webapp service"
      systemd:
        name: pxe-webapp
        state: started
        enabled: yes
        daemon_reload: yes

    - name: "Configure unified Apache site (static files + webapp proxy)"
      copy:
        dest: /etc/apache2/sites-available/pxe-server.conf
        content: |
          Listen 9009
          <VirtualHost *:80>
              DocumentRoot {{ web_root }}
              <Directory "{{ web_root }}">
                  Options Indexes FollowSymLinks
                  AllowOverride None
                  Require all granted
              </Directory>
              ProxyPreserveHost On
              ProxyPass /manage http://127.0.0.1:9010/
              ProxyPassReverse /manage http://127.0.0.1:9010/
          </VirtualHost>
          <VirtualHost *:9009>
              Alias /static /opt/pxe-webapp/static
              <Directory "/opt/pxe-webapp/static">
                  Require all granted
                  Options -Indexes
              </Directory>
              ProxyPreserveHost On
              ProxyPass /static !
              ProxyPass / http://127.0.0.1:9010/
              ProxyPassReverse / http://127.0.0.1:9010/
          </VirtualHost>

    - name: "Enable Apache proxy modules"
      command: a2enmod proxy proxy_http
      args:
        creates: /etc/apache2/mods-enabled/proxy.load

    - name: "Disable default Apache site"
      command: a2dissite 000-default.conf
      args:
        removes: /etc/apache2/sites-enabled/000-default.conf

    - name: "Enable unified PXE server site"
      command: a2ensite pxe-server.conf
      args:
        creates: /etc/apache2/sites-enabled/pxe-server.conf

    - name: "Reload Apache after site changes"
      systemd:
        name: apache2
        state: reloaded

    - name: "Configure static IP for PXE interface"
      copy:
        dest: /etc/netplan/50-cloud-init.yaml
        backup: yes
        content: |
          network:
            version: 2
            renderer: networkd
            ethernets:
              {{ pxe_iface }}:
                dhcp4: no
                addresses: [10.9.100.1/24]
      notify: "Apply netplan"

  handlers:
    - name: "Apply netplan"
      command: netplan apply

    - name: "reload sysctl"
      command: sysctl --system
