"""Breast Density · 유방 MG CC 분석 (간단 버전 · 환자별 QC).

mView 에서 MG 를 보다가 버튼을 누르면, 그 환자의 로딩된 MG 경로가 `<datadir>/job_<token>.json`
으로 전달되고 브라우저가 `http://127.0.0.1:<port>/?job=<token>` 로 열린다(환자마다 새 탭).
이 페이지는 query param 의 token 으로 해당 job 파일을 읽어 그 환자를 분석한다. 탭마다 URL 이
달라 환자 혼선이 없다(폴링/auto-refresh 불필요).

CC(좌/우)만 분석하고, 같은 side 에 CC 가 여러 장이면 부피·밀도를 산술평균해 side 별 한 줄로
요약한다. 영상별 상세 UI 는 띄우지 않고 썸네일+요약표만 보여준다(QC 가 목적).
통계·분포·백분위·저장은 이 간단 버전의 범위가 아니다(복잡 버전 별도).
"""
import argparse
import html
import io
import json
import os
from pathlib import Path

import numpy as np
import pandas as pd
import pydicom
import streamlit as st
import streamlit.components.v1 as components
from PIL import Image, ImageOps

from dicom_io import read_image
from acceleration import analyze_accelerated
from planning import LABELS, descriptors
from cc_select import select_cc


def get_datadir():
    """job 파일 위치(datadir) 를 얻는다.

    우선순위: 실행 인자 `--datadir` → 환경변수 `BREASTDENSITY_DATADIR`.
    (streamlit run app.py -- --datadir <경로>, 또는 AppTest/배포 시 환경변수.)
    """
    parser = argparse.ArgumentParser()
    parser.add_argument('--datadir', default=None)
    args, _ = parser.parse_known_args()
    return args.datadir or os.environ.get('BREASTDENSITY_DATADIR')


DATADIR = get_datadir()

st.set_page_config(page_title='Breast Density · MG CC QC', layout='wide')
st.title('Breast Density · 유방 MG CC 분석 (QC)')
st.caption('OpenBreast 알고리즘 기반')


def load_job(token):
    """datadir 의 job_<token>.json 을 읽어 paths 리스트를 돌려준다. 없거나 깨졌으면 None."""
    if not DATADIR or not token:
        return None
    # token 은 파일명에 들어가므로 경로 조작 방지를 위해 영숫자/대시/언더스코어만 허용.
    if not all(c.isalnum() or c in '-_' for c in token):
        return None
    path = Path(DATADIR) / f'job_{token}.json'
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding='utf-8'))
    except (ValueError, OSError):
        return None


job_token = st.query_params.get('job')
if not DATADIR:
    st.warning('실행 인자 --datadir 가 없습니다. mView 가 전달하는 job 파일 위치를 지정해야 합니다.')
    st.stop()
if not job_token:
    st.info('mView 에서 유방촬영(MG) 을 열고 분석 버튼을 누르면 새 탭에 결과가 표시됩니다.')
    st.stop()

job = load_job(job_token)
if not job or not job.get('paths'):
    st.error('이 분석 요청(job) 을 찾을 수 없거나 경로가 비어 있습니다. mView 에서 다시 실행하세요.')
    st.stop()

# --- 경로 → DICOM bytes (로컬에 실제로 존재하는 것만) ---
# job json 은 Python 이 삭제하지 않는다. 경로가 mView DUMP 폴더라, mView 종료 시 원본 DICOM 과
# 함께 job 파일도 mView 가 정리한다(BreastDensityLauncher.stopServer → cleanupAllJobFiles).
# 세션 중에는 rerun(재실행) 시 다시 읽을 수 있도록 남겨둔다.
items = []
missing = []
for path in job['paths']:
    try:
        items.append((Path(path).name, Path(path).read_bytes()))
    except OSError:
        missing.append(path)


