"""WizardKit: UFD Functions"""
# vim: sts=2 sw=2 ts=2

# TODO: Drop OrderedDict use

import logging
import math
import os
import pathlib
import shutil
from subprocess import CalledProcessError

from collections import OrderedDict
from docopt import docopt

from wk import io, log
from wk.cfg.main import KIT_NAME_FULL, KIT_NAME_SHORT
from wk.cfg.ufd import (
  BOOT_ENTRIES,
  BOOT_FILES,
  IMAGE_BOOT_ENTRIES,
  ITEMS,
  ITEMS_HIDDEN,
  SOURCES,
  )
from wk.exe import get_json_from_command, run_program
from wk.os import linux

from wk.ui import cli as ui


# STATIC VARIABLES
DOCSTRING = '''WizardKit: Build UFD

Usage:
  build-ufd [options] --ufd-device PATH
            [--linux PATH]
            [--main-kit PATH]
            [--winpe PATH]
            [--extra-dir PATH]
            [EXTRA_IMAGES...]
  build-ufd (-h | --help)

Options:
  -e PATH, --extra-dir PATH
  -k PATH, --main-kit PATH
  -l PATH, --linux PATH
  -u PATH, --ufd-device PATH
  -w PATH, --winpe PATH

  -d --debug            Enable debug mode
  -h --help             Show this page
  -M --use-mbr          Use real MBR instead of GPT w/ Protective MBR
  -F --force            Bypass all confirmation messages. USE WITH EXTREME CAUTION!
  -U --update           Don't format device, just update
'''
LOG = logging.getLogger(__name__)
EXTRA_IMAGES_LIST = '/mnt/UFD/arch/extra_images.list'
MIB = 1024 ** 2
ISO_LABEL = f'{KIT_NAME_SHORT}_LINUX'
UFD_LABEL = f'{KIT_NAME_SHORT}_UFD'


# Functions
def apply_image(part_path, image_path, hide_macos_boot=True) -> None:
  """Apply raw image to dev_path using dd."""
  cmd = [
    'sudo',
    'dd',
    'bs=4M',
    f'if={image_path}',
    f'of={part_path}',
    ]
  run_program(cmd)

  # Bail?
  if not ('macwk' in image_path.name.lower() and hide_macos_boot):
    return

  # Hide macOS boot files
  boot_efi_path = '/mnt/TMP/System/Library/CoreServices/boot.efi'
  linux.mount(source=part_path, mount_point='/mnt/TMP', read_write=True)
  if os.path.exists(boot_efi_path):
    try:
      os.rename(
        boot_efi_path,
        boot_efi_path.replace('boot.efi', 'secretboot.efi'),
        )
    except OSError:
      # Ignore for now?
      pass
  linux.unmount(source_or_mountpoint='/mnt/TMP')


