"""
Generic microscopy montage generator used for the PhD image arrays.

Default layout
--------------
6 rows x 21 columns
500 x 500 pixels per image
2-pixel white grid
180-degree image rotation
Arial 40 red position labels
JPEG output at 600 dpi
Four folders processed concurrently

Filename requirement
--------------------
Each source filename must end with its grid position before the extension,
for example:

    sample_A01.jpg
    sample_A1.jpg
    allopurinol_PVPVA_F21.jpg

The final letter identifies the row and the number identifies the column.
Montage files containing "_montage" are ignored.

To use a different layout, change ROWS and COLUMNS in the settings section.
For example, use ROWS = 5 and COLUMNS = 23 for a 5 x 23 montage, or
ROWS = 8 and COLUMNS = 8 for an 8 x 8 montage.
"""

import ctypes
import os
import re
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont


# =============================================================================
# USER SETTINGS
# =============================================================================

# Add the local synced OneDrive folders that contain the source JPG images.
# Keep the leading r before each Windows path.
IMAGE_FOLDERS = [
    r"C:\path\to\first\image_folder",
    r"C:\path\to\second\image_folder",
]

# Montage layout.
ROWS = 6
COLUMNS = 21

# Size of every individual image in the montage.
SPOT_WIDTH = 500
SPOT_HEIGHT = 500

# White space between adjacent images.
GRID_THICKNESS = 2

# Image preparation.
ROTATION_DEGREES = 180

# Use 1.0 to retain the complete image.
# Use 0.55 to reproduce the earlier central-crop montage format.
CENTRE_CROP_FRACTION = 1.0

# Position labels.
DRAW_POSITION_LABELS = True
LABEL_FONT_SIZE = 40
LABEL_COLOUR = "red"
LABEL_OFFSET_X = 5
LABEL_OFFSET_Y = 5

# Output settings.
OUTPUT_SUFFIX = "_montage.jpg"
JPEG_QUALITY = 95
OUTPUT_DPI = 600

# Number of folders processed at the same time.
MAX_WORKERS = 4

# Return downloaded source JPGs to online-only status after the montage has
# been created. This is applied only on Windows and does not affect the output.
RETURN_SOURCE_IMAGES_TO_ONLINE_ONLY = True

IMAGE_EXTENSIONS = {".jpg", ".jpeg"}


# =============================================================================
# WINDOWS PATH AND ONEDRIVE HELPERS
# =============================================================================

def windows_long_path(path):
    """Add the Windows long-path prefix when required."""

    absolute_path = os.path.abspath(str(path))

    if os.name != "nt" or absolute_path.startswith("\\\\?\\"):
        return absolute_path

    if absolute_path.startswith("\\\\"):
        return "\\\\?\\UNC\\" + absolute_path[2:]

    return "\\\\?\\" + absolute_path


def make_online_only(path):
    """Mark a processed OneDrive source file as unpinned/online-only."""

    if os.name != "nt":
        return

    file_attribute_pinned = 0x00080000
    file_attribute_unpinned = 0x00100000
    invalid_file_attributes = 0xFFFFFFFF

    long_path = windows_long_path(path)
    kernel32 = ctypes.windll.kernel32

    attributes = kernel32.GetFileAttributesW(long_path)

    if attributes == invalid_file_attributes:
        print(f"Warning: could not read OneDrive attributes for {path}")
        return

    new_attributes = (
        attributes | file_attribute_unpinned
    ) & ~file_attribute_pinned

    success = kernel32.SetFileAttributesW(long_path, new_attributes)

    if not success:
        print(f"Warning: could not return source image online-only: {path}")


# =============================================================================
# IMAGE AND FILENAME HELPERS
# =============================================================================

def load_arial_font(font_size):
    """Load Arial from Windows, with a portable fallback."""

    possible_fonts = [
        r"C:\Windows\Fonts\arial.ttf",
        "arial.ttf",
    ]

    for font_path in possible_fonts:
        try:
            return ImageFont.truetype(font_path, font_size)
        except OSError:
            continue

    print("Warning: Arial was unavailable; the default PIL font was used.")
    return ImageFont.load_default()


def centre_crop(image, crop_fraction):
    """Return a centred crop while preserving the chosen image fraction."""

    if not 0 < crop_fraction <= 1:
        raise ValueError("CENTRE_CROP_FRACTION must be greater than 0 and at most 1.")

    if crop_fraction == 1:
        return image

    width, height = image.size
    crop_width = max(1, round(width * crop_fraction))
    crop_height = max(1, round(height * crop_fraction))

    left = (width - crop_width) // 2
    top = (height - crop_height) // 2

    return image.crop(
        (
            left,
            top,
            left + crop_width,
            top + crop_height,
        )
    )


def prepare_image(image_path):
    """Open, optionally crop, resize and rotate one microscopy image."""

    with Image.open(windows_long_path(image_path)) as source_image:
        image = source_image.convert("RGB")
        image = centre_crop(image, CENTRE_CROP_FRACTION)
        image = image.resize(
            (SPOT_WIDTH, SPOT_HEIGHT),
            Image.Resampling.LANCZOS,
        )

        if ROTATION_DEGREES:
            image = image.rotate(
                ROTATION_DEGREES,
                expand=False,
            )

        return image.copy()


