# -*- coding: utf-8 -*-
"""Parse o1_raw_all.txt offline (no JV-Link / no fetch). ASCII-only output for cmd."""
from __future__ import annotations

import argparse
import re
import sqlite3
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path

RAW_FILE = "o1_raw_all.txt"
DB = r"data\keiba.db"
OUT = "o1_parse_result.txt"
SEP = "=" * 72
O1_HDR = 43
O1_BPC = 8
O1_TAN_SLOTS = 28
O1_SPEC_LEN = 107

HDR_RE = re.compile(
    r"^#(?P<idx>\d+)\s+key=(?P<key>\S+)\s+len=(?P<len>\d+)\s+kubun=(?P<kubun>.+)$"
)

# reason code -> short English note (ASCII only)
REASON_NOTE = {
    "ok": "parsed OK",
    "not_o1": "not an O1 record",
    "race_key_short": "record too short for race key",
    "record_too_short": "shorter than tan-odds block",
    "tanflag_off": "TanFlag!=1 (odds not published / deleted race)",
    "no_horses_registered": "TorokuTosu and SyussoTosu are zero",
    "odds_sentinel_only": "odds field is *** or ---- only",
    "odds_all_zero": "all tan odds slots are 0000 or blank",
    "umaban_ok_but_odds_zero": "umaban present but all odds zero",
    "odds_nonnumeric": "odds field not numeric",
    "umaban_all_empty": "all umaban slots empty",
    "len_below_spec107": "length < JV spec 107 bytes",
    "unknown": "other (inspect raw)",
}


@dataclass
class TanBlockScan:
    slots: int = 0
    uma_nonempty: int = 0
    odds_nonempty: int = 0
    odds_numeric_gt0: int = 0
    odds_all_zero: int = 0
    odds_sentinel: int = 0
    odds_bad: int = 0
    first_uma_samples: list[str] = field(default_factory=list)
    first_odds_samples: list[str] = field(default_factory=list)


@dataclass
class ParseDiag:
    ok: bool
    reason: str
    horses: dict[int, float] = field(default_factory=dict)
    detail: str = ""


def _db_key(raw: str) -> str | None:
    if len(raw) < 27:
        return None
    return f"{raw[11:15]}|{raw[19:21]}|{raw[15:19]}|{raw[25:27]}"


def _fmt_key(k: str) -> str:
    y, j, md, rn = k.split("|")
    return f"{y}/{md[0:2]}/{md[2:4]} jyo{j} R{int(rn)}"


def _field(raw: str, start: int, end: int) -> str:
    if len(raw) < end:
        return ""
    return raw[start:end]


def _scan_tan_blocks(raw: str) -> TanBlockScan:
    scan = TanBlockScan()
    if len(raw) < O1_HDR:
        return scan
    data = raw[O1_HDR:]
    for i in range(O1_TAN_SLOTS):
        pos = i * O1_BPC
        if pos + O1_BPC > len(data):
            break
        scan.slots += 1
        uma_s = data[pos : pos + 2]
        odds_s = data[pos + 2 : pos + 6]
        uma_st = uma_s.strip()
        odds_st = odds_s.strip()
        if uma_st and uma_st.strip("0"):
            scan.uma_nonempty += 1
            if len(scan.first_uma_samples) < 6:
                scan.first_uma_samples.append(repr(uma_s))
        if odds_st:
            scan.odds_nonempty += 1
            if len(scan.first_odds_samples) < 6:
                scan.first_odds_samples.append(repr(odds_s))
            if "*" in odds_s or "-" in odds_s:
                scan.odds_sentinel += 1
            elif odds_st.strip("0") == "":
                scan.odds_all_zero += 1
            else:
                try:
                    if int(odds_st) > 0:
                        scan.odds_numeric_gt0 += 1
                    else:
                        scan.odds_all_zero += 1
                except ValueError:
                    scan.odds_bad += 1
    return scan


