# PythonLand 投資×Python シリーズ 第11回
# 「株主優待カレンダーを作る（tkinter × SQLite）」
# https://pythonland.tech/shareholder-perks-calendar.html
#
# 記事3〜10章のコードをつないだ完成版です（2026-09-05 時点）。
# 標準ライブラリだけで動きます（追加インストール不要）。
#
# ⚠️ 通信について: このツールは外部と一切通信しません。読み書きするのは、このファイルと
#    同じフォルダの perks.db（--demo のときは perks_demo.db）1ファイルだけです。
# ⚠️ 第8回の dividends.db には接続しません。1行も読み書きしません。
# ⚠️ このツールは「自分で登録した情報を月ごとに並べ替えて表示する」だけの道具です。
#    銘柄の推奨・優待の金銭換算・優待利回りの計算は一切行いません（金額の列を持ちません）。
#    「必要株数まで N株」の表示は、登録された2つの数値の引き算の結果であって、
#    買い増しの提案ではありません。
# ⚠️ 長期保有条件の成否は判定しません。判定方法・必要回数・判定月は会社ごとに異なります。
#    条件の内容は各社のIR情報でご確認のうえ、note 欄に書き写して使ってください。
# ⚠️ --demo で入るサンプルの銘柄コード・銘柄名・優待内容・必要株数・権利確定月はすべて架空です。
#    実在の企業・優待制度とは関係がありません。
# ⚠️ perks.db は「自分が何を持っているか」のリストそのものです。共有フォルダ・
#    クラウド同期フォルダ・公開ディレクトリには置かないでください。
#
# 使い方:
#   python perks_app.py                 # perks.db を使う（無ければ新規作成）
#   python perks_app.py --demo          # 架空データの perks_demo.db を作り直して起動
#   python perks_app.py --db my.db      # 任意のDBファイルを指定して起動
#
# ライセンス: MIT（https://pythonland.tech/ のコードは MIT ライセンスで公開しています）

"""株主優待の権利確定月を12か月グリッドで見るtkinterアプリ（保存先はSQLite）。"""
import argparse
import ctypes
import sqlite3
import sys
import tkinter as tk
from contextlib import closing
from pathlib import Path
from tkinter import font, messagebox, ttk

BASE_DIR = Path(__file__).resolve().parent
DEFAULT_DB = BASE_DIR / "perks.db"
DEMO_DB = BASE_DIR / "perks_demo.db"

SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS perks (
    id          INTEGER PRIMARY KEY,
    code        TEXT    NOT NULL UNIQUE,   -- 自分で決める識別子（'SMPL-01' など）
    name        TEXT    NOT NULL,
    min_shares  INTEGER NOT NULL,          -- 優待に必要な株数（単元株とは別に会社が決める）
    held_shares INTEGER NOT NULL DEFAULT 0,
    rule_times  INTEGER NOT NULL DEFAULT 0,  -- 長期条件: 連続何回の名簿記載が必要か（0=条件なし）
    held_times  INTEGER NOT NULL DEFAULT 0,  -- 現在すでに連続何回か（自分で数えて入力する）
    note        TEXT    NOT NULL DEFAULT ''  -- 優待内容と条件のメモ（会社の記載を書き写す）
);
CREATE TABLE IF NOT EXISTS perk_months (
    perk_id INTEGER NOT NULL REFERENCES perks(id) ON DELETE CASCADE,
    month   INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
    PRIMARY KEY (perk_id, month)
);
CREATE INDEX IF NOT EXISTS idx_perk_months_month ON perk_months(month);
"""

# --demo で入る架空データ。(コード, 名前, 必要株数, 保有株数, 長期条件回数, 現在回数, メモ, 権利確定月)
DEMO_PERKS = [
    ("SMPL-01", "サンプル商事", 100, 100, 0, 0, "自社サービス割引券（架空）", (3, 9)),
    ("SMPL-02", "サンプル食品", 500, 100, 0, 0, "自社製品詰め合わせ（架空）", (1,)),
    ("SMPL-03", "サンプル物流", 100, 200, 3, 2, "連続3回で内容が変わる条件つき（架空）", (10,)),
    ("SMPL-04", "サンプル電機", 100, 100, 0, 0, "四半期ごとに設定（架空）", (3, 6, 9, 12)),
    ("SMPL-05", "サンプル薬品", 200, 200, 3, 3, "長期条件を満たした状態（架空）", (2,)),
    ("SMPL-06", "サンプル建設", 1000, 500, 0, 0, "必要株数が多い例（架空）", (12,)),
    ("SMPL-07", "サンプル銀行", 100, 300, 5, 4, "連続5回が条件の例（架空）", (3,)),
    ("SMPL-08", "サンプル運輸", 100, 100, 0, 0, "年2回・月がずれる例（架空）", (8, 11)),
]

# 月ごとの件数と「必要株数に届いていない件数」を数える（4章）
SQL_MONTH_COUNTS = """
SELECT pm.month AS m,
       COUNT(*) AS n,
       SUM(CASE WHEN p.held_shares >= p.min_shares THEN 0 ELSE 1 END) AS short_n
  FROM perk_months pm JOIN perks p ON p.id = pm.perk_id
 GROUP BY pm.month
 ORDER BY pm.month