def position_from_filename(filename):
    """Extract a final grid code such as A01, F8 or H21."""

    filename_stem = Path(filename).stem
    match = re.search(
        r"([A-Z])0*(\d{1,2})$",
        filename_stem,
        flags=re.IGNORECASE,
    )

    if match is None:
        return None

    row_letter = match.group(1).upper()
    column_number = int(match.group(2))

    valid_rows = string.ascii_uppercase[:ROWS]

    if row_letter not in valid_rows:
        return None

    if not 1 <= column_number <= COLUMNS:
        return None

    return row_letter, column_number


def find_source_images(folder):
    """Map each expected grid position to its source JPG."""

    image_map = {}
    unexpected_files = []
    duplicate_positions = []

    for filename in os.listdir(windows_long_path(folder)):
        file_path = Path(folder) / filename

        if "_montage" in filename.casefold():
            continue

        if file_path.suffix.casefold() not in IMAGE_EXTENSIONS:
            continue

        position = position_from_filename(filename)

        if position is None:
            unexpected_files.append(filename)
            continue

        if position in image_map:
            duplicate_positions.append(
                (position, image_map[position].name, filename)
            )
            continue

        image_map[position] = file_path

    if unexpected_files:
        print(f"\nFiles without a recognised final grid position in {folder}:")
        for filename in sorted(unexpected_files):
            print(f"  {filename}")

    if duplicate_positions:
        print(f"\nDuplicate positions in {folder}; the first file was retained:")
        for position, retained, ignored in duplicate_positions:
            code = f"{position[0]}{position[1]:02d}"
            print(f"  {code}: retained {retained}; ignored {ignored}")

    return image_map


# =============================================================================
# MONTAGE CREATION
# =============================================================================

def make_montage(image_folder):
    """Create one labelled montage from a folder of microscopy JPGs."""

    folder = Path(image_folder)
    long_folder = windows_long_path(folder)

    if not os.path.isdir(long_folder):
        raise FileNotFoundError(f"Image folder not found: {folder}")

    print(f"\nProcessing: {folder}")

    image_map = find_source_images(folder)
    row_letters = string.ascii_uppercase[:ROWS]

    montage_width = (
        COLUMNS * SPOT_WIDTH
        + (COLUMNS - 1) * GRID_THICKNESS
    )
    montage_height = (
        ROWS * SPOT_HEIGHT
        + (ROWS - 1) * GRID_THICKNESS
    )

    montage = Image.new(
        "RGB",
        (montage_width, montage_height),
        color="white",
    )
    drawing = ImageDraw.Draw(montage)
    font = load_arial_font(LABEL_FONT_SIZE)

    processed_sources = []
    missing_positions = []

    for row_index, row_letter in enumerate(row_letters):
        for column_number in range(1, COLUMNS + 1):
            position = (row_letter, column_number)
            position_label = f"{row_letter}{column_number:02d}"

            x_position = (column_number - 1) * (
                SPOT_WIDTH + GRID_THICKNESS
            )
            y_position = row_index * (
                SPOT_HEIGHT + GRID_THICKNESS
            )

            source_path = image_map.get(position)

            if source_path is not None:
                prepared_image = prepare_image(source_path)
                montage.paste(
                    prepared_image,
                    (x_position, y_position),
                )
                processed_sources.append(source_path)
            else:
                missing_positions.append(position_label)

            if DRAW_POSITION_LABELS:
                drawing.text(
                    (
                        x_position + LABEL_OFFSET_X,
                        y_position + LABEL_OFFSET_Y,
                    ),
                    position_label,
                    fill=LABEL_COLOUR,
                    font=font,
                )

    output_path = folder / f"{folder.name}{OUTPUT_SUFFIX}"
    montage.save(
        windows_long_path(output_path),
        format="JPEG",
        quality=JPEG_QUALITY,
        subsampling=0,
        dpi=(OUTPUT_DPI, OUTPUT_DPI),
    )

    print(f"Created: {output_path}")
    print(f"Images included: {len(processed_sources)}")

    if missing_positions:
        print(
            f"Missing positions ({len(missing_positions)}): "
            + ", ".join(missing_positions)
        )

    if RETURN_SOURCE_IMAGES_TO_ONLINE_ONLY:
        for source_path in processed_sources:
            make_online_only(source_path)

        print("Processed source JPGs were returned to online-only status.")

    return output_path


# =============================================================================
# PROCESS THE FOLDERS CONCURRENTLY
# =============================================================================

def main():
    """Generate the requested montages, processing four folders at a time."""

    if not IMAGE_FOLDERS:
        raise ValueError("Add at least one folder to IMAGE_FOLDERS.")

    completed_outputs = []
    failed_folders = []

    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        future_to_folder = {
            executor.submit(make_montage, folder): folder
            for folder in IMAGE_FOLDERS
        }

        for future in as_completed(future_to_folder):
            folder = future_to_folder[future]

            try:
                completed_outputs.append(future.result())
            except Exception as error:
                failed_folders.append((folder, error))
                print(f"\nFailed: {folder}\nReason: {error}")

    print("\nMontage processing complete.")
    print(f"Successful montages: {len(completed_outputs)}")
    print(f"Failed folders: {len(failed_folders)}")

    if completed_outputs:
        print("\nCreated montage files:")
        for output_path in completed_outputs:
            print(f"  {output_path}")

    if failed_folders:
        print("\nFolders requiring attention:")
        for folder, error in failed_folders:
            print(f"  {folder}: {error}")


if __name__ == "__main__":
    main()