# --- 환자 정보 (상단 고정 헤더용) ---
# 표시값은 mView 가 job json(job.patient)에 실어주는 값을 우선한다. DUMP 의 DICOM PatientName 은
# 로마자만 있거나 한글 charset 이 안 풀려 영문만 보이는 경우가 있어, 한글명·검사/시리즈 설명은
# mView 가 DB 기준으로 넘겨준 값(getFullPatientName/StudyDescription/SeriesDescription)을 쓴다.
# job.patient 가 없으면(구 schema) DICOM 에서 읽은 값으로 폴백한다(하위호환).
def _format_person_name(pn):
    """DICOM PersonName 을 '한글 (영문)' 형태로 조합한다.

    PN VR 은 'Alphabetic=Ideographic=Phonetic' 그룹을 가진다(예: 'HONG^GILDONG=홍^길동').
    한글(ideographic)과 영문(alphabetic)이 모두 있으면 '한글 (영문)' 으로, 한쪽만 있으면 그것만
    표시한다. PN 구분자 '^' 는 공백으로 치환한다. (mView 가 job.patient.name 으로 넘기는 경우엔
    이미 'getFullPatientName()' 이 같은 형식으로 조합해 주므로 그 값이 우선 사용된다.)
    """
    if pn is None:
        return ''
    try:
        ideographic = ' '.join(str(getattr(pn, 'ideographic', '') or '').replace('^', ' ').split())
        alphabetic = ' '.join(str(getattr(pn, 'alphabetic', '') or '').replace('^', ' ').split())
    except Exception:
        return ' '.join(str(pn).replace('^', ' ').split())
    if ideographic and alphabetic and ideographic != alphabetic:
        return f'{ideographic} ({alphabetic})'
    return ideographic or alphabetic or ' '.join(str(pn).replace('^', ' ').split())


def read_patient_info(raw):
    try:
        ds = pydicom.dcmread(io.BytesIO(raw), stop_before_pixels=True, force=True)
    except Exception:
        return {}
    return {
        'id': str(getattr(ds, 'PatientID', '')),
        'name': _format_person_name(getattr(ds, 'PatientName', None)),
        'sex': str(getattr(ds, 'PatientSex', '')),
        'birth': str(getattr(ds, 'PatientBirthDate', '')),
        'date': str(getattr(ds, 'StudyDate', '')),
        'study_desc': str(getattr(ds, 'StudyDescription', '')),
        'series_desc': str(getattr(ds, 'SeriesDescription', '')),
    }


pinfo = read_patient_info(items[0][1]) if items else {}
# mView 가 넘긴 환자정보(job.patient)를 병합한다. mView(DB) 값을 우선하고, 없으면 DICOM 값을 유지한다.
# birthdate·examdate 도 mView 가 raw(YYYYMMDD) 로 정규화해 보내므로(capturePatientInfo→digitsOnly) 포맷이
# 일관되고, DICOM 의 placeholder/익명화 날짜가 정확한 DB 값을 덮어쓰지 않는다. (job.sex 는 보내지 않으므로
# 성별은 DICOM 값 유지.)
_jp = job.get('patient') if isinstance(job.get('patient'), dict) else {}
for _src, _dst in (('id', 'id'), ('name', 'name'), ('sex', 'sex'), ('birth', 'birth'),
                   ('studyDate', 'date'), ('studyDescription', 'study_desc'),
                   ('seriesDescription', 'series_desc')):
    _v = _jp.get(_src)
    if _v:
        pinfo[_dst] = str(_v)