def build_ufd() -> None:
  """Build UFD using selected sources."""
  args = docopt(DOCSTRING)
  if args['--debug']:
    log.enable_debug_mode()
  if args['--update'] and args['EXTRA_IMAGES']:
    ui.print_warning('Extra images are ignored when updating')
    args['EXTRA_IMAGES'] = []
  log.update_log_path(dest_name='build-ufd', timestamp=True)
  try_print = ui.TryAndPrint()
  try_print.add_error('FileNotFoundError')
  try_print.catch_all = False
  try_print.indent = 2
  try_print.verbose = True
  try_print.width = 64

  # Show header
  ui.print_success(KIT_NAME_FULL)
  ui.print_warning('UFD Build Tool')
  ui.print_warning(' ')

  # Verify selections
  ufd_dev = verify_ufd(args['--ufd-device'])
  sources = verify_sources(args, SOURCES)
  extra_images = [io.case_insensitive_path(i) for i in args['EXTRA_IMAGES']]
  show_selections(args, sources, ufd_dev, SOURCES, extra_images)
  if not args['--force']:
    confirm_selections(update=args['--update'])

  # Prep UFD
  if not args['--update']:
    ui.print_info('Prep UFD')
    try_print.run(
      message='Zeroing first 64MiB...',
      function=zero_device,
      dev_path=ufd_dev,
      )
    try_print.run(
      message='Creating partition table...',
      function=create_table,
      dev_path=ufd_dev,
      use_mbr=args['--use-mbr'],
      images=extra_images,
      )
    try_print.run(
      message='Setting boot flag...',
      function=set_boot_flag,
      dev_path=ufd_dev,
      use_mbr=args['--use-mbr'],
      )
    try_print.run(
      message='Formatting partition...',
      function=format_partition,
      dev_path=ufd_dev,
      label=UFD_LABEL,
      )
  ufd_dev_first_partition = find_first_partition(ufd_dev)

  # Mount UFD
  try_print.run(
    message='Mounting UFD...',
    function=linux.mount,
    source=ufd_dev_first_partition,
    mount_point='/mnt/UFD',
    read_write=True,
    )

  # Load extra images if updating
  if args['--update'] and os.path.exists(EXTRA_IMAGES_LIST):
    with open(EXTRA_IMAGES_LIST, 'r', encoding='utf-8') as _f:
      extra_images = [
        io.get_path_obj(image.strip(), resolve=False)
        for image in _f.readlines()
        ]

  # Remove Arch folder
  if args['--update']:
    try_print.run(
      message='Removing Linux...',
      function=remove_arch,
      )

  # Copy sources
  ui.print_standard(' ')
  ui.print_info('Copy Sources')
  try_print.run(
    'Copying Memtest86...', io.recursive_copy,
    '/usr/share/memtest86-efi/', '/mnt/UFD/EFI/Memtest86/', overwrite=True,
    )
  for s_label, s_path in sources.items():
    try_print.run(
      message=f'Copying {s_label}...',
      function=copy_source,
      source=s_path,
      items=ITEMS[s_label],
      overwrite=True,
      )

  # Apply extra images
  if not args['--update']:
    ui.print_standard(' ')
    ui.print_info('Apply Extra Images')
    for part_num, image_path in enumerate(extra_images):
      try_print.run(
        message=f'Applying {image_path.name}...',
        function=apply_image,
        part_path=f'{str(ufd_dev_first_partition)[:-1]}{part_num+2}',
        image_path=image_path,
        )

  # Save extra image list
  if extra_images:
    with open(EXTRA_IMAGES_LIST, 'w', encoding='utf-8') as _f:
      _f.write('\n'.join([image.name for image in extra_images]))

  # Update boot entries
  ui.print_standard(' ')
  ui.print_info('Boot Setup')
  try_print.run(
    message='Updating boot entries...',
    function=update_boot_entries,
    ufd_dev=ufd_dev,
    images=extra_images,
    )

  # Install syslinux (to partition)
  try_print.run(
    message='Syslinux (partition)...',
    function=install_syslinux_to_partition,
    partition=ufd_dev_first_partition,
    )

  # Unmount UFD
  try_print.run(
    message='Unmounting UFD...',
    function=linux.unmount,
    source_or_mountpoint='/mnt/UFD',
    )

  # Install syslinux (to device)
  try_print.run(
    message='Syslinux (device)...',
    function=install_syslinux_to_dev,
    ufd_dev=ufd_dev,
    use_mbr=args['--use-mbr'],
    )

  # Hide items
  ui.print_standard(' ')
  ui.print_info('Final Touches')
  try_print.run(
    message='Hiding items...',
    function=hide_items,
    ufd_dev_first_partition=ufd_dev_first_partition,
    items=ITEMS_HIDDEN,
    )

  # Done
  ui.print_standard('\nDone.')
  if not args['--force']:
    ui.pause('Press Enter to exit...')


