#!/usr/bin/env python3
"""
DART 수주(단일판매·공급계약) 공시 SCAN
====================================
역할 (1단계):
  Open DART ``list.json`` 을 폴링 → 신규 공시만 DB 저장 → Mattermost 알림.
  (선택) ``DART_SAVE_CANDIDATES=true`` 이면 ``target_candidates_history``
  (strategy_id=DART) 에 후보만 적재.

품질필터 (기본 ON):
  1) 건설·재개발·아파트 등 제외 (법인명·공시명·원문)
  2) 공시 원문 매출액대비(%) ≥ DART_MIN_SALES_PCT
  3) 수출·해외·USD·반도체·방산 등 테마 키워드 필수(DART_REQUIRE_THEME)

하지 않는 것:
  - 공시 직후 시장가 매수 (즉시매수 금지 — TRIGGER 전략은 후속)
  - Gemini 식 독립 SafeRequest / JSON 파일 원자저장 (기존 SafeRequest + MariaDB)

실행:
  cd ~/kis_bot
  nohup python3 -u -m kis_trader.scan.dart_order_tracker \\
    >> logs/dart_order_tracker.log 2>&1 &
  # 1회만:
  python3 -m kis_trader.scan.dart_order_tracker --once
"""
from __future__ import annotations

import argparse
import datetime as dt
import io
import random
import re
import sys
import time
import zipfile
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple

from database import TradeDB
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from kis_trader.utils.logger import get_logger, msg_mm, msg_tg
from kis_trader.utils.request_handler import SafeRequest

logger = get_logger("kis_trader.dart_order")

DART_LIST_URL = "https://opendart.fss.or.kr/api/list.json"
DART_DOCUMENT_URL = "https://opendart.fss.or.kr/api/document.xml"
DART_VIEW_URL = "https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}"
STRATEGY_ID = "DART"

# 매출액대비(%) — 정식공시 / 자율공시 표기 차이
_RE_SALES_PCT = re.compile(
    r"매출액\s*대비\s*\(%\)\s*([0-9]+(?:\.[0-9]+)?)",
    re.IGNORECASE,
)
_RE_CONTRACT_AMT = re.compile(
    r"계약금액(?:\s*총액)?\s*\(원\)\s*([0-9,\.]+)",
)