# 환자 정보를 화면 상단에 '고정(fixed)'해 스크롤해도 항상 보이게 한다. Streamlit 의 position:sticky 는
# 스크롤 컨테이너 때문에 잘 안 먹으므로 viewport 기준 position:fixed 로 두고, Streamlit 상단 헤더(Deploy 등)를
# 숨긴 뒤 본문 상단에 고정바 높이만큼 여백을 준다. 검은 배경에서 잘 보이도록 글씨를 밝게.
st.markdown(
    "<style>"
    "header[data-testid='stHeader']{display:none;}"
    ".block-container{padding-top:4.4rem;}"
    "#bd-patient-bar{position:fixed;top:0;left:0;right:0;z-index:100000;"
    "display:flex;align-items:center;"
    "background:#1b1f27;color:#f2f4f8;border-bottom:1px solid #566;"
    "padding:11px 24px;font-size:15px;font-weight:500;box-shadow:0 1px 5px rgba(0,0,0,.45);"
    "white-space:nowrap;overflow-x:auto;}"
    "#bd-patient-bar b{color:#5aa9ff;font-weight:700;margin-right:9px;}"
    "#bd-patient-bar .sep{color:#55606f;margin:0 20px;}"
    "#bd-patient-bar .desc{color:#c9d2e0;}"
    # #bd-info 는 축소 허용(min-width:0)·내부 가로스크롤, #bd-actions 는 미축소(flex-shrink:0)로
    # 두어, 설명이 길어도 상단 복사 버튼이 밀려 사라지지 않게 한다(코드리뷰 지적).
    "#bd-info{flex:1 1 auto;min-width:0;overflow-x:auto;}"
    "#bd-actions{flex:0 0 auto;margin-left:auto;display:inline-flex;gap:11px;align-items:center;padding-left:28px;}"
    "#bd-actions .bd-copy{cursor:pointer;border:none;border-radius:6px;background:#2563eb;color:#fff;"
    "padding:7px 15px;font-size:13px;font-weight:600;}"
    "#bd-actions .bd-copy:hover{background:#1d4fd7;}"
    "#bd-actions #bdmsg{font-size:12px;min-width:44px;}"
    "</style>",
    unsafe_allow_html=True,
)
_pe = {k: html.escape(str(v)) for k, v in pinfo.items()}  # HTML 안전(이름에 특수문자 대비)
# 모든 정보를 한 줄에 표시(여백 여유). 라벨과 값 사이는 b{margin-right} 로 띄운다. 길면 가로 스크롤.
# 오른쪽 여백(#bd-actions)에는 아래 components 스크립트가 복사/CSV 버튼을 주입한다.
_parts = [
    f"<b>환자</b>{_pe.get('id','')} · {_pe.get('name','')} ({_pe.get('sex','')}/{_pe.get('birth','')})",
    f"<b>검사일</b>{_pe.get('date','')}",
]
if _pe.get('study_desc'):
    _parts.append(f"<span class='desc'><b>검사</b>{_pe['study_desc']}</span>")
if _pe.get('series_desc'):
    _parts.append(f"<span class='desc'><b>시리즈</b>{_pe['series_desc']}</span>")
st.markdown(
    "<div id='bd-patient-bar'><span id='bd-info'>"
    + "<span class='sep'>|</span>".join(_parts)
    + "</span></div>",
    unsafe_allow_html=True,
)

def analyze_cc(rec):
    """CC 한 장을 기본 설정으로 분석 + 썸네일 생성. digest 로 session_state 캐시.

    설정 UI 는 없다(QC 간소화). 전처리는 DICOM 태그 기준, 윤곽 threshold 는 자동,
    MAG 피부 제외 4mm·dense threshold +0.07 은 알고리즘 기본값(analyze_accelerated 기본)이다.
    CC 는 대흉근 제외(skip_pectoral)·하부 절단(inferior_cut)이 없다.
    """
    digest = rec['digest']
    cache_key = 'analysis' + digest
    cached = st.session_state.get(cache_key)
    if cached is not None:
        return cached

    image, working_spacing, step, warnings = read_image(rec['raw'])
    result = analyze_accelerated(image, False, step, backend='auto')
    meta = rec['meta'].copy()
    values = descriptors(result['breast_mask'], working_spacing, meta, result['density_percent'])

    # overlay: 녹색=유방, 분홍=고밀도
    rgb = np.repeat(image[..., None], 3, axis=2)
    bm, dm = result['breast_mask'], result['dense_mask']
    rgb[bm] = 0.75 * rgb[bm] + 0.25 * np.array([0, 1, .5])
    rgb[dm] = 0.55 * rgb[dm] + 0.45 * np.array([1, .1, .5])
    thumb_orig = ImageOps.pad(Image.fromarray(np.rint(np.clip(image, 0, 1) * 255).astype('uint8')),
                              (240, 300), method=Image.Resampling.LANCZOS, color=0)
    thumb_over = ImageOps.pad(Image.fromarray(np.rint(np.clip(rgb, 0, 1) * 255).astype('uint8')),
                              (240, 300), method=Image.Resampling.LANCZOS, color=0)
    out = {'values': values, 'thumb_orig': thumb_orig, 'thumb_over': thumb_over,
           'warnings': warnings + result['warnings'], 'execution': result['execution']}
    st.session_state[cache_key] = out
    return out