"""
# ある月に権利が確定する登録ぶんを並べる（8章）
SQL_MONTH_LIST = """
SELECT p.code, p.name, p.min_shares, p.held_shares,
       p.rule_times, p.held_times, p.note
  FROM perk_months pm JOIN perks p ON p.id = pm.perk_id
 WHERE pm.month = ?
 ORDER BY p.code
"""

CELL_EMPTY = "#f5f5f5"      # 登録なし
CELL_MET = "#eef6ff"        # 登録ぶんがすべて「保有株数 >= 必要株数」
CELL_SHORT = "#fff3e0"      # 1件以上が「保有株数 < 必要株数」


# ------------------------------------------------------------ SQLite層（3〜4章）
def connect(db_path):
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")   # ON DELETE CASCADE を効かせる
    return conn


def query(db_path, sql, params=()):
    with closing(connect(db_path)) as conn:
        return conn.execute(sql, params).fetchall()


def migrate(db_path):
    """スキーマを作る。版は PRAGMA user_version で持つ（何回呼んでも安全）。"""
    with closing(connect(db_path)) as conn, conn:
        ver = conn.execute("PRAGMA user_version").fetchone()[0]
        if ver < SCHEMA_VERSION:
            conn.executescript(SCHEMA)
            # PRAGMA は ? でバインドできないので、自前の整数リテラルを埋め込む
            conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")


def save_perk(db_path, values, months):
    """1銘柄ぶんを登録・更新する。月は「全消し→入れ直し」で揃える（10章）。"""
    with closing(connect(db_path)) as conn, conn:
        conn.execute("""
            INSERT INTO perks (code, name, min_shares, held_shares,
                               rule_times, held_times, note)
                 VALUES (?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(code) DO UPDATE SET
                 name = excluded.name, min_shares = excluded.min_shares,
                 held_shares = excluded.held_shares, rule_times = excluded.rule_times,
                 held_times = excluded.held_times, note = excluded.note
        """, values)
        perk_id = conn.execute("SELECT id FROM perks WHERE code = ?",
                               (values[0],)).fetchone()[0]
        conn.execute("DELETE FROM perk_months WHERE perk_id = ?", (perk_id,))
        conn.executemany("INSERT INTO perk_months (perk_id, month) VALUES (?, ?)",
                         [(perk_id, m) for m in sorted(months)])


def delete_perk(db_path, code):
    """銘柄を消す。perk_months は ON DELETE CASCADE で一緒に消える。"""
    with closing(connect(db_path)) as conn, conn:
        return conn.execute("DELETE FROM perks WHERE code = ?", (code,)).rowcount


def month_counts(db_path):
    """12か月ぶんの (件数, 未達件数) を必ず返す。DBに無い月は 0 で埋める（5章）。"""
    counts = {m: (0, 0) for m in range(1, 13)}
    for r in query(db_path, SQL_MONTH_COUNTS):
        counts[r["m"]] = (r["n"], r["short_n"])
    return counts


# ------------------------------------------------------------ 表示用の文字列（8〜9章）
def shares_status(min_shares, held_shares):
    """登録された2つの数値を比べるだけ。買い増しの提案ではない。"""
    if held_shares >= min_shares:
        return "必要株数以上"
    return f"必要株数まで {min_shares - held_shares:,}株"


def times_status(rule_times, held_times):
    """長期条件は成否を判定せず、登録された回数をそのまま並べて出す。"""
    if rule_times == 0:
        return "登録なし"
    return f"{held_times} / {rule_times} 回（登録値）"


# ------------------------------------------------------------ フォント（10章）
PREFERRED_FONTS = ("Yu Gothic UI", "Meiryo UI", "MS UI Gothic")


def pick_font(size=10, bold=False):
    """入っているフォントから選ぶ。1つも無ければTkの既定に任せる（tk.Tk()の後に呼ぶ）。"""
    available = set(font.families())
    family = next((f for f in PREFERRED_FONTS if f in available), "")
    weight = "bold" if bold else "normal"
    if family:
        return font.Font(family=family, size=size, weight=weight)
    return font.Font(size=size, weight=weight)


def enable_dpi_awareness():
    """Windowsのみ。2回目の呼び出しは必ず失敗するので握りつぶす（10章）。"""
    if sys.platform != "win32":
        return None
    try:
        return ctypes.windll.shcore.SetProcessDpiAwareness(1)
    except Exception:      # 2回目は E_ACCESSDENIED / Shcore.dll が無い環境もある
        return None


# ------------------------------------------------------------------ 画面（5〜10章）
class PerksApp(ttk.Frame):
    # (属性名, ラベル, 幅, 数字だけか)
    FIELDS = [
        ("v_code", "コード", 10, False),
        ("v_name", "銘柄名", 18, False),
        ("v_min", "必要株数", 8, True),
        ("v_held", "保有株数", 8, True),
        ("v_rule", "長期条件（回）", 6, True),
        ("v_times", "現在（回）", 6, True),
    ]
    LIST_COLUMNS = [
        ("code", "コード", 90, "w"),
        ("name", "銘柄名", 170, "w"),
        ("min_shares", "必要株数", 90, "e"),
        ("held_shares", "保有株数", 90, "e"),
        ("shares", "必要株数との比較", 150, "w"),
        ("times", "長期条件（登録値）", 140, "w"),
        ("note", "メモ", 260, "w"),
    ]

    def __init__(self, master, db_path):
        super().__init__(master, padding=8)
        self.db_path = db_path
        self.pack(fill="both", expand=True)
        self.ui_font = pick_font(10)
        self.cell_font = pick_font(11, bold=True)
        self.selected_month = None
        self.month_vars = {}
        self.cells = {}
        self._build_form()
        self._build_calendar()
        self._build_list()
        self.reload()

    # ---------------------------------------------------------- 登録フォーム（10章）
    def _build_form(self):
        box = ttk.LabelFrame(self, text="優待を登録する（同じコードなら上書き）", padding=8)
        digits = (self.register(lambda p: p == "" or p.isdecimal()), "%P")
        for col, (attr, label, width, only_digits) in enumerate(self.FIELDS):
            ttk.Label(box, text=label).grid(row=0, column=col, sticky="w", padx=(4, 2))
            var = tk.StringVar()
            setattr(self, attr, var)
            entry = ttk.Entry(box, textvariable=var, width=width)
            if only_digits:                      # 1文字ごとの検証（第8回と同じ型）
                entry.configure(validate="key", validatecommand=digits)
            entry.grid(row=1, column=col, sticky="w", padx=(4, 2), pady=(0, 6))
        ttk.Label(box, text="メモ（優待内容・条件）").grid(row=0, column=6, sticky="w", padx=(4, 2))
        self.v_note = tk.StringVar()
        ttk.Entry(box, textvariable=self.v_note, width=34).grid(
            row=1, column=6, sticky="we", padx=(4, 2), pady=(0, 6))

        months = ttk.Frame(box)
        months.grid(row=2, column=0, columnspan=7, sticky="w", pady=(2, 0))
        ttk.Label(months, text="権利確定月（複数可）").pack(side="left", padx=(4, 8))
        for m in range(1, 13):
            var = tk.IntVar(value=0)
            self.month_vars[m] = var
            ttk.Checkbutton(months, text=f"{m}", variable=var).pack(side="left")
        ttk.Button(months, text="登録する", command=self.on_submit).pack(side="left", padx=(16, 4))
        ttk.Button(months, text="削除する", command=self.on_delete).pack(side="left")
        box.columnconfigure(6, weight=1)
        box.pack(fill="x")

    # ---------------------------------------------------------- 12か月グリッド（5〜7章）
    def _build_calendar(self):
        wrap = ttk.LabelFrame(self, text="権利確定月（登録ぶん）", padding=8)
        wrap.pack(fill="both", expand=True, pady=(8, 4))
        grid = tk.Frame(wrap)
        grid.pack(fill="both", expand=True)
        for r in range(3):
            grid.rowconfigure(r, weight=1)       # セル側を広げる
        for c in range(4):
            grid.columnconfigure(c, weight=1)
        for m in range(1, 13):
            r, c = divmod(m - 1, 4)
            cell = tk.Label(grid, text=f"{m}月", font=self.cell_font,
                            bd=1, relief="solid", cursor="hand2")
            cell.grid(row=r, column=c, sticky="nsew", padx=3, pady=3)  # ウィジェット側を広げる
            cell.bind("<Button-1>", lambda e, m=m: self.show_month(m))  # m=m が要る（7章）
            self.cells[m] = cell
        ttk.Label(wrap, font=self.ui_font, foreground="#666", wraplength=960,
                  justify="left",
                  text="色は「登録した保有株数」と「登録した必要株数」を比べた結果だけを表します"
                       "（薄い青＝登録ぶんがすべて必要株数以上／薄い橙＝1件以上が必要株数未満／"
                       "灰色＝登録なし）。優待の内容や有利さとは関係ありません。"
                  ).pack(fill="x", padx=4, pady=(6, 0))

    # ---------------------------------------------------------- 選択月の一覧（8章）
    def _build_list(self):
        box = ttk.LabelFrame(self, text="選択した月の内訳", padding=6)
        box.pack(fill="x")
        self.tree = ttk.Treeview(box, columns=[c[0] for c in self.LIST_COLUMNS],
                                 show="headings", selectmode="browse", height=6)
        for cid, text, width, anchor in self.LIST_COLUMNS:
            self.tree.heading(cid, text=text)
            self.tree.column(cid, width=width, anchor=anchor, stretch=(cid == "note"))
        bar = ttk.Scrollbar(box, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=bar.set)
        self.tree.pack(side="left", fill="both", expand=True)
        bar.pack(side="left", fill="y")
        self.tree.bind("<<TreeviewSelect>>", self.on_select_row)
        self.v_status = tk.StringVar(value="月のマスをクリックすると内訳が出ます")
        ttk.Label(self, textvariable=self.v_status, anchor="w").pack(fill="x", pady=(4, 0))

    # ---------------------------------------------------------- 動作
    def on_submit(self):
        code = self.v_code.get().strip()
        name = self.v_name.get().strip()
        months = [m for m, var in self.month_vars.items() if var.get() == 1]
        if not code or not name:
            messagebox.showerror("入力エラー", "コードと銘柄名は必須です。")
            return
        if not months:
            messagebox.showerror("入力エラー", "権利確定月を1つ以上選んでください。")
            return
        try:
            min_shares = int(self.v_min.get() or 0)
            held_shares = int(self.v_held.get() or 0)
            rule_times = int(self.v_rule.get() or 0)
            held_times = int(self.v_times.get() or 0)
        except ValueError:
            messagebox.showerror("入力エラー", "株数と回数は半角数字で入力してください。")
            return
        if min_shares <= 0:
            messagebox.showerror("入力エラー", "必要株数は1以上で入力してください。")
            return
        save_perk(self.db_path,
                  (code, name, min_shares, held_shares, rule_times, held_times,
                   self.v_note.get().strip()), months)
        self.reload()

    def on_delete(self):
        code = self.v_code.get().strip()
        if not code:
            messagebox.showerror("入力エラー", "削除するコードを入力してください。")
            return
        if not messagebox.askyesno("確認", f"{code} を削除します。よろしいですか？"):
            return
        if delete_perk(self.db_path, code) == 0:
            messagebox.showinfo("削除", f"{code} は登録されていません。")
        self.reload()

    def on_select_row(self, _event=None):
        """一覧の行を選ぶと、その内容がフォームに戻る（そのまま直して登録できる）。"""
        selection = self.tree.selection()
        if not selection:
            return
        code = self.tree.set(selection[0], "code")
        rows = query(self.db_path, "SELECT * FROM perks WHERE code = ?", (code,))
        if not rows:
            return
        r = rows[0]
        for var, key in ((self.v_code, "code"), (self.v_name, "name"),
                         (self.v_note, "note")):
            var.set(r[key])
        for var, key in ((self.v_min, "min_shares"), (self.v_held, "held_shares"),
                         (self.v_rule, "rule_times"), (self.v_times, "held_times")):
            var.set(str(r[key]))
        chosen = {row["month"] for row in query(
            self.db_path, "SELECT month FROM perk_months WHERE perk_id = ?", (r["id"],))}
        for m, var in self.month_vars.items():
            var.set(1 if m in chosen else 0)

    def show_month(self, month):
        """クリックされた月の内訳を下の表に出す。"""
        self.selected_month = month
        self.tree.delete(*self.tree.get_children(""))
        rows = query(self.db_path, SQL_MONTH_LIST, (month,))
        for r in rows:
            self.tree.insert("", "end", values=(
                r["code"], r["name"], f"{r['min_shares']:,}", f"{r['held_shares']:,}",
                shares_status(r["min_shares"], r["held_shares"]),
                times_status(r["rule_times"], r["held_times"]), r["note"]))
        short = sum(1 for r in rows if r["held_shares"] < r["min_shares"])
        self.v_status.set(
            f"{month}月に権利確定月を登録しているのは {len(rows)} 件"
            f"（うち保有株数が必要株数に届いていない登録: {short} 件）"
            if rows else f"{month}月に権利確定月を登録している銘柄はありません")
        self._paint_cells()

    def _paint_cells(self):
        counts = month_counts(self.db_path)
        for m, cell in self.cells.items():
            n, short = counts[m]
            if n == 0:
                bg, fg, label = CELL_EMPTY, "#999999", f"{m}月\n—"
            elif short:
                bg, fg, label = CELL_SHORT, "#333333", f"{m}月\n{n}件（{short}件 未達）"
            else:
                bg, fg, label = CELL_MET, "#333333", f"{m}月\n{n}件"
            border = 3 if m == self.selected_month else 1
            cell.configure(text=label, bg=bg, fg=fg, bd=border)

    def reload(self):
        self._paint_cells()
        if self.selected_month:
            self.show_month(self.selected_month)


# ------------------------------------------------------------------ 起動
def create_demo_db():
    """架空データの perks_demo.db を作り直す（既存ファイルは削除する）。"""
    DEMO_DB.unlink(missing_ok=True)
    migrate(DEMO_DB)
    for code, name, min_s, held_s, rule, times, note, months in DEMO_PERKS:
        save_perk(DEMO_DB, (code, name, min_s, held_s, rule, times, note), months)
    return DEMO_DB


def parse_args(argv=None):
    ap = argparse.ArgumentParser(description="株主優待の権利確定月を12か月グリッドで見る")
    ap.add_argument("--db", type=Path, default=None,
                    help=f"読み書きするDB（既定: {DEFAULT_DB.name}）")
    ap.add_argument("--demo", action="store_true",
                    help=f"架空データの {DEMO_DB.name} を作り直して起動する")
    args = ap.parse_args(argv)
    # --demo は DEMO_DB を作り直す。--db と併用できると、自分のDBを消したと
    # 誤解する読者が出るので断る（第9回と同じ考え方）
    if args.demo and args.db is not None:
        ap.error(f"--demo と --db は併用できません。--demo は {DEMO_DB.name} だけを"
                 "作り直します。自分のDBを開くなら --demo を外してください。")
    return args


def main(argv=None):
    args = parse_args(argv)
    db_path = create_demo_db() if args.demo else (args.db or DEFAULT_DB)
    if not args.demo:
        migrate(db_path)
    enable_dpi_awareness()          # tk.Tk() より前に呼ぶ
    root = tk.Tk()
    root.title(f"株主優待カレンダー — {db_path.name}")
    root.geometry("1020x640")
    root.minsize(860, 560)
    PerksApp(root, db_path)
    root.mainloop()


if __name__ == "__main__":
    main()