def _parse_tanodds(raw: str) -> dict[int, float]:
    out: dict[int, float] = {}
    if len(raw) < O1_HDR + O1_BPC:
        return out
    data = raw[O1_HDR:]
    for i in range(0, len(data) - O1_BPC + 1, O1_BPC):
        uma_s = data[i : i + 2].strip()
        odds_s = data[i + 2 : i + 6].strip()
        if not uma_s or not odds_s or odds_s.strip("0") == "":
            continue
        if "*" in odds_s or "-" in odds_s:
            continue
        try:
            uma = int(uma_s)
            v = int(odds_s) / 10.0
            if uma > 0 and v > 0:
                out[uma] = v
        except ValueError:
            continue
    return out


def diagnose_parse(raw: str, kubun: str) -> ParseDiag:
    if len(raw) < 2 or raw[:2] != "O1":
        return ParseDiag(False, "not_o1", detail=f"head={raw[:10]!r}")

    k = _db_key(raw)
    if k is None:
        return ParseDiag(False, "race_key_short", detail=f"len={len(raw)}")

    if len(raw) < O1_HDR + O1_BPC:
        return ParseDiag(
            False,
            "record_too_short",
            detail=f"len={len(raw)} need>={O1_HDR + O1_BPC}",
        )

    data_kubun = _field(raw, 2, 3)
    tan_flag = _field(raw, 39, 40)
    toroku = _field(raw, 35, 37).strip()
    syusso = _field(raw, 37, 39).strip()
    scan = _scan_tan_blocks(raw)
    horses = _parse_tanodds(raw)

    if horses:
        return ParseDiag(True, "ok", horses=horses)

    if tan_flag not in ("", "1"):
        return ParseDiag(
            False,
            "tanflag_off",
            detail=f"TanFlag={tan_flag!r} kubun={data_kubun!r} syusso={syusso!r}",
        )

    if syusso.strip("0 ") == "" and toroku.strip("0 ") == "":
        return ParseDiag(
            False,
            "no_horses_registered",
            detail=f"TorokuTosu={toroku!r} SyussoTosu={syusso!r} kubun={data_kubun!r}",
        )

    if scan.odds_sentinel > 0 and scan.odds_numeric_gt0 == 0:
        return ParseDiag(
            False,
            "odds_sentinel_only",
            detail=f"sentinel={scan.odds_sentinel} samples={scan.first_odds_samples}",
        )

    if scan.odds_numeric_gt0 == 0 and scan.odds_all_zero > 0:
        reason = "odds_all_zero"
        if scan.uma_nonempty > 0:
            reason = "umaban_ok_but_odds_zero"
        return ParseDiag(
            False,
            reason,
            detail=(
                f"kubun={data_kubun!r} hdr_kubun={kubun!r} "
                f"uma_slots={scan.uma_nonempty} zero_odds={scan.odds_all_zero} "
                f"tan[43:75]={raw[43:75]!r}"
            ),
        )

    if scan.odds_bad > 0 and scan.odds_numeric_gt0 == 0:
        return ParseDiag(
            False,
            "odds_nonnumeric",
            detail=f"samples={scan.first_odds_samples}",
        )

    if scan.uma_nonempty == 0:
        return ParseDiag(
            False,
            "umaban_all_empty",
            detail=f"tan[43:75]={raw[43:75]!r} kubun={data_kubun!r}",
        )

    if len(raw) < O1_SPEC_LEN:
        return ParseDiag(
            False,
            "len_below_spec107",
            detail=f"len={len(raw)} spec={O1_SPEC_LEN} kubun={data_kubun!r}",
        )

    return ParseDiag(
        False,
        "unknown",
        detail=(
            f"kubun={data_kubun!r} tan_flag={tan_flag!r} "
            f"uma={scan.uma_nonempty} odds_gt0={scan.odds_numeric_gt0} "
            f"tan[43:75]={raw[43:75]!r}"
        ),
    )


