import io,hashlib
import numpy as np
import pydicom
from pydicom.pixels import apply_voi_lut,apply_modality_lut
from skimage.transform import resize

def finite(ds,name):
    try:
        value=float(getattr(ds,name));return value if np.isfinite(value) else None
    except (AttributeError,ValueError,TypeError):return None

def positive(ds,name):
    v=finite(ds,name);return v if v is not None and v>0 else None

def header(raw):
    # force=True: PACS 캐시의 DICOM 파일은 128바이트 preamble + 'DICM' 매직(File Meta)이 없는 경우가
    # 많다(원시 데이터셋 저장). force 로 그런 파일도 읽는다. MG/단일프레임/MONOCHROME 검증은 아래에서 수행.
    ds=pydicom.dcmread(io.BytesIO(raw),stop_before_pixels=True,force=True)
    if str(getattr(ds,'Modality',''))!='MG': raise ValueError('단일 프레임 MG mammography만 지원합니다.')
    if int(getattr(ds,'NumberOfFrames',1))!=1 or int(getattr(ds,'SamplesPerPixel',1))!=1:
        raise ValueError('DBT 다중 프레임 / 컬러 영상은 지원하지 않습니다.')
    photo=str(getattr(ds,'PhotometricInterpretation',''))
    if photo not in ('MONOCHROME1','MONOCHROME2'):raise ValueError('지원하지 않는 PhotometricInterpretation')
    spacing=None;source=None
    for name in ('PixelSpacing','ImagerPixelSpacing'):
        try:
            values=[float(x) for x in getattr(ds,name)]
            if len(values)==2 and all(np.isfinite(v) and v>0 for v in values):spacing=values;source=name;break
        except (AttributeError,ValueError,TypeError):pass
    view=str(getattr(ds,'ViewPosition','')).upper().strip()
    if not view and hasattr(ds,'ViewCodeSequence'):
        for item in ds.ViewCodeSequence:
            meaning=str(getattr(item,'CodeMeaning','')).lower()
            if 'medio' in meaning and 'oblique' in meaning:view='MLO'
            elif 'cranio' in meaning:view='CC'
    meta={'side':str(getattr(ds,'ImageLaterality',getattr(ds,'Laterality',''))).upper(),
          'view':view,'spacing_mm':spacing,'spacing_source':source,
          'thickness_mm':positive(ds,'BodyPartThickness'),'compression_force_N':positive(ds,'CompressionForce'),
          'kvp':positive(ds,'KVP'),'exposure_mAs':positive(ds,'ExposureInmAs'),
          'intent':str(getattr(ds,'PresentationIntentType','UNKNOWN')),'manufacturer':str(getattr(ds,'Manufacturer','')),
          'photometric':photo,'rows':int(ds.Rows),'columns':int(ds.Columns),
          'bits_stored':int(getattr(ds,'BitsStored',0)),'implant':str(getattr(ds,'BreastImplantPresent','UNKNOWN')),
          'sop_class':str(getattr(ds,'SOPClassUID',''))}
    identity=(str(getattr(ds,'PatientID','')),str(getattr(ds,'IssuerOfPatientID','')),str(getattr(ds,'StudyInstanceUID','')))
    return meta,identity

def normalize(a):
    a=np.asarray(a,dtype=np.float64)
    lo,hi=float(a.min()),float(a.max())
    if not np.isfinite(a).all() or hi-lo<1e-8:raise ValueError('유효한 영상 대비가 없습니다.')
    return ((a-lo)/(hi-lo)).astype('float32')

# GDCM 이 지원하지 않는 유일한 JPEG 계열 TS. 이 TS 만 imagecodecs(BSD) 로 폴백한다("GDCM does not
# support JPEG Extended for 12-bit"). 그 외 TS 는 python-gdcm/pydicom 경로를 그대로 쓴다.
_JPEG_EXTENDED_TS='1.2.840.10008.1.2.4.51'

def _decode_pixel_array(ds):
    """ds.pixel_array 를 반환하되, 설치 디코더가 처리 못하는 .51 TS 는 imagecodecs 로 폴백한다.

    pylibjpeg-libjpeg(GPL-3.0) 제거 후 JPEG 계열 디코딩은 python-gdcm(Apache)가 담당하나, '.51 JPEG
    Extended(Process 2&4) 12-bit' 는 GDCM 이 지원하지 않는다. 이 TS 에 한해 imagecodecs(BSD) 로 캡슐화
    프레임을 직접 디코딩한다(뷰어 dicom_loader._decode_pixel_array 이식, 작업지시서 20261022_01).

    폴백 트리거는 선분기(선-TS-검사)가 아니라 catch-then-check 다 — 정상 디코딩되면 gdcm/pydicom 경로를
    그대로 쓰고(동작 불변), pixel_array 가 실패할 때만 TS 를 확인해 .51 이면 폴백한다. imagecodecs 미설치/
    디코딩 실패면 원 예외를 그대로 전파한다(.51 은 MG 에 희귀 — known-limitation).

    주의: 픽셀 존재 확인이 필요하면 'PixelData' in ds 로 할 것. hasattr(ds,'pixel_array') 는 프로퍼티를
    평가해 .51 디코딩 예외를 유발, 폴백을 무력화하는 dead-code 가 된다(뷰어 코드리뷰 회귀).
    """
    try:
        return ds.pixel_array
    except Exception:
        ts=str(getattr(getattr(ds,'file_meta',None),'TransferSyntaxUID',''))
        if ts==_JPEG_EXTENDED_TS:
            arr=_decode_via_imagecodecs(ds)
            if arr is not None:return arr
        # 폴백 대상이 아니거나 실패 → 원 예외 전파(read_image 호출부가 처리)
        raise