def confirm_selections(update=False) -> None:
  """Ask tech to confirm selections, twice if necessary."""
  if not ui.ask('Is the above information correct?'):
    ui.abort()

  # Safety check
  if not update:
    ui.print_standard(' ')
    ui.print_warning('SAFETY CHECK')
    ui.print_standard(
      'All data will be DELETED from the disk and partition(s) listed above.')
    ui.print_colored(
      ['This is irreversible and will lead to', 'DATA LOSS'],
      [None, 'RED'],
      )
    if not ui.ask('Asking again to confirm, is this correct?'):
      ui.abort()

  ui.print_standard(' ')


def copy_source(source, items, overwrite=False) -> None:
  """Copy source items to /mnt/UFD."""
  is_image = source.is_file()
  items_not_found = False

  # Mount source if necessary
  if is_image:
    linux.mount(source, '/mnt/Source')

  # Copy items
  for i_source, i_dest in items:
    i_source = f'{"/mnt/Source" if is_image else source}{i_source}'
    i_dest = f'/mnt/UFD{i_dest}'
    try:
      io.recursive_copy(i_source, i_dest, overwrite=overwrite)
    except FileNotFoundError:
      items_not_found = True

  # Unmount source if necessary
  if is_image:
    linux.unmount('/mnt/Source')

  # Raise exception if item(s) were not found
  if items_not_found:
    raise FileNotFoundError('One or more items not found')


def create_table(dev_path, use_mbr=False, images=None) -> None:
  """Create GPT or DOS partition table."""
  cmd = [
    'sudo',
    'parted', dev_path,
    '--script', '--',
    'mklabel', 'msdos' if use_mbr else 'gpt',
    ]
  if images:
    images = [os.stat(i_path).st_size for i_path in images]
  else:
    images = []
  start = MIB
  end = -1

  # Calculate partition sizes
  ## Align all partitions using 1MiB boundaries for 4K alignment
  ## NOTE: Partitions are aligned to 1 MiB boundaries to match parted's usage
  ## NOTE 2: Crashing if dev_size can't be set is fine since it's necessary
  dev_size = get_block_device_size(dev_path)
  part_sizes = [math.ceil(i/MIB) * MIB for i in images]
  main_size = dev_size - start*2 - sum(part_sizes)
  main_size = math.floor(main_size/MIB) * MIB
  images.insert(0, main_size)
  part_sizes.insert(0, main_size)

  # Build cmd
  for part, real in zip(part_sizes, images):
    end = start + real
    cmd.append(
      f'mkpart primary {"fat32" if start==MIB else "hfs+"} {start}B {end-1}B',
      )
    start += part

  # Run cmd
  run_program(cmd)


def find_first_partition(dev_path) -> str:
  """Find path to first partition of dev, returns str."""
  cmd = [
    'lsblk',
    '--list',
    '--noheadings',
    '--output', 'name',
    '--paths',
    dev_path,
    ]

  # Run cmd
  proc = run_program(cmd)
  part_path = proc.stdout.splitlines()[1].strip()

  # Done
  return part_path


def format_partition(dev_path, label) -> None:
  """Format first partition on device FAT32."""
  cmd = [
    'sudo',
    'mkfs.vfat',
    '-F', '32',
    '-n', label,
    find_first_partition(dev_path),
    ]
  run_program(cmd)


def get_block_device_size(dev_path) -> int:
  """Get block device size via lsblk, returns int."""
  cmd = [
    'lsblk',
    '--bytes',
    '--nodeps',
    '--noheadings',
    '--output',
    'size',
    dev_path,
    ]

  # Run cmd
  proc = run_program(cmd)

  # Done
  return int(proc.stdout.strip())


def get_uuid(path) -> str:
  """Get filesystem UUID via findmnt, returns str."""
  cmd = [
    'findmnt',
    '--noheadings',
    '--target', path,
    '--output', 'uuid'
    ]

  # Run findmnt
  proc = run_program(cmd, check=False)

  # Done
  return proc.stdout.strip()