def _now_str() -> str:
    return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def _mm_channel() -> str:
    ch = (get_env_from_db("DART_MM_CHANNEL", "") or "").strip()
    if ch:
        return ch
    ch = (get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "") or "").strip()
    if ch:
        return ch
    return (get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock").strip()


def _split_keywords(raw: str) -> List[str]:
    parts = [p.strip() for p in (raw or "").replace("ㆍ", ",").split(",")]
    return [p for p in parts if p]


def _keywords() -> List[str]:
    raw = get_env_from_db("DART_REPORT_NAME_KEYWORDS", "단일판매,공급계약") or ""
    return _split_keywords(raw)


def _exclude_corp_keywords() -> List[str]:
    # 재개발·아파트·건설 수주 — 테마 선반영 후 공시되는 경우가 많아 SCAN에서 제외
    raw = get_env_from_db(
        "DART_EXCLUDE_CORP_KEYWORDS",
        "건설,재개발,아파트,주택,토건,시공,하우징,도시정비,디벨로퍼",
    ) or ""
    return _split_keywords(raw)


def _exclude_content_keywords() -> List[str]:
    raw = get_env_from_db(
        "DART_EXCLUDE_CONTENT_KEYWORDS",
        "아파트,재개발,주택분양,도시정비,건축공사,도급공사,시공계약,분양",
    ) or ""
    return _split_keywords(raw)


def _theme_keywords() -> List[str]:
    # 수출·해외·제조/반도체·방산 쪽 — 국내 아파트 수주와 구분
    raw = get_env_from_db(
        "DART_THEME_KEYWORDS",
        "수출,해외,USD,달러,반도체,방산,우주,이차전지,배터리,디스플레이,"
        "부품,제조,항공,국방,전자,소재,장비",
    ) or ""
    return _split_keywords(raw)


def _plain_from_html(text: str) -> str:
    plain = re.sub(r"<[^>]+>", " ", text or "")
    plain = re.sub(r"&[a-zA-Z]+;", " ", plain)
    plain = re.sub(r"\s+", " ", plain)
    return plain.strip()


def _contains_any(hay: str, needles: Sequence[str]) -> Optional[str]:
    if not hay or not needles:
        return None
    for n in needles:
        if n and n in hay:
            return n
    return None


def ensure_dart_disclosure_columns(db: TradeDB) -> None:
    """웹/백테/스캐너 공용 — filter_ok·sales_pct 컬럼 보장."""
    db.conn.execute(
        """
        CREATE TABLE IF NOT EXISTS dart_disclosures (
            rcept_no VARCHAR(32) NOT NULL PRIMARY KEY,
            corp_code VARCHAR(16) NOT NULL DEFAULT '',
            corp_name VARCHAR(100) NOT NULL DEFAULT '',
            stock_code VARCHAR(20) NOT NULL DEFAULT '',
            report_nm VARCHAR(255) NOT NULL DEFAULT '',
            rcept_dt VARCHAR(8) NOT NULL DEFAULT '',
            url VARCHAR(255) NOT NULL DEFAULT '',
            first_seen_at VARCHAR(30) NOT NULL,
            mm_sent TINYINT NOT NULL DEFAULT 0,
            filter_ok TINYINT NULL,
            sales_pct DOUBLE NULL,
            filter_reason VARCHAR(64) NULL,
            KEY idx_dart_seen (first_seen_at),
            KEY idx_dart_stock (stock_code)
        ) CHARACTER SET utf8mb4
        """
    )
    cols = {
        str(r["Field"])
        for r in db.conn.execute("SHOW COLUMNS FROM dart_disclosures").fetchall()
    }
    for col, ddl in (
        ("filter_ok", "ADD COLUMN filter_ok TINYINT NULL"),
        ("sales_pct", "ADD COLUMN sales_pct DOUBLE NULL"),
        ("filter_reason", "ADD COLUMN filter_reason VARCHAR(64) NULL"),
    ):
        if col not in cols:
            try:
                db.conn.execute(f"ALTER TABLE dart_disclosures {ddl}")
            except Exception as e:
                logger.debug("dart_disclosures alter skip %s: %s", col, e)


class DartOrderTracker(SafeRequest):
    """
    Open DART 공시검색(list.json) → 수주성 보고서명 필터 → 품질필터 → DB/알림.
    SafeRequest 상속: 429/5xx 재시도 + 클라이언트 스로틀.
    """

    def __init__(self, db: Optional[TradeDB] = None):
        # DART 일일 호출 한도 여유 — 폴링은 분 단위라 간격 여유 있게
        super().__init__(min_interval_sec=0.35, max_retries=4, timeout_sec=15.0)
        self.db = db or TradeDB()
        self._ensure_table()

    def _ensure_table(self) -> None:
        ensure_dart_disclosure_columns(self.db)
        self._backfill_corp_exclude()

    def _backfill_corp_exclude(self) -> None:
        """이미 적재된 건설·재개발 법인명은 filter_ok=0 으로 표시 (원문 재다운로드 없음)."""
        kws = _exclude_corp_keywords()
        if not kws:
            return
        try:
            rows = self.db.conn.execute(
                """
                SELECT rcept_no, corp_name, report_nm FROM dart_disclosures
                WHERE filter_ok IS NULL
                """
            ).fetchall()
        except Exception:
            return
        for r in rows:
            blob = f"{r.get('corp_name') or ''} {r.get('report_nm') or ''}"
            hit = _contains_any(blob, kws)
            if hit:
                try:
                    self.db.conn.execute(
                        """
                        UPDATE dart_disclosures
                        SET filter_ok=0, filter_reason=%s
                        WHERE rcept_no=%s
                        """,
                        (f"exclude_corp:{hit}"[:64], r["rcept_no"]),
                    )
                except Exception:
                    pass
            else:
                # 품질필터 도입 전 건 — 백테는 filter_ok=1 만 쓰므로 일단 통과 표시
                try:
                    self.db.conn.execute(
                        """
                        UPDATE dart_disclosures
                        SET filter_ok=1, filter_reason=%s
                        WHERE rcept_no=%s
                        """,
                        ("legacy_pass", r["rcept_no"]),
                    )
                except Exception:
                    pass

    def _api_key(self) -> str:
        return (get_env_from_db("DART_API_KEY", "") or "").strip()

    def fetch_list(self, *, bgn_de: str, end_de: str) -> List[Dict[str, Any]]:
        """공시검색 list.json (페이지 순회, 최대 10페이지)."""
        key = self._api_key()
        if not key:
            logger.error("DART_API_KEY 가 비어 있습니다.")
            return []

        out: List[Dict[str, Any]] = []
        page_count = 100
        max_pages = max(1, min(50, get_env_int("DART_LIST_MAX_PAGES", 20)))
        for page_no in range(1, max_pages + 1):
            params = {
                "crtfc_key": key,
                "bgn_de": bgn_de,
                "end_de": end_de,
                "page_no": str(page_no),
                "page_count": str(page_count),
            }
            resp = self.get(DART_LIST_URL, params=params)
            if resp is None or resp.status_code != 200:
                logger.warning(
                    "DART list HTTP 실패 status=%s page=%s",
                    getattr(resp, "status_code", None),
                    page_no,
                )
                break
            try:
                body = resp.json()
            except Exception as e:
                logger.warning("DART list JSON 파싱 실패: %s", e)
                break

            status = str(body.get("status") or "")
            # 013 = 조회된 데이터가 없습니다 (정상 empty)
            if status == "013":
                break
            if status != "000":
                logger.warning(
                    "DART list status=%s msg=%s page=%s",
                    status, body.get("message"), page_no,
                )
                break

            rows = body.get("list") or []
            if isinstance(rows, dict):
                rows = [rows]
            if not rows:
                break
            out.extend(rows)
            total_page = int(body.get("total_page") or 1)
            if page_no >= total_page:
                break
        return out

    def fetch_document_plain(self, rcept_no: str) -> str:
        """공시 원문 ZIP(XML) → 텍스트. 실패 시 빈 문자열."""
        key = self._api_key()
        rcept_no = str(rcept_no or "").strip()
        if not key or not rcept_no:
            return ""
        resp = self.get(
            DART_DOCUMENT_URL,
            params={"crtfc_key": key, "rcept_no": rcept_no},
            timeout=30.0,
        )
        if resp is None or resp.status_code != 200 or not resp.content:
            return ""
        try:
            zf = zipfile.ZipFile(io.BytesIO(resp.content))
            names = zf.namelist()
            if not names:
                return ""
            raw = zf.read(names[0])
            text = raw.decode("utf-8", errors="replace")
            return _plain_from_html(text)
        except Exception as e:
            logger.debug("DART document 파싱 실패 %s: %s", rcept_no, e)
            return ""

    def filter_reports(self, rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """보고서명 키워드 + (선택) 기재정정 스킵 + 상장만."""
        kws = _keywords()
        skip_corr = get_env_bool("DART_SKIP_CORRECTION", True)
        listed_only = get_env_bool("DART_LISTED_ONLY", True)
        hits: List[Dict[str, Any]] = []
        for row in rows:
            name = str(row.get("report_nm") or "").strip()
            if not name:
                continue
            if skip_corr and ("기재정정" in name or name.startswith("[기재정정]")):
                continue
            if kws and not any(k in name for k in kws):
                continue
            stock = str(row.get("stock_code") or "").strip()
            if listed_only and not stock:
                continue
            hits.append(row)
        return hits

    def quality_check(
        self, item: Dict[str, Any]
    ) -> Tuple[bool, Dict[str, Any]]:
        """
        건설 제외 · 매출대비% · 테마 키워드.
        반환: (통과여부, meta{sales_pct, filter_reason, theme_hit, plain_len})
        """
        meta: Dict[str, Any] = {
            "sales_pct": None,
            "filter_reason": "pass",
            "theme_hit": None,
            "plain_len": 0,
        }
        if not get_env_bool("DART_QUALITY_FILTER_ENABLED", True):
            return True, meta

        corp = str(item.get("corp_name") or "")
        report = str(item.get("report_nm") or "")
        blob_head = f"{corp} {report}"

        ex_corp = _exclude_corp_keywords()
        hit = _contains_any(blob_head, ex_corp)
        if hit:
            meta["filter_reason"] = f"exclude_corp:{hit}"
            return False, meta

        plain = ""
        if get_env_bool("DART_FETCH_DOCUMENT", True):
            plain = self.fetch_document_plain(str(item.get("rcept_no") or ""))
            meta["plain_len"] = len(plain)

        ex_content = _exclude_content_keywords()
        if plain:
            hit = _contains_any(plain, ex_content)
            if hit:
                meta["filter_reason"] = f"exclude_content:{hit}"
                return False, meta

        min_pct = float(get_env_float("DART_MIN_SALES_PCT", 5.0) or 0.0)
        sales_pct: Optional[float] = None
        if plain:
            m = _RE_SALES_PCT.search(plain)
            if m:
                try:
                    sales_pct = float(m.group(1))
                except Exception:
                    sales_pct = None
        meta["sales_pct"] = sales_pct

        if min_pct > 0:
            if sales_pct is None:
                if get_env_bool("DART_SKIP_IF_SALES_PCT_UNKNOWN", True):
                    meta["filter_reason"] = "sales_pct_unknown"
                    return False, meta
            elif sales_pct < min_pct:
                meta["filter_reason"] = f"low_sales:{sales_pct}"
                return False, meta

        if get_env_bool("DART_REQUIRE_THEME", True):
            themes = _theme_keywords()
            theme_blob = f"{blob_head} {plain}"
            th = _contains_any(theme_blob, themes)
            meta["theme_hit"] = th
            if not th:
                meta["filter_reason"] = "no_theme"
                return False, meta

        meta["filter_reason"] = "pass"
        return True, meta

    def _known_rcept_nos(self, rcept_nos: Sequence[str]) -> Set[str]:
        nos = [str(x).strip() for x in rcept_nos if str(x).strip()]
        if not nos:
            return set()
        # pymysql IN — 플레이스홀더
        placeholders = ",".join(["%s"] * len(nos))
        rows = self.db.conn.execute(
            f"SELECT rcept_no FROM dart_disclosures WHERE rcept_no IN ({placeholders})",
            tuple(nos),
        ).fetchall()
        return {str(r["rcept_no"]) for r in rows}

    def _insert_disclosure(
        self,
        item: Dict[str, Any],
        *,
        mm_sent: bool,
        filter_ok: bool,
        sales_pct: Optional[float],
        filter_reason: str,
    ) -> bool:
        """신규 공시 1건 INSERT. 이미 있으면 False."""
        rcept_no = str(item.get("rcept_no") or "").strip()
        if not rcept_no:
            return False
        stock = str(item.get("stock_code") or "").strip()
        corp_name = str(item.get("corp_name") or "").strip()[:100]
        report_nm = str(item.get("report_nm") or "").strip()[:255]
        url = DART_VIEW_URL.format(rcept_no=rcept_no)
        try:
            self.db.conn.execute(
                """
                INSERT INTO dart_disclosures
                    (rcept_no, corp_code, corp_name, stock_code, report_nm,
                     rcept_dt, url, first_seen_at, mm_sent,
                     filter_ok, sales_pct, filter_reason)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                """,
                (
                    rcept_no,
                    str(item.get("corp_code") or "").strip()[:16],
                    corp_name,
                    stock[:20],
                    report_nm,
                    str(item.get("rcept_dt") or "").strip()[:8],
                    url[:255],
                    _now_str(),
                    1 if mm_sent else 0,
                    1 if filter_ok else 0,
                    sales_pct,
                    (filter_reason or "")[:64],
                ),
            )
            return True
        except Exception as e:
            # PK 중복 등
            logger.debug("dart insert skip %s: %s", rcept_no, e)
            return False

    def _notify(self, item: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> bool:
        stock = str(item.get("stock_code") or "").strip() or "-"
        corp = str(item.get("corp_name") or "").strip()
        report = str(item.get("report_nm") or "").strip()
        rcept_no = str(item.get("rcept_no") or "").strip()
        url = DART_VIEW_URL.format(rcept_no=rcept_no)
        meta = meta or {}
        pct = meta.get("sales_pct")
        pct_s = f"{pct:.2f}%" if isinstance(pct, (int, float)) else "-"
        theme = meta.get("theme_hit") or "-"
        body = (
            f"📢 **[DART 수주/공급계약]**\n"
            f"- 종목: `{stock}` {corp}\n"
            f"- 공시: {report}\n"
            f"- 매출대비: {pct_s} · 테마: {theme}\n"
            f"- 접수: {item.get('rcept_dt') or '-'}\n"
            f"- [원문]({url})\n"
            f"⚠️ 즉시 시장가 매수 금지 — SCAN 후보/알림 전용"
        )
        ok_mm = msg_mm(body, channel_alias=_mm_channel(), jitter=True)
        # 텔레그램 키가 있으면 병행 (없으면 내부에서 no-op)
        msg_tg(body, jitter=False)
        return bool(ok_mm)

    def _save_candidate(self, item: Dict[str, Any]) -> None:
        if not get_env_bool("DART_SAVE_CANDIDATES", False):
            return
        stock = str(item.get("stock_code") or "").strip()
        if not stock:
            return
        try:
            from kis_trader.database.db_manager import get_db
            kdb = get_db()
            et = _now_str()
            code = stock.zfill(6) if stock.isdigit() else stock
            kdb.insert_universe_snapshot(
                strategy_id=STRATEGY_ID,
                event_time=et,
                items=[{
                    "code": code,
                    "name": str(item.get("corp_name") or stock),
                }],
            )
        except Exception as e:
            logger.warning("DART 후보 적재 실패: %s", e)

    def poll_once(self) -> int:
        """
        1회 폴링. 품질필터 통과 신규 건수 반환.
        lookback: 오늘 포함 DART_LOOKBACK_DAYS 일.
        """
        if not get_env_bool("DART_ENABLED", True):
            logger.info("DART_ENABLED=false — 스킵")
            return 0
        # SCAN 스위치 (신규 키 우선)
        if not get_env_bool("DART_SCAN_ENABLED", get_env_bool("DART_ENABLED", True)):
            logger.info("DART_SCAN_ENABLED=false — 스킵")
            return 0
        if not self._api_key():
            logger.error("DART_API_KEY 없음 — 중단")
            return 0

        lookback = max(0, get_env_int("DART_LOOKBACK_DAYS", 1))
        end = dt.datetime.now().date()
        bgn = end - dt.timedelta(days=lookback)
        bgn_de = bgn.strftime("%Y%m%d")
        end_de = end.strftime("%Y%m%d")

        rows = self.fetch_list(bgn_de=bgn_de, end_de=end_de)
        hits = self.filter_reports(rows)
        if not hits:
            logger.info(
                "DART 폴링: list=%d hits=0 (%s~%s)",
                len(rows), bgn_de, end_de,
            )
            return 0

        known = self._known_rcept_nos([str(h.get("rcept_no") or "") for h in hits])
        new_count = 0
        pass_count = 0
        for item in hits:
            rcept_no = str(item.get("rcept_no") or "").strip()
            if not rcept_no or rcept_no in known:
                continue

            ok, meta = self.quality_check(item)
            inserted = self._insert_disclosure(
                item,
                mm_sent=False,
                filter_ok=ok,
                sales_pct=meta.get("sales_pct"),
                filter_reason=str(meta.get("filter_reason") or ""),
            )
            if not inserted:
                continue
            new_count += 1
            known.add(rcept_no)

            if not ok:
                logger.info(
                    "⛔ DART 필터 %s %s | %s | reason=%s sales=%s",
                    item.get("stock_code"),
                    item.get("corp_name"),
                    str(item.get("report_nm") or "").strip()[:40],
                    meta.get("filter_reason"),
                    meta.get("sales_pct"),
                )
                continue

            pass_count += 1
            mm_ok = self._notify(item, meta)
            if mm_ok:
                try:
                    self.db.conn.execute(
                        "UPDATE dart_disclosures SET mm_sent=1 WHERE rcept_no=%s",
                        (rcept_no,),
                    )
                except Exception:
                    pass
            self._save_candidate(item)
            # 임시 워치 (구독 스위치 ON 시)
            try:
                from kis_trader.scan.dart_watchlist import upsert_watch
                upsert_watch(
                    self.db,
                    stock_code=str(item.get("stock_code") or "").strip(),
                    corp_name=str(item.get("corp_name") or ""),
                    rcept_no=rcept_no,
                    report_nm=str(item.get("report_nm") or ""),
                )
            except Exception as e:
                logger.debug("DART 워치 적재 스킵: %s", e)
            logger.info(
                "🆕 DART 통과 %s %s | sales=%s%% theme=%s | %s",
                item.get("stock_code"),
                item.get("corp_name"),
                meta.get("sales_pct"),
                meta.get("theme_hit"),
                str(item.get("report_nm") or "").strip()[:60],
            )
        logger.info(
            "DART 폴링: list=%d hits=%d new=%d pass=%d (%s~%s)",
            len(rows), len(hits), new_count, pass_count, bgn_de, end_de,
        )
        return pass_count

    def run_forever(self) -> None:
        poll = max(15, get_env_int("DART_POLL_SEC", 60))
        logger.info(
            "DART 수주 감시 시작 poll=%ss lookback=%sd channel=%s quality=%s min_sales=%s theme=%s",
            poll,
            get_env_int("DART_LOOKBACK_DAYS", 1),
            _mm_channel(),
            get_env_bool("DART_QUALITY_FILTER_ENABLED", True),
            get_env_float("DART_MIN_SALES_PCT", 5.0),
            get_env_bool("DART_REQUIRE_THEME", True),
        )
        while True:
            try:
                self.poll_once()
            except Exception as e:
                logger.exception("DART 폴링 예외: %s", e)
            # 일반 루프: 기본 간격 + 1~3초 지터 (서버 부하·패턴 고정 방지)
            time.sleep(float(poll) + random.uniform(1.0, 3.0))


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="DART 수주/공급계약 공시 SCAN")
    parser.add_argument(
        "--once", action="store_true",
        help="1회 폴링 후 종료 (스모크/cron용)",
    )
    args = parser.parse_args(list(argv) if argv is not None else None)

    if not get_env_bool("DART_ENABLED", True):
        logger.warning("DART_ENABLED=false")
        return 0
    tracker = DartOrderTracker()
    if args.once:
        n = tracker.poll_once()
        print(f"dart_order_tracker --once new_pass={n}")
        return 0
    tracker.run_forever()
    return 0


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