def _decode_via_imagecodecs(ds):
    """캡슐화된 .51 JPEG Extended 프레임을 imagecodecs 로 디코딩해 pixel_array 규약의 ndarray 로 반환.

    breast-density 는 header() 가 단일 프레임·MONOCHROME 만 허용하므로(다중 프레임·컬러는 디코딩 전에 거부)
    단일 프레임만 디코딩하고 YBR→RGB 분기는 두지 않는다. imagecodecs 미설치/실패 시 None(상위에서 원 예외 전파).

    반환 dtype 규약은 ds.pixel_array 와 동일해야 이후 apply_modality_lut/apply_voi_lut/MONOCHROME1 반전이
    raw-TS(gdcm) 경로와 같은 수치로 동작한다 — unsigned 선언은 그대로, PixelRepresentation==1(signed) 이면
    BitsStored 기준 sign-extend 하여 int16 으로 복원한다(imagecodecs 는 12-bit 를 unsigned 로 돌려주므로).
    """
    try:
        import imagecodecs
        from pydicom.encaps import generate_frames
    except ImportError:
        return None
    try:
        # generate_frames: 캡슐화 PixelData 에서 프레임별 JPEG 바이트 순회(pydicom 3.x). 단일 프레임 전제.
        frames=list(generate_frames(ds.PixelData,number_of_frames=1))
        if not frames:return None
        frame=np.squeeze(np.asarray(imagecodecs.jpeg_decode(frames[0])))
        # signed sign-extend — imagecodecs 가 unsigned 로 돌려줬고 DICOM 이 signed 를 선언한 경우에만.
        if int(getattr(ds,'PixelRepresentation',0) or 0)==1 and np.issubdtype(frame.dtype,np.unsignedinteger):
            bits=int(getattr(ds,'BitsStored',0) or 0) or int(getattr(ds,'BitsAllocated',0) or 0) or frame.dtype.itemsize*8
            # 상위 비트(2^(bits-1))가 1인 값은 음수이므로 2^bits 를 빼서 복원한다.
            frame32=frame.astype(np.int32)
            frame=np.where(frame32>=(1<<(bits-1)),frame32-(1<<bits),frame32).astype(np.int16)
        return frame
    except Exception:
        return None

def read_image(raw,spacing='from_tags',mode='dicom',target_step=.4):
    ds=pydicom.dcmread(io.BytesIO(raw),force=True);meta,_=header(raw)
    if spacing=='from_tags':spacing=meta['spacing_mm']
    a=_decode_pixel_array(ds)
    if a.ndim!=2:raise ValueError('2D grayscale pixel array가 아닙니다.')
    warnings=[]
    if mode=='legacy':
        a=a.astype('float64'); vendor=meta['manufacturer'].upper()
        if meta['intent']=='FOR PROCESSING':
            a=(16383.-np.log(np.clip(a,1,16383)))**2
            warnings.append('원본 OpenBreast RAW 14-bit 변환 가정 사용')
        elif any(v in vendor for v in ('FUJIFILM','SECTRA','PHILIPS','AGFA')):
            a=a.max()-a
        else:
            a=np.clip(a,0,4095)
            warnings.append('원본 OpenBreast processed 12-bit 범위 가정 사용')
        image=normalize(a)
    else:
        a=apply_modality_lut(a,ds)
        if meta['intent']=='FOR PROCESSING':
            # Dynamic-range-aware logarithmic inversion: adaptation, not MATLAB exact expression.
            a=np.log1p(np.maximum(a.astype('float64')-float(np.min(a)),0))
            a=float(a.max())-a
            warnings.append('FOR PROCESSING: dynamic-range log-inversion 적용; 공급사별 검증 필요')
        else:
            try:
                if np.issubdtype(a.dtype,np.floating) and np.all(a==np.floor(a)):
                    a=a.astype('int64')
                a=apply_voi_lut(a,ds)
            except (ValueError,KeyError,TypeError):warnings.append('VOI LUT 적용 실패: 저장 pixel 사용')
            if meta['photometric']=='MONOCHROME1':a=float(np.max(a))+float(np.min(a))-a
        image=normalize(a)
    if spacing:
        dy,dx=spacing
        step=max(float(target_step),image.shape[0]*dy/1600,image.shape[1]*dx/1600)
        # Rounded working dimensions yield slightly different actual row/column spacing.
        shape=(max(16,int(round(image.shape[0]*dy/step))),max(16,int(round(image.shape[1]*dx/step))))
        working_spacing=[image.shape[0]*dy/shape[0],image.shape[1]*dx/shape[1]]
        # Record actual anisotropy; MAG uses mean step, geometry uses actual values.
        mag_step=float(np.mean(working_spacing))
    else:
        scale=min(1,1200/max(image.shape));shape=(max(16,int(round(image.shape[0]*scale))),max(16,int(round(image.shape[1]*scale))))
        working_spacing=None;mag_step=None
        warnings.append('pixel spacing 미확인: 물리 크기·밀도·체적 미측정')
    image=resize(image,shape,order=1,preserve_range=True,anti_aliasing=True).astype('float32')
    return image,working_spacing,mag_step,warnings

def checksum(raw):return hashlib.sha256(raw).hexdigest()