def hide_items(ufd_dev_first_partition, items) -> None:
  """Set FAT32 hidden flag for items."""
  with open('/root/.mtoolsrc', 'w', encoding='utf-8') as _f:
    _f.write(f'drive U: file="{ufd_dev_first_partition}"\n')
    _f.write('mtools_skip_check=1\n')

  # Hide items
  for item in items:
    cmd = [f'yes | sudo mattrib +h "U:/{item}"']
    run_program(cmd, shell=True, check=False)


def install_syslinux_to_dev(ufd_dev, use_mbr) -> None:
  """Install Syslinux to UFD (dev)."""
  cmd = [
    'sudo',
    'dd',
    'bs=440',
    'count=1',
    f'if=/usr/lib/syslinux/bios/{"mbr" if use_mbr else "gptmbr"}.bin',
    f'of={ufd_dev}',
    ]
  run_program(cmd)


def install_syslinux_to_partition(partition) -> None:
  """Install Syslinux to UFD (partition)."""
  cmd = [
    'sudo',
    'syslinux',
    '--install',
    '--directory',
    '/syslinux/',
    partition,
    ]
  run_program(cmd)


def is_valid_path(path_obj, path_type) -> bool:
  """Verify path_obj is valid by type, returns bool."""
  valid_path = False
  if path_type == 'DIR':
    valid_path = path_obj.is_dir()
  elif path_type == 'KIT':
    valid_path = path_obj.is_dir() and path_obj.joinpath('.bin').exists()
  elif path_type == 'IMG':
    valid_path = path_obj.is_file() and path_obj.suffix.lower() == '.img'
  elif path_type == 'ISO':
    valid_path = path_obj.is_file() and path_obj.suffix.lower() == '.iso'
  elif path_type == 'UFD':
    valid_path = path_obj.is_block_device()

  return valid_path


def set_boot_flag(dev_path, use_mbr=False) -> None:
  """Set modern or legacy boot flag."""
  cmd = [
    'sudo',
    'parted', dev_path,
    'set', '1',
    'boot' if use_mbr else 'legacy_boot',
    'on',
    ]
  run_program(cmd)


def remove_arch() -> None:
  """Remove arch dir from UFD.

  This ensures a clean installation to the UFD and resets the boot files
  """
  shutil.rmtree(io.case_insensitive_path('/mnt/UFD/arch'))


def show_selections(args, sources, ufd_dev, ufd_sources, extra_images) -> None:
  """Show selections including non-specified options."""

  # Sources
  ui.print_info('Sources')
  for label in ufd_sources.keys():
    if label in sources:
      ui.print_standard(f'  {label+":":<18} {sources[label]}')
    else:
      ui.print_colored(
        [f'  {label+":":<18}', 'Not Specified'],
        [None, 'YELLOW'],
        )

  # Extra images
  if extra_images:
    print(f'  {"Extra Images:":<18} {extra_images[0]}')
    for image in extra_images[1:]:
      print(f'  {" ":<18} {image}')

  # Destination
  ui.print_standard(' ')
  ui.print_info('Destination')
  cmd = [
    'lsblk', '--nodeps', '--noheadings', '--paths',
    '--output', 'NAME,FSTYPE,TRAN,SIZE,VENDOR,MODEL,SERIAL',
    ufd_dev,
    ]
  proc = run_program(cmd, check=False)
  ui.print_standard(proc.stdout.strip())
  cmd = [
    'lsblk', '--noheadings', '--paths',
    '--output', 'NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT',
    ufd_dev,
    ]
  proc = run_program(cmd, check=False)
  for line in proc.stdout.splitlines()[1:]:
    ui.print_standard(line)

  # Notes
  if args['--update']:
    ui.print_warning('Updating kit in-place')
  elif args['--use-mbr']:
    ui.print_warning('Formatting using legacy MBR')
  ui.print_standard(' ')


