"""08:30 장전 자가 점검 — 통과 기록이 있어야 봇이 주문 루프를 시작한다 (KIS 기준)"""
import email.utils, json, os, shutil, sys, time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests

BASE = os.environ.get("KIS_BASE", "https://openapi.koreainvestment.com:9443")  # 모의: openapivts...:29443
CLOCK_URL = os.environ.get("CLOCK_URL", BASE)   # 키 없이 응답 헤더만 본다
PAPER = "openapivts" in BASE
HOME = Path(__file__).resolve().parent          # 작업 스케줄러는 작업 폴더가 다를 수 있다
KST = timezone(timedelta(hours=9))
NOW = datetime.now(KST)
TODAY = NOW.strftime("%Y%m%d")
CLOSE = NOW.replace(hour=17, minute=0, second=0, microsecond=0)  # 이 시각까지 살아 있어야 한다(NXT 애프터까지 돌리면 20시)
SPECIAL_OPEN = {}        # 예: {"YYYYMMDD": "10:00"} — KRX 공지를 확인하고 사람이 넣는다
CLOCK_LIMIT = 1.5        # 초. Date 헤더가 초 단위라 1초 미만은 판정 불가
MIN_FREE_GB = 5          # 하루 로그·DB 증가량에 맞춰 잡는다
env = os.environ
ACCOUNT, PRODUCT = env["KIS_CANO"], env.get("KIS_PRDT", "01")
HEAD = {"appkey": env["KIS_APPKEY"], "appsecret": env["KIS_APPSECRET"],
        "custtype": "P", "content-type": "application/json; charset=utf-8"}


def check_clock():
    t0 = time.time()
    h = requests.head(CLOCK_URL, timeout=5, allow_redirects=False).headers
    server = email.utils.parsedate_to_datetime(h["Date"]).timestamp() + 0.5  # 초 단위 내림 보정
    gap = server - (t0 + time.time()) / 2
    if abs(gap) > CLOCK_LIMIT:
        raise RuntimeError(f"서버-로컬 {gap:+.2f}초 (w32tm /resync 는 봇을 띄우기 전에)")
    return f"서버-로컬 {gap:+.2f}초"


def check_disk():
    free = shutil.disk_usage(HOME).free / 1024 ** 3
    if free < MIN_FREE_GB:
        raise RuntimeError(f"여유 {free:.1f}GB < {MIN_FREE_GB}GB")
    probe = HOME / "logs" / ".write_probe"
    probe.parent.mkdir(exist_ok=True)
    probe.write_text(TODAY)      # 공간이 있어도 권한·잠금으로 못 쓰면 여기서 터진다
    probe.unlink()
    return f"여유 {free:.1f}GB · 쓰기 OK"


def check_token():
    path = HOME / "token.json"   # {"token": "...", "expires": "2026-11-05 08:10:22"}
    saved = json.loads(path.read_text("utf-8")) if path.exists() else {}
    expires = saved.get("expires", "2000-01-01 00:00:00")
    left_until = datetime.strptime(expires, "%Y-%m-%d %H:%M:%S").replace(tzinfo=KST)
    if left_until >= CLOSE:
        return saved["token"], f"기존 토큰 만료 {expires}"
    r = requests.post(f"{BASE}/oauth2/tokenP", timeout=10, json={
        "grant_type": "client_credentials",
        "appkey": HEAD["appkey"], "appsecret": HEAD["appsecret"]})
    j = r.json()
    if r.status_code != 200 or "access_token" not in j:   # 1분 1회 제한·키 오류 모두 여기
        raise RuntimeError(f"재발급 실패 HTTP {r.status_code} {j.get('error_code')} {j.get('error_description')}")
    new = {"token": j["access_token"], "expires": j["access_token_token_expired"]}
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(new), "utf-8")
    os.replace(tmp, path)
    return new["token"], f"만료 {expires} → 재발급, 새 만료 {new['expires']}"


def kis_get(token, url, tr_id, params, tr_cont=""):
    r = requests.get(f"{BASE}{url}", params=params, timeout=10, headers={
        **HEAD, "authorization": f"Bearer {token}", "tr_id": tr_id, "tr_cont": tr_cont})
    j = r.json()
    if j.get("rt_cd") != "0":
        raise RuntimeError(f"{tr_id} {j.get('msg_cd')} {j.get('msg1')}")
    return j, r.headers.get("tr_cont", "")