# --- CC 선별 ---
accepted, rejected = select_cc(items)
if not accepted['R'] and not accepted['L']:
    st.error('분석할 CC 영상이 없습니다. (MLO·tomo·AI결과 이미지만 있거나 경로 오류)')
    with st.expander('제외된 영상'):
        for name, reason in rejected:
            st.write(f'- {name}: {reason}')
    st.stop()

# --- 분석 (side 별) ---
# 한 장 분석 실패(윤곽 추출 실패 등 정상 임상 상황에서도 raise 가능)가 환자 전체 QC 를
# 막지 않도록 영상별로 격리한다. 실패 장은 제외 목록에 남겨 QC 투명성을 유지한다.
side_results = {'R': [], 'L': []}
for side in ('R', 'L'):
    for rec in accepted[side]:
        try:
            side_results[side].append(analyze_cc(rec))
        except Exception as exc:
            rejected.append((rec['name'], f'분석 실패: {exc}'))

if not side_results['R'] and not side_results['L']:
    st.error('CC 영상 분석에 모두 실패했습니다. 전처리·윤곽·spacing 을 확인하세요.')
    with st.expander('제외/실패 영상'):
        for name, reason in rejected:
            st.write(f'- {name}: {reason}')
    st.stop()

# --- 썸네일: 한 줄에 4개(원본/분석 쌍은 인접 유지). 존재하는 side·복수 CC 모두 표시(편측 허용) ---
st.subheader('CC 영상 (원본 / 분석 overlay)')
st.caption('녹색: 유방 영역 · 분홍색: 고밀도(MAG) 영역')
thumbs = []
for side in ('R', 'L'):
    results = side_results[side]
    for idx, res in enumerate(results):
        suffix = '' if len(results) == 1 else f' #{idx + 1}'
        thumbs.append((f'{side}CC{suffix} 원본', res['thumb_orig']))
        thumbs.append((f'{side}CC{suffix} 분석', res['thumb_over']))
for start in range(0, len(thumbs), 4):
    for col, (caption, img) in zip(st.columns(4), thumbs[start:start + 4]):
        col.image(img, caption=caption, width=240)


def side_average(results):
    """side 의 여러 CC 지표를 산술평균(None 제외). 전부 None 이면 None(표에서 '미측정'으로 표시).

    주의: 압박두께(thickness)도 평균하지만, 복수 CC 가 재촬영이면 압박 조건이 다를 수 있어
    평균 두께는 엄밀히는 '존재하지 않는 중간값'이다. 간단 QC 범위에서는 산술평균으로 둔다.
    숫자/문자 혼재로 표 dtype 이 깨지지 않도록 미측정은 None 으로 두고 표시에서만 치환한다.
    """
    row = {}
    for metric in LABELS:
        vals = [r['values'][metric] for r in results if r['values'].get(metric) is not None]
        row[LABELS[metric]] = round(float(np.mean(vals)), 2) if vals else None
    return row


# --- 요약 표: side 별 1행 (복수면 산술평균) ---
st.subheader('요약 (좌/우 CC)')
summary_rows = []
for side in ('R', 'L'):
    results = side_results[side]
    if not results:
        continue
    summary_rows.append({'좌우': side, '장수': len(results), **side_average(results)})
summary_df = pd.DataFrame(summary_rows)
# 지표 컬럼은 숫자 dtype 유지, 미측정(None/NaN)은 '미측정'으로 표시(숫자열 오염 방지).
metric_cols = [c for c in summary_df.columns if c in LABELS.values()]
st.dataframe(summary_df.style.format(na_rep='미측정', precision=2, subset=metric_cols),
             hide_index=True)
st.caption('같은 side CC 가 2장 이상이면 부피·밀도 등 지표는 산술평균입니다. 체적은 CC 투영면적 × 압박두께 근사치, '
           'MAG %는 면적밀도(BI-RADS·체적밀도 아님).')

# --- 경고 / 제외 영상 (QC 투명성) ---
all_warnings = [w for side in ('R', 'L') for r in side_results[side] for w in r['warnings']]
if all_warnings:
    with st.expander(f'경고 {len(all_warnings)}건'):
        for warning in all_warnings:
            st.warning(warning)
