#!/usr/bin/env python3
"""Apply BFS to Scenes A/B with direction-matched eye-level head crops.

First run --prepare-reference to crop source pixels without generation or masks.
Then run the default command to apply BFS to the reviewed Scene A/B identity results.
"""
from __future__ import annotations

import argparse
import json
import re
import time
from pathlib import Path

import hashlib
import importlib.metadata
import platform
import sys
from PIL import Image

ASSETS = Path(__file__).resolve().parent.parent

PROJECT_ROOT = ASSETS.parents[3]

CACHE_DIR = PROJECT_ROOT / ".tmp" / "download" / "huggingface" / "hub"

MODEL_ID = "Qwen/Qwen-Image-Edit-2511"

def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as source:
        for block in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()

def runtime_record() -> dict[str, object]:
    packages = {}
    for package in ("diffusers", "torch", "transformers", "accelerate"):
        try:
            packages[package] = importlib.metadata.version(package)
        except importlib.metadata.PackageNotFoundError:
            packages[package] = "not-installed"
    return {
        "python": sys.version.split()[0],
        "platform": platform.platform(),
        "packages": packages,
    }

def square_canvas(path: Path, size: int) -> Image.Image:
    """Return an RGB, white-backed square canvas without distorting the input."""
    with Image.open(path) as source:
        source = source.convert("RGBA")
        source.thumbnail((size, size), Image.Resampling.LANCZOS)
        canvas = Image.new("RGBA", (size, size), "white")
        offset = ((size - source.width) // 2, (size - source.height) // 2)
        canvas.alpha_composite(source, offset)
    return canvas.convert("RGB")

SOURCES = {
    'a': 'sec-05/p7-5-5-qwen-2511-cutout-identity-a-mira-audit-20260909-v1-size-1280x1280-seed-62294-steps-30.png',
    'b': 'sec-05/p7-5-5-qwen-2511-mannequin-outfit-b-mira-stage3-v1-size-1280x1280-seed-62294-steps-20.png',
    'c': 'sec-05/p7-5-5-qwen-2511-identity-c-mira-stage2-book-v1-size-1280x1280-seed-62294-steps-20.png',
}
DEFAULT_FACE = ASSETS / 'sec-02/p7-5-2-mira-head-qwen-image-bf16-front-v1-code-63ece7-seed-62294-steps-30-size-1280.png'
REFERENCES = {
    'a': ('sec-02/p7-5-2-qwen-2511-mira-torso-multiview-vertical-level-yaw-zero-native1280-v1-size-1280x1280-seed-62294-steps-4.png', (320, 10, 960, 650), False, 1280),
    'b': ('sec-02/p7-5-2-qwen-2511-mira-torso-multiview-vertical-level-yaw-plus-45-native1024-v1-size-1024x1024-seed-62294-steps-4.png', (280, 10, 792, 522), False, 1024),
}


ZERO_REFERENCES = {
    'a': ('sec-02/p7-5-2-qwen-2511-mira-torso-multiview-vertical-low-yaw-zero-lowzero-repeat-v1-size-1280x1280-seed-62295-steps-4.png', (400, 40, 912, 552), False, 1280),
    'b': ('sec-02/p7-5-2-qwen-2511-mira-torso-multiview-vertical-level-yaw-zero-native1280-v1-size-1280x1280-seed-62294-steps-4.png', (320, 10, 960, 650), False, 1280),
}


LOW45_REFERENCES = {
    'b': ('sec-02/p7-5-2-qwen-2511-mira-torso-multiview-vertical-low-yaw-plus-45-native1280-v1-size-1280x1280-seed-62294-steps-4.png', (360, 20, 1000, 660), False, 1280),
}


def crop_paths(scene: str, profile: str = 'direction-v2') -> tuple[Path, Path]:
    variant = {'direction-v2': 'level-matched-native-head-crop-v2', 'zero-v3': 'zero-view-native-head-crop-v3', 'low45-v4': 'low45-native-head-crop-v4'}[profile]
    stem = f'p7-5-5-bfs-{scene}-{variant}'
    return ASSETS / 'sec-05' / f'{stem}.png', ASSETS / 'sec-05' / f'{stem}-result.json'

LORA_FILE = 'bfs_head_v5_2511_original.safetensors'
LORA_PATH = ASSETS.parents[3] / '.tmp/download/artifacts/weight-mr2along-bfs-head-v5-2511' / LORA_FILE
LORA_SHA256 = '5055ec271228d4767fd327efa59dbdea6f2a628d6d890101eb4fd5015cb1ac57'
PROMPT = (
    "head_swap: start with Picture 1 as the base image, keeping its lighting, "
    "environment, and background. remove the head from Picture 1 completely and "
    "replace it with the head from Picture 2, strictly preserving the hair, eye "
    "color, and nose structure of Picture 2. copy the eye direction, head rotation, "
    "and micro-expressions from Picture 1. high quality, sharp details, 4k"
)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--scenes', nargs='+', choices=('a', 'b'), default=['a', 'b'])
    parser.add_argument('--prepare-reference', action='store_true')
    parser.add_argument('--reference-profile', choices=('direction-v2', 'zero-v3', 'low45-v4'), default='direction-v2')
    parser.add_argument('--prompt', default=PROMPT)
    parser.add_argument('--steps', type=int, default=10)
    parser.add_argument('--seed', type=int, default=62294)
    parser.add_argument('--run-label', default='v2')
    parser.add_argument('--output-dir', type=Path, default=ASSETS / "sec-05")
    parser.add_argument('--dry-run', action='store_true')
    args = parser.parse_args()
    if args.steps < 1 or not args.prompt.strip():
        parser.error('Positive steps and a nonempty prompt are required.')
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]*', args.run_label):
        parser.error('Use letters, digits, underscores and hyphens for --run-label.')
    references = {'direction-v2': REFERENCES, 'zero-v3': ZERO_REFERENCES, 'low45-v4': LOW45_REFERENCES}[args.reference_profile]
    if any(scene not in references for scene in args.scenes):
        parser.error('The selected reference profile only supports: ' + ', '.join(references))
    if args.prepare_reference:
        from PIL import Image, ImageOps
        for view in dict.fromkeys(args.scenes):
            source_name, crop_box, mirror, source_size = references[view]
            source = ASSETS / source_name
            crop_path, crop_record = crop_paths(view, args.reference_profile)
            for path in (crop_path, crop_record):
                if path.exists():
                    raise FileExistsError(path)
            with Image.open(source) as original:
                if original.size != (source_size, source_size):
                    raise ValueError(f'Expected native {source_size}px torso: {source}')
                cropped = original.crop(crop_box)
                if mirror:
                    cropped = ImageOps.mirror(cropped)
                cropped.save(crop_path)
            crop_record.write_text(json.dumps(dict(
                operation='pixel crop with optional horizontal flip; no resizing or AI generation',
                horizontal_flip=mirror,
                input=str(source), input_sha256=sha256(source),
                crop_box=list(crop_box), output=str(crop_path), size=list(cropped.size),
                output_sha256=sha256(crop_path), source_code_sha256=sha256(Path(__file__)),
            ), indent=2) + '\n')
            print(crop_path)
        return
    if not LORA_PATH.is_file() or sha256(LORA_PATH) != LORA_SHA256:
        raise ValueError(f'Missing or mismatched BFS weights: {LORA_PATH}')
    plans = []
    for experiment in [{'direction-v2': 'level-matched', 'zero-v3': 'zero-view', 'low45-v4': 'low45'}[args.reference_profile]]:
        for scene in dict.fromkeys(args.scenes):
            source = ASSETS / SOURCES[scene]
            reference, crop_record = crop_paths(scene, args.reference_profile)
            weight = 1.0
            for path in (source, reference):
                if not path.is_file():
                    raise FileNotFoundError(path)
            variant = f'{experiment}-native-headcrop-weight10'
            stem = (f'p7-5-5-qwen-2511-bfs-reviewed-{scene}-mira-{variant}-{args.run_label}'
                    f'-size-1280x1280-seed-{args.seed}-steps-{args.steps}')
            output = args.output_dir.resolve() / f'{stem}.png'
            result = output.with_name(f'{stem}-result.json')
            for path in (output, result):
                if path.exists():
                    raise FileExistsError(f'Refusing to overwrite: {path}')
            plans.append(dict(scene=scene, experiment=experiment, reference_profile=args.reference_profile, crop_provenance=json.loads(crop_record.read_text()), lora_weight=weight, inputs=[
                dict(role='Picture 1: reviewed pre-BFS identity result', path=str(source), sha256=sha256(source)),
                dict(role='Picture 2: native head crop from direction-matched torso reference',
                     path=str(reference), sha256=sha256(reference)),
            ], output=str(output), result=str(result)))
    settings = dict(model=MODEL_ID, prompt=args.prompt.strip(), steps=args.steps, seed=args.seed,
                    size=[1280, 1280], true_cfg_scale=4.0, guidance_scale=1.0,
                    generator_device='cpu', dtype='bfloat16', mask=None, lora=dict(repository='mr2along/BFS',
                    revision='d9ffba9012dfa1b299a4294572791c3275ae6ae4',
                    file=LORA_FILE, sha256=LORA_SHA256))
    if args.dry_run:
        print(json.dumps(dict(status='planned', settings=settings, plans=plans), indent=2))
        return
    import torch
    from diffusers import QwenImageEditPlusPipeline

    if not torch.cuda.is_available():
        raise RuntimeError('A local CUDA GPU is required.')
    pipeline = QwenImageEditPlusPipeline.from_pretrained(
        MODEL_ID, torch_dtype=torch.bfloat16, cache_dir=CACHE_DIR, local_files_only=True,
    )
    pipeline.load_lora_weights(LORA_PATH.parent, weight_name=LORA_FILE,
                               adapter_name='bfs_head_v5', local_files_only=True)
    pipeline.enable_attention_slicing('max')
    pipeline.enable_sequential_cpu_offload()
    args.output_dir.mkdir(parents=True, exist_ok=True)
    provenance = dict(source_code_sha256=sha256(Path(__file__)),

                      runtime=runtime_record(), cuda_device=torch.cuda.get_device_name(0),
                      offload='sequential CPU offload',
                      preprocessing='aspect-preserving resize on white 1280x1280 canvases')
    for plan in plans:
        for item in plan['inputs']:
            if sha256(Path(item['path'])) != item['sha256']:
                raise ValueError(f"Input changed after preflight: {item['path']}")
        pipeline.set_adapters('bfs_head_v5', adapter_weights=plan['lora_weight'])
        print(f"Starting experiment {plan['experiment']} scene {plan['scene']}", flush=True)
        started = time.monotonic()
        image = pipeline(
            image=[square_canvas(Path(item['path']), 1280) for item in plan['inputs']],
            prompt=settings['prompt'], height=1280, width=1280,
            generator=torch.Generator(device='cpu').manual_seed(args.seed),
            true_cfg_scale=4.0, negative_prompt=' ', num_inference_steps=args.steps,
            guidance_scale=1.0, num_images_per_prompt=1,
        ).images[0]
        with Path(plan['output']).open('xb') as stream:
            image.save(stream, format='PNG')
        record = dict(plan, **settings, **provenance, status='generated', stage='bfs_head_crop_reference_comparison',
                      output_sha256=sha256(Path(plan['output'])),
                      elapsed_seconds=round(time.monotonic()-started, 2))
        with Path(plan['result']).open('x', encoding='utf-8') as stream:
            json.dump(record, stream, ensure_ascii=False, indent=2)
            stream.write('\n')
        print(json.dumps({'scene': plan['scene'], 'output': plan['output'], 'result': plan['result']}), flush=True)


if __name__ == '__main__':
    main()