def check_market_day(token):
    cache = HOME / f"holiday_{TODAY}.json"    # 공식 안내: 가급적 1일 1회
    if cache.exists():
        rows = json.loads(cache.read_text("utf-8"))
    else:
        rows = kis_get(token, "/uapi/domestic-stock/v1/quotations/chk-holiday", "CTCA0903R",
                       {"BASS_DT": TODAY, "CTX_AREA_FK": "", "CTX_AREA_NK": ""})[0]["output"]
        cache.write_text(json.dumps(rows), "utf-8")
    row = next(x for x in rows if x["bass_dt"] == TODAY)
    return row["opnd_yn"] == "Y", SPECIAL_OPEN.get(TODAY, "09:00")


def check_balance(token):
    want = json.loads((HOME / "positions.json").read_text("utf-8"))  # {"005930": 10}
    got, fk, nk, cont = {}, "", "", ""
    for _ in range(20):                       # 실전 1회 50건·모의 20건 → 연속조회 필수
        j, more = kis_get(token, "/uapi/domestic-stock/v1/trading/inquire-balance",
                          "VTTC8434R" if PAPER else "TTTC8434R",
                          {"CANO": ACCOUNT, "ACNT_PRDT_CD": PRODUCT, "AFHR_FLPR_YN": "N", "OFL_YN": "",
                           "INQR_DVSN": "02", "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N",
                           "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00",
                           "CTX_AREA_FK100": fk, "CTX_AREA_NK100": nk}, cont)
        for row in j["output1"]:
            if int(row["hldg_qty"]):          # 전량매도 종목이 수량 0으로 남아 있을 수 있다
                got[row["pdno"]] = int(row["hldg_qty"])
        if more not in ("M", "F"):
            break
        fk, nk, cont = j["ctx_area_fk100"], j["ctx_area_nk100"], "N"
        time.sleep(0.5 if PAPER else 0.05)
    diff = {c: (want.get(c, 0), got.get(c, 0)) for c in set(want) | set(got) if want.get(c, 0) != got.get(c, 0)}
    if diff:
        raise RuntimeError(f"봇 기록≠실제 (봇, 계좌) {diff}")
    return f"{len(got)}종목 일치 · 예수금 {int(j['output2'][0]['dnca_tot_amt']):,}원"


def notify(text):
    url = f"{env.get('TG_BASE', 'https://api.telegram.org')}/bot{env['TG_TOKEN']}/sendMessage"
    try:
        requests.post(url, json={"chat_id": env["TG_CHAT"], "text": text}, timeout=10).raise_for_status()
    except requests.RequestException as e:
        print("알림 실패:", type(e).__name__, file=sys.stderr)   # URL에 토큰이 있어 원문을 찍지 않는다


def run(name, fn, *args):
    try:
        out = fn(*args)
        return out, f"[OK]   {name}: {out[1] if isinstance(out, tuple) else out}", True
    except requests.RequestException as e:    # 메시지에 URL·계좌번호가 섞이므로 종류만 남긴다
        return None, f"[FAIL] {name}: 통신 오류 {type(e).__name__}", False
    except Exception as e:                    # 예외는 곧 불합격 — 점검기가 죽으면 안 된다
        return None, f"[FAIL] {name}: {type(e).__name__}: {e}", False


def main():
    lines, ok, status, open_time = [], True, "pass", None
    for name, fn in (("시계", check_clock), ("디스크", check_disk)):
        _, line, passed = run(name, fn)
        lines.append(line); ok &= passed
    out, line, passed = run("토큰", check_token)
    lines.append(line); ok &= passed
    if not passed:
        lines.append("[SKIP] 거래일·잔고: 토큰 없음")
    else:
        token = out[0]
        day, line, passed = run("거래일", check_market_day, token)
        ok &= passed
        if passed and not day[0]:
            status = "closed"
            lines.append("[OK]   거래일: 오늘 휴장(opnd_yn=N) — 봇을 시작하지 않는다")
        else:
            if passed:
                open_time = day[1]
                line = f"[OK]   거래일: 개장(opnd_yn=Y) · 개장 시각 설정 {open_time}"
            lines.append(line)
            _, line, passed = run("잔고", check_balance, token)
            lines.append(line); ok &= passed
    if not ok:
        status = "fail"
    folder = HOME / "preflight"
    folder.mkdir(exist_ok=True)
    (folder / f"{TODAY}.json").write_text(json.dumps(
        {"status": status, "open_time": open_time, "checks": lines}, ensure_ascii=False), "utf-8")
    text = f"장전 점검 {TODAY} {NOW:%H:%M} → {status.upper()}\n" + "\n".join(lines)
    print(text)
    notify(text)                              # 통과해도 보낸다 — 소식이 없으면 그게 이상 신호
    return {"pass": 0, "fail": 1, "closed": 2}[status]


if __name__ == "__main__":
    sys.exit(main())