if rejected or missing:
    with st.expander(f'제외/누락 영상 {len(rejected) + len(missing)}건'):
        for name, reason in rejected:
            st.write(f'- (제외) {name}: {reason}')
        for path in missing:
            # 전체 경로는 환자 식별 가능 → 파일명만 표시(PHI 노출 최소화).
            st.write(f'- (누락) {Path(path).name}: 로컬 파일 없음')


# --- Excel 내보내기 (클립보드 복사 / CSV) ---
# 박 이사 요청: 결과를 단추 하나로 클립보드에 복사 → Excel 에 바로 붙여넣기. 열 구성은 요청 양식 기준.
# 정렬/필터가 되도록 ID·birthdate·examdate 는 좌/우 모든 행에 채운다.
# 수기 입력용 '권장 최대 깊이' 빈 열은 제거함 — TSV·CSV 모두 끝에 빈 열이 붙어 Excel 에서 불필요한
# 빈 열이 생기므로(사용자 요청). 깊이는 Excel 쪽에서 직접 열을 추가해 입력한다.
st.subheader('Excel 내보내기')
EXPORT_COLUMNS = ['ID', 'birthdate', 'examdate', '좌우', '유방투영 면적', '가로범위',
                  '세로 범위', '압박 두께', '체적 근사치', '면적밀도(%)']
_LABEL_TO_COL = {'유방 투영 면적 (cm²)': '유방투영 면적', '가로 범위 (mm)': '가로범위',
                 '세로 범위 (mm)': '세로 범위', '압박두께 (mm)': '압박 두께',
                 '체적 근사치 (mL)': '체적 근사치', '면적밀도 (%)': '면적밀도(%)'}
export_rows = []
for row in summary_rows:
    # ID·birthdate·examdate 는 R/L 구분 없이 매 행에 채운다(Excel 정렬·필터용).
    er = {'ID': pinfo.get('id', ''), 'birthdate': pinfo.get('birth', ''),
          'examdate': pinfo.get('date', ''), '좌우': row['좌우']}
    for label, col in _LABEL_TO_COL.items():
        value = row.get(label)
        er[col] = '' if value is None else value
    export_rows.append(er)
export_df = pd.DataFrame(export_rows, columns=EXPORT_COLUMNS)


def _to_tsv(frame, with_header):
    """Excel 붙여넣기용 탭 구분 텍스트. None/NaN 은 빈칸으로."""
    lines = ['\t'.join(map(str, frame.columns))] if with_header else []
    for _, record in frame.iterrows():
        lines.append('\t'.join('' if pd.isna(cell) else str(cell) for cell in record))
    return '\n'.join(lines)


# CSV 와 클립보드 TSV 는 동일한 열 구성(export_df)을 쓴다 — 끝에 붙던 빈 열은 제거(사용자 요청).
csv_df = export_df
_fname = 'breast_density'
if pinfo.get('id'):
    _fname += '_' + ''.join(c for c in pinfo['id'] if c.isalnum())
if pinfo.get('date'):
    _fname += '_' + ''.join(c for c in pinfo['date'] if c.isalnum())

# 내보내기 컨트롤을 한 줄(가로)로: [클립보드 복사] [헤더 포함] [CSV 다운로드].
col_copy, col_hdr, col_csv = st.columns([2.4, 1, 1.1], vertical_alignment='center')
include_header = col_hdr.checkbox('헤더 포함', value=False,
                                  help='여러 환자를 한 시트에 누적할 땐 끄세요(기본). 처음 한 번 열 이름이 필요하면 켜기.')
tsv_text = _to_tsv(export_df, include_header)