def update_boot_entries(ufd_dev, images=None) -> None:
  """Update boot files for UFD usage"""
  configs = []
  uuids = [get_uuid('/mnt/UFD')]

  # Find config files
  for c_path, c_ext in BOOT_FILES.items():
    try:
      c_path = io.case_insensitive_path(f'/mnt/UFD{c_path}')
    except FileNotFoundError:
      # Ignore and continue to next file
      continue
    for item in os.scandir(c_path):
      if item.name.lower().endswith(c_ext.lower()):
        configs.append(item.path)

  # Use UUID instead of label
  cmd = [
    'sudo',
    'sed',
    '--in-place',
    '--regexp-extended',
    f's#archisolabel={ISO_LABEL}#archisodevice=/dev/disk/by-uuid/{uuids[0]}#',
    *configs,
    ]
  run_program(cmd)

  # Uncomment extra entries if present
  for b_path, b_comment in BOOT_ENTRIES.items():
    try:
      io.case_insensitive_path(f'/mnt/UFD{b_path}')
    except (FileNotFoundError, NotADirectoryError):
      # Entry not found, continue to next entry
      continue

    # Entry found, update config files
    cmd = [
      'sudo',
      'sed',
      '--in-place',
      f's/#{b_comment}#//',
      *configs,
      ]
    run_program(cmd, check=False)

  # Check if we're working with extra images
  if not images and os.path.exists(EXTRA_IMAGES_LIST):
    with open(EXTRA_IMAGES_LIST, 'r', encoding='utf-8') as _f:
      images = [image.strip() for image in _f.readlines()]
  if not images:
    # No extra images detected
    return

  # Get PARTUUID values
  cmd = ['lsblk', '--json', '--output', 'partuuid', ufd_dev]
  try:
    uuids = get_json_from_command(cmd)
  except (CalledProcessError, ValueError):
    # Bail
    return
  uuids = [v['partuuid'] for v in uuids['blockdevices']]

  # Uncomment extra image entries if present
  for _i, image in enumerate(images):
    for name, comment in IMAGE_BOOT_ENTRIES.items():
      if name.lower() in image.name.lower():
        cmd = [
          'sudo',
          'sed',
          '--in-place',
          '--regexp-extended',
          fr's/(#{comment}#.*)"PARTUUID/\1"{uuids[_i+2]}/',
          *configs,
          ]
        run_program(cmd, check=False)
        cmd = [
          'sudo',
          'sed',
          '--in-place',
          f's/#{comment}#//',
          *configs,
          ]
        run_program(cmd, check=False)
        IMAGE_BOOT_ENTRIES.pop(name)
        break


def verify_sources(args, ufd_sources) -> dict[str, pathlib.Path]:
  """Check all sources and abort if necessary, returns dict."""
  sources = OrderedDict()

  for label, data in ufd_sources.items():
    s_path = args[data['Arg']]
    if s_path:
      try:
        s_path_obj = io.case_insensitive_path(s_path)
      except FileNotFoundError:
        ui.print_error(f'ERROR: {label} not found: {s_path}')
        ui.abort()
      if not is_valid_path(s_path_obj, data['Type']):
        ui.print_error(f'ERROR: Invalid {label} source: {s_path}')
        ui.abort()
      sources[label] = s_path_obj

  return sources


def verify_ufd(dev_path) -> pathlib.Path:
  """Check that dev_path is a valid UFD, returns pathlib.Path obj."""
  ufd_dev = None

  try:
    ufd_dev = io.case_insensitive_path(dev_path)
  except FileNotFoundError:
    ui.print_error(f'ERROR: UFD device not found: {dev_path}')
    ui.abort()

  if not is_valid_path(ufd_dev, 'UFD'):
    ui.print_error(f'ERROR: Invalid UFD device: {ufd_dev}')
    ui.abort()

  return ufd_dev # type: ignore[reportGeneralTypeIssues]


def zero_device(dev_path) -> None:
  """Zero-out first 64MB of device."""
  cmd = [
    'sudo',
    'dd',
    'bs=4M',
    'count=16',
    'if=/dev/zero',
    f'of={dev_path}',
    ]
  run_program(cmd)


if __name__ == '__main__':
  print("This file is not meant to be called directly.")