def iter_o1_records(path: Path):
    if not path.is_file():
        raise FileNotFoundError(path)

    pending_hdr: re.Match[str] | None = None
    with path.open("r", encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.rstrip("\n\r")
            if not line or line.startswith("FROM_TO="):
                continue
            if line == SEP or (line.startswith("=") and len(line) >= 40):
                pending_hdr = None
                continue
            m = HDR_RE.match(line)
            if m:
                pending_hdr = m
                continue
            if pending_hdr is not None and line[:2] == "O1":
                yield (
                    int(pending_hdr.group("idx")),
                    pending_hdr.group("key"),
                    pending_hdr.group("kubun").strip("'\""),
                    int(pending_hdr.group("len")),
                    line,
                )
                pending_hdr = None


def _load_db_o1(path: str, year: str) -> dict[str, dict[int, str]]:
    conn = sqlite3.connect(path)
    by_race: dict[str, dict[int, str]] = defaultdict(dict)
    for y, j, md, rn, uma, to, kubun in conn.execute(
        "SELECT Year,JyoCD,MonthDay,RaceNum,Umaban,TanOdds,DataKubun "
        "FROM NL_O1 WHERE Year=? AND DataKubun IN ('4','5')",
        (year,),
    ):
        k = f"{y}|{str(j).zfill(2)}|{md}|{str(rn).zfill(2)}"
        if uma is not None:
            by_race[k][int(uma)] = to
    conn.close()
    return by_race


def _db_tanodds_count(horses: dict[int, str]) -> int:
    n = 0
    for v in horses.values():
        if v is None:
            continue
        s = str(v).strip()
        if not s:
            continue
        try:
            if float(s) > 0:
                n += 1
        except ValueError:
            if s.strip("0"):
                n += 1
    return n


def _emit(text: str) -> None:
    """Print and save ASCII-safe text (cmd cp932 friendly)."""
    sys.stdout.write(text)
    if not text.endswith("\n"):
        sys.stdout.write("\n")
    sys.stdout.flush()


def main() -> None:
    ap = argparse.ArgumentParser(description="Parse o1_raw_all.txt offline")
    ap.add_argument("--raw", default=RAW_FILE)
    ap.add_argument("--db", default="")
    ap.add_argument("--year", default="2026")
    ap.add_argument("--out", default=OUT)
    ap.add_argument(
        "--show-fail",
        type=int,
        default=0,
        help="list all failures if non-zero (-1 = all)",
    )
    args = ap.parse_args()

    raw_path = Path(args.raw)
    records = list(iter_o1_records(raw_path))
    if not records:
        _emit(f"ERROR: no O1 records in {raw_path}")
        _emit("Run dump_o1_raw_all.py first.")
        sys.exit(1)

    db: dict[str, dict[int, str]] = {}
    if args.db:
        db_path = Path(args.db)
        if db_path.is_file():
            db = _load_db_o1(str(db_path), args.year)
        else:
            _emit(f"WARN: DB not found: {db_path}")

    parsed_ok: list[tuple[int, str, str, ParseDiag]] = []
    parsed_ng: list[tuple[int, str, str, int, ParseDiag]] = []
    reason_ctr: Counter[str] = Counter()
    reason_by_kubun: Counter[tuple[str, str]] = Counter()
    len_ctr: Counter[str] = Counter()

    for idx, key, kubun, decl_len, raw in records:
        k = _db_key(raw) or key
        diag = diagnose_parse(raw, kubun)
        actual_len = len(raw)
        len_bucket = f"{actual_len}" if actual_len == decl_len else f"{actual_len}(hdr={decl_len})"
        len_ctr[len_bucket] += 1

        if diag.ok:
            parsed_ok.append((idx, k, kubun, diag))
        else:
            parsed_ng.append((idx, k, kubun, actual_len, diag))
            reason_ctr[diag.reason] += 1
            reason_by_kubun[(diag.reason, kubun)] += 1

    ok_db = 0
    jv_ok_db_empty: list[tuple[str, dict[int, float], dict[int, str]]] = []
    jv_ok_db_partial: list[tuple[str, int, int]] = []

    if db:
        for _idx, k, _kb, diag in parsed_ok:
            db_horses = db.get(k, {})
            db_n = _db_tanodds_count(db_horses)
            jv_n = len(diag.horses)
            if db_n == 0 and jv_n > 0:
                jv_ok_db_empty.append((k, diag.horses, db_horses))
            elif db_n < jv_n:
                jv_ok_db_partial.append((k, jv_n, db_n))
            elif db_n > 0:
                ok_db += 1

    lines: list[str] = []
    w = lines.append
    w("=== o1_raw_all.txt parse result ===")
    w(f"file: {raw_path.resolve()}")
    w(f"total records: {len(records)}")
    w("")
    w(f"PARSED OK:     {len(parsed_ok)}")
    w(f"PARSED FAIL:   {len(parsed_ng)}")
    w("")

    if parsed_ok:
        horse_counts = Counter(len(d.horses) for _i, _k, _kb, d in parsed_ok)
        w("--- OK breakdown ---")
        w(
            f"  horses/race: min={min(horse_counts)} max={max(horse_counts)} "
            f"avg={sum(horse_counts.elements()) / len(parsed_ok):.1f}"
        )
        ok_kubun = Counter(kb for _i, _k, kb, _d in parsed_ok)
        for kb, cnt in sorted(ok_kubun.items()):
            w(f"  DataKubun={kb!r}: {cnt}")

    if parsed_ng:
        w("")
        w("--- FAIL reasons (count) ---")
        for reason, cnt in reason_ctr.most_common():
            note = REASON_NOTE.get(reason, reason)
            w(f"  {cnt:4d}  {reason}  # {note}")
        w("")
        w("--- FAIL reason x hdr_kubun ---")
        for (reason, kb), cnt in sorted(reason_by_kubun.items(), key=lambda x: (-x[1], x[0])):
            w(f"  {cnt:4d}  {reason}  hdr_kubun={kb!r}")

        w("")
        w("--- FAIL samples (top 3 per reason) ---")
        by_reason: dict[str, list] = defaultdict(list)
        for item in parsed_ng:
            by_reason[item[4].reason].append(item)
        for reason in reason_ctr:
            w(f"")
            w(f"[{reason}] {REASON_NOTE.get(reason, '')}")
            for idx, k, kubun, actual_len, diag in by_reason[reason][:3]:
                w(f"  #{idx} {_fmt_key(k)} len={actual_len} hdr_kubun={kubun!r}")
                w(f"       {diag.detail}")

        if args.show_fail != 0:
            w("")
            w("--- FAIL full list ---")
            for idx, k, kubun, actual_len, diag in parsed_ng:
                w(
                    f"#{idx}\t{_fmt_key(k)}\t{diag.reason}"
                    f"\thdr_kubun={kubun!r}\t{diag.detail}"
                )

    w("")
    w("--- record length (top 10) ---")
    for ln, cnt in len_ctr.most_common(10):
        w(f"  len={ln}: {cnt}")

    if db and parsed_ok:
        w("")
        w(f"--- DB compare (parsed OK only, year={args.year}, kubun 4,5) ---")
        w(f"NL_O1 OK (both sides):     {ok_db}")
        w(f"parse OK, DB TanOdds empty: {len(jv_ok_db_empty)}  <- jrvltsql write bug")
        w(f"parse OK, DB partial:       {len(jv_ok_db_partial)}")
        if jv_ok_db_empty:
            w("")
            w("--- parse OK / DB empty (first 10) ---")
            for k, jv_odds, _db_h in jv_ok_db_empty[:10]:
                sample = ", ".join(f"{u}:{v}" for u, v in sorted(jv_odds.items())[:5])
                w(f"  {_fmt_key(k)}  {sample}")

    w("")
    w("--- verdict ---")
    if len(parsed_ng) == 0:
        w(f"All {len(parsed_ok)} raw records parse to tan odds.")
        if db and jv_ok_db_empty:
            w(
                f"Parser OK. Problem is DB write: {len(jv_ok_db_empty)} races "
                f"have odds in raw but NL_O1 TanOdds empty."
            )
        elif db and ok_db == len(parsed_ok):
            w("Raw parse and NL_O1 match.")
    else:
        top = reason_ctr.most_common(1)[0]
        w(
            f"{len(parsed_ng)} unparsed. Top reason: {top[0]} ({top[1]}) "
            f"- {REASON_NOTE.get(top[0], '')}"
        )
        if len(parsed_ok) > 0 and db and jv_ok_db_empty:
            w(
                f"Also: {len(jv_ok_db_empty)} parsed OK but NL_O1 empty "
                f"(jrvltsql write side)."
            )

    text = "\n".join(lines) + "\n"
    out_path = Path(args.out)
    out_path.write_text(text, encoding="ascii", errors="backslashreplace")
    _emit(text.rstrip("\n"))
    _emit(f"Wrote: {out_path}")


if __name__ == "__main__":
    main()