# 클립보드 복사 JS: win.navigator.clipboard(보안 컨텍스트=localhost) 우선, 막히면 execCommand('copy') 폴백.
# 하단 버튼과 헤더 주입 버튼이 같은 로직을 쓰도록 함수(bdCopy)로 1곳에만 정의한다(중복/드리프트 방지).
# doc/win 을 인자로 받아 자기 문서(하단)와 부모 문서(헤더 주입) 양쪽에서 재사용한다.
_COPY_FN = (
    "async function bdCopy(t,doc,win,msgEl){let ok=false;"
    "try{if(win.navigator&&win.navigator.clipboard){await win.navigator.clipboard.writeText(t);ok=true;}}catch(e){}"
    "if(!ok){try{const a=doc.createElement('textarea');a.value=t;a.style.position='fixed';a.style.opacity='0';"
    "doc.body.appendChild(a);a.focus();a.select();ok=doc.execCommand('copy');doc.body.removeChild(a);}catch(e){ok=false;}}"
    "if(msgEl){msgEl.textContent=ok?'복사됨':'복사 실패';msgEl.style.color=ok?'#16a34a':'#dc2626';"
    "setTimeout(function(){msgEl.textContent='';},2500);}return ok;}"
)
# tsv_text 를 JS 문자열 리터럴로 임베드. json.dumps 는 '/'를 이스케이프하지 않아 데이터에 '</script>' 가
# 섞이면 <script> 블록이 조기 종료될 수 있으므로 '</' → '<\/' 로 치환한다(코드리뷰 지적).
_tsv_js = json.dumps(tsv_text).replace('</', '<\\/')

with col_copy:
    components.html(
        "<div style=\"font-family:'Segoe UI',Malgun Gothic,sans-serif;\">"
        "<button id='bdcopy' style=\"padding:9px 18px;font-size:14px;cursor:pointer;color:#fff;"
        "background:#2563eb;border:none;border-radius:6px;\">\U0001F4CB 클립보드 복사 (Excel 붙여넣기)</button>"
        "<span id='bdmsg' style='margin-left:10px;font-size:13px;'></span>"
        "<script>" + _COPY_FN + "const t=" + _tsv_js + ";"
        "document.getElementById('bdcopy').addEventListener('click',function(){"
        "bdCopy(t,document,window,document.getElementById('bdmsg'));});</script></div>",
        height=48,
    )
# CSV 다운로드: Excel 한글 깨짐 방지 UTF-8 BOM. 파일명에 환자ID·검사일(영숫자만).
col_csv.download_button('CSV 다운로드', data=csv_df.to_csv(index=False).encode('utf-8-sig'),
                        file_name=_fname + '.csv', mime='text/csv')

# 추가: 상단 고정 헤더(#bd-patient-bar) 오른쪽 여백에도 동일한 '클립보드 복사' 버튼을 주입한다 —
# 결과를 아래로 스크롤하지 않고도 항상 누를 수 있도록(하단 내보내기 섹션은 그대로 유지).
# st.markdown 은 <script> 를 제거하므로 same-origin(srcdoc) components.html 에서 부모 DOM 에 추가한다.
# 부모 접근(window.parent.document)이 막히는 환경(cross-origin/sandbox)에서도 앱이 깨지지 않도록 전체를
# try/catch 로 감싸 실패 시 조용히 포기(하단 버튼이 폴백). bar 미생성 시 재시도는 상한(25회=3초)을 둔다.
# 복사 내용은 하단 '헤더 포함' 토글을 반영한다(토글 시 rerun → 재주입).
components.html(
    "<script>" + _COPY_FN + "const t=" + _tsv_js + ";let bdTries=0;"
    "function build(){try{"
    "const pdoc=window.parent.document;"
    "const bar=pdoc.getElementById('bd-patient-bar');if(!bar){if(bdTries++<25)setTimeout(build,120);return;}"
    "const old=pdoc.getElementById('bd-actions');if(old)old.remove();"
    "const w=pdoc.createElement('span');w.id='bd-actions';"
    "const cp=pdoc.createElement('button');cp.className='bd-copy';cp.textContent='\U0001F4CB 클립보드 복사';"
    "cp.title='표 데이터를 복사해 Excel 에 붙여넣기';"
    "const ms=pdoc.createElement('span');ms.id='bdmsg';"
    "cp.onclick=function(){bdCopy(t,pdoc,window.parent,ms);};"
    "w.appendChild(cp);w.appendChild(ms);bar.appendChild(w);"
    "}catch(e){}}build();"
    "</script>",
    height=0,
)

with st.expander('복사가 안 되면 여기서 직접 선택·복사'):
    st.code(tsv_text, language=None)
