"""Recount ten KJV words (hell, Satan, Enoch, 666, heaven, angels, fasting,
forgiveness, heart, purgatory) in the pinned TrustBible KJV text.

Python 3 standard library only. The compressed input is hash-checked before
anything is written. Every rule is the Bible word counter's whole-word rule:
any case, possessives included, hyphen-joined forms excluded.
"""

import argparse
import csv
import gzip
import hashlib
import io
import json
from pathlib import Path
import re
import urllib.error
import urllib.request


SOURCE_URL = "https://trustbible.org/data/kjv.jsonl.gz"
SOURCE_SHA256 = "0c3b8afefa4efaa8bd72576682c8ad060e4ccb499e81943a9678de49ab9416ad"
STANDALONE = r"(?<![\w-])%s(?![\w-])"

# "fast" also means firm or fixed ("hold fast", "fast asleep"). Only these
# verses use it for going without food; every use in them is that sense.
FASTING_SENSE_REFERENCES = [
    "2 Samuel 12:21", "2 Samuel 12:23", "1 Kings 21:9", "1 Kings 21:12", "2 Chronicles 20:3",
    "Ezra 8:21", "Esther 4:16", "Isaiah 58:3", "Isaiah 58:4", "Isaiah 58:5", "Isaiah 58:6",
    "Jeremiah 14:12", "Jeremiah 36:9", "Joel 1:14", "Joel 2:15", "Jonah 3:5", "Zechariah 7:5",
    "Zechariah 8:19", "Matthew 6:16", "Matthew 6:17", "Matthew 6:18", "Matthew 9:14", "Matthew 9:15",
    "Mark 2:18", "Mark 2:19", "Mark 2:20", "Luke 5:33", "Luke 5:34", "Luke 5:35", "Luke 18:12",
    "Acts 27:9",
]
# Which Enoch each verse names (the spelling Henoch is counted separately).
ENOCH_PEOPLE = {
    "son of Cain (and the city named after him)": ["Genesis 4:17", "Genesis 4:18"],
    "son of Jared, father of Methuselah": ["Genesis 5:18", "Genesis 5:19", "Genesis 5:21", "Genesis 5:22",
                                            "Genesis 5:23", "Genesis 5:24", "Luke 3:37", "Hebrews 11:5", "Jude 1:14"],
}

WORDS = {
    "hell": {
        "question": "How many times is hell mentioned in the Bible?",
        "forms": {"hell": "hell"},
        "headline": ["hell"],
        "related": {"hells": "hells", "sheol": "sheol", "hades": "hades", "gehenna": "gehenna", "grave": "grave", "pit": "pit"},
    },
    "satan": {
        "question": "How many times is Satan mentioned in the Bible?",
        "forms": {"satan": "satan"},
        "headline": ["satan"],
        "related": {"devil": "devil", "devils": "devils", "lucifer": "lucifer", "beelzebub": "beelzebub", "belial": "belial", "serpent": "serpent", "dragon": "dragon"},
    },
    "enoch": {
        "question": "How many times is Enoch mentioned in the Bible?",
        "forms": {"enoch": "enoch"},
        "headline": ["enoch"],
        "related": {"henoch": "henoch", "methuselah": "methuselah", "elijah": "elijah"},
    },
    "666": {
        "question": "How many times is 666 mentioned in the Bible?",
        "forms": {
            "six_hundred_threescore_and_six": "six hundred threescore and six",
            "six_hundred_and_threescore_and_six": "six hundred and threescore and six",
            "six_hundred_sixty_and_six": "six hundred sixty and six",
        },
        "headline": ["six_hundred_threescore_and_six", "six_hundred_and_threescore_and_six", "six_hundred_sixty_and_six"],
        "related": {"666": "666", "threescore": "threescore", "beast": "beast", "six hundred threescore and seven": "six hundred threescore and seven"},
    },
    "heaven": {
        "question": "How many times is heaven mentioned in the Bible?",
        "forms": {"heaven": "heaven", "heavens": "heavens", "heavenly": "heavenly"},
        "headline": ["heaven"],
        "related": {"kingdom of heaven": "kingdom of heaven", "kingdom of god": "kingdom of god", "paradise": "paradise"},
    },
    "angels": {
        "question": "How many times are angels mentioned in the Bible?",
        "forms": {"angel": "angel", "angels": "angels"},
        "headline": ["angel", "angels"],
        "related": {"angel of the lord": "angel of the lord", "cherubims": "cherubims", "cherub": "cherub", "seraphims": "seraphims", "archangel": "archangel"},
    },
    "fasting": {
        "question": "How many times is fasting mentioned in the Bible?",
        "forms": {"fast": "fast", "fastest": "fastest", "fasted": "fasted", "fasting": "fasting", "fastings": "fastings"},
        "headline": ["fast", "fastest", "fasted", "fasting", "fastings"],
        "sense_filtered": {"fast": FASTING_SENSE_REFERENCES, "fastest": FASTING_SENSE_REFERENCES},
        "related": {"fasts": "fasts", "fast (any sense)": "fast", "fastened": "fastened"},
    },
    "forgiveness": {
        "question": "How many times is forgiveness mentioned in the Bible?",
        "forms": {"forgive": "forgive", "forgiven": "forgiven", "forgiveness": "forgiveness", "forgivenesses": "forgivenesses",
                  "forgiveth": "forgiveth", "forgiving": "forgiving", "forgave": "forgave", "forgavest": "forgavest"},
        "headline": ["forgive", "forgiven", "forgiveness", "forgivenesses", "forgiveth", "forgiving", "forgave", "forgavest"],
        "related": {"pardon": "pardon", "pardoned": "pardoned", "remission": "remission"},
    },
    "heart": {
        "question": "How many times is heart mentioned in the Bible?",
        "forms": {"heart": "heart", "hearts": "hearts"},
        "headline": ["heart"],
        "related": {"hearted": "hearted", "heartily": "heartily", "hearth": "hearth", "soul": "soul", "mind": "mind"},
    },
    "purgatory": {
        "question": "How many times is purgatory mentioned in the Bible?",
        "forms": {"purgatory": "purgatory"},
        "headline": ["purgatory"],
        "related": {"purge": "purge", "purged": "purged", "purgeth": "purgeth", "purging": "purging", "limbo": "limbo", "maccabees": "maccabees"},
    },
}


def require(condition, message):
    if not condition:
        raise ValueError(message)


def pattern_for(phrase):
    return STANDALONE % r"\s+".join(re.escape(part) for part in phrase.split())


def load_source(path):
    if path:
        raw = path.read_bytes()
    else:
        request = urllib.request.Request(SOURCE_URL, headers={"User-Agent": "TrustBible-word-count/1.0"})
        with urllib.request.urlopen(request, timeout=60) as response:
            raw = response.read()
    actual = hashlib.sha256(raw).hexdigest()
    require(actual == SOURCE_SHA256, f"Source SHA-256 mismatch: expected {SOURCE_SHA256}, got {actual}. No output written.")
    records = [json.loads(line) for line in gzip.decompress(raw).decode("utf-8").splitlines()]
    require(len(records) == 31102, "Expected 31102 verse records.")
    keys = set()
    for verse in records:
        require(isinstance(verse.get("book"), str) and isinstance(verse.get("text"), str), "Invalid book or text.")
        require(all(type(verse.get(k)) is int and verse[k] > 0 for k in ("chapter", "verse")), "Invalid verse reference.")
        key = (verse["book"], verse["chapter"], verse["verse"])
        require(key not in keys, f"Duplicate verse: {key}")
        keys.add(key)
    require(len({v["book"] for v in records}) == 66, "Expected 66 books.")
    return records


def reference(verse):
    return f"{verse['book']} {verse['chapter']}:{verse['verse']}"


def context_url(verse):
    slug = verse["book"].lower().replace(" ", "-")
    return f"https://trustbible.org/en/bible/{slug}/{verse['chapter']}/#verse-{verse['verse']}"


def pair(counts):
    return {"occurrences": sum(counts), "verses": sum(n > 0 for n in counts)}


def recount_word(key, config, kjv, books):
    ot = set(books[:39])
    refs = [reference(v) for v in kjv]
    sense = config.get("sense_filtered", {})
    for allowed in sense.values():
        require(set(allowed) <= set(refs), f"{key}: unknown sense reference.")
    forms, form_values, all_sense_values = {}, {}, {}
    for name, phrase in config["forms"].items():
        regex = re.compile(pattern_for(phrase), re.IGNORECASE)
        matches = [list(regex.finditer(v["text"])) for v in kjv]
        raw = [len(m) for m in matches]
        allowed = set(sense.get(name, refs))
        kept = [n if ref in allowed else 0 for n, ref in zip(raw, refs)]
        form_values[name], all_sense_values[name] = kept, raw
        casing = {}
        for verse_matches, ref in zip(matches, refs):
            if ref in allowed:
                for match in verse_matches:
                    casing[match.group()] = casing.get(match.group(), 0) + 1
        forms[name] = {
            "phrase": phrase, "pattern": pattern_for(phrase), "flags": "re.IGNORECASE",
            "totals": pair(kept), "casing": dict(sorted(casing.items())),
            "possessives": sum(v["text"][m.end():m.end() + 1] in ("'", "’")
                               for v, ms, ref in zip(kjv, matches, refs) if ref in allowed for m in ms),
            **({"all_senses": pair(raw), "excluded_other_sense": pair([r - k for r, k in zip(raw, kept)])} if name in sense else {}),
        }
    headline = [sum(form_values[name][i] for name in config["headline"]) for i in range(len(kjv))]
    every = [sum(form_values[name][i] for name in config["forms"]) for i in range(len(kjv))]
    per_book = {book: pair([n for v, n in zip(kjv, headline) if v["book"] == book]) for book in books}
    hits = [i for i, n in enumerate(headline) if n]
    most = max(headline)
    related = {}
    for label, phrase in config["related"].items():
        regex = re.compile(pattern_for(phrase), re.IGNORECASE)
        related[label] = {"phrase": phrase, "pattern": pattern_for(phrase),
                          "flags": "re.IGNORECASE",
                          **pair([len(regex.findall(v["text"])) for v in kjv]),
                          "testaments": {t: pair([len(regex.findall(v["text"])) for v in kjv if (v["book"] in ot) == (t == "OT")]) for t in ("OT", "NT")}}
    result = {
        "question": config["question"],
        "headline_forms": config["headline"],
        "headline": pair(headline),
        "all_forms": pair(every),
        "testaments": {t: pair([n for v, n in zip(kjv, headline) if (v["book"] in ot) == (t == "OT")]) for t in ("OT", "NT")},
        "forms": forms,
        "per_book": per_book,
        "books_ranked": [b for b, _ in sorted(((b, p) for b, p in per_book.items() if p["occurrences"]),
                                               key=lambda item: (-item[1]["occurrences"], books.index(item[0])))],
        "books_with_zero": [b for b, p in per_book.items() if p["occurrences"] == 0],
        "first": refs[hits[0]] if hits else None,
        "last": refs[hits[-1]] if hits else None,
        "most_in_one_verse": {"occurrences": most, "verses": [refs[i] for i in hits if headline[i] == most]} if hits else None,
        "verses": [{"reference": refs[i], "occurrences": headline[i], "context_url": context_url(kjv[i])} for i in hits],
        "related_counts_not_in_headline": related,
    }
    if sense:
        result["sense_rule"] = ("“fast” and “fastest” count only in the listed verses, where they mean going without food; "
                                "elsewhere “fast” means firm or fixed (“hold fast”, “stand fast”, “fast asleep”).")
        result["sense_references"] = sorted(set().union(*map(set, sense.values())), key=refs.index)
        result["excluded_other_sense"] = [{"reference": refs[i], "form": name, "occurrences": all_sense_values[name][i] - form_values[name][i]}
                                          for i in range(len(kjv)) for name in sense
                                          if all_sense_values[name][i] - form_values[name][i]]
    if key == "enoch":
        result["people"] = {who: pair([headline[refs.index(r)] for r in rs]) | {"references": rs}
                            for who, rs in ENOCH_PEOPLE.items()}
        require(sum(p["occurrences"] for p in result["people"].values()) == result["headline"]["occurrences"],
                "Every Enoch occurrence must be assigned to one person.")
    rows = []
    for i, verse in enumerate(kjv):
        candidates = {name: all_sense_values[name][i] for name in config["forms"]}
        if not any(candidates.values()):
            continue
        rows.append({
            "reference": refs[i], "testament": "OT" if verse["book"] in ot else "NT", "book": verse["book"],
            **{name: form_values[name][i] for name in config["forms"]},
            "headline_occurrences": headline[i],
            "other_sense_occurrences": sum(all_sense_values[name][i] - form_values[name][i] for name in config["forms"]),
            "kjv_text": verse["text"], "context_url": context_url(verse),
        })
    return result, rows


def recount(kjv):
    books = list(dict.fromkeys(v["book"] for v in kjv))
    require(books[38:40] == ["Malachi", "Matthew"], "Unexpected OT/NT ordering.")
    words, ledgers = {}, {}
    for key, config in WORDS.items():
        words[key], ledgers[key] = recount_word(key, config, kjv, books)
    return words, ledgers


def csv_bytes(key, rows):
    fields = ["reference", "testament", "book", *WORDS[key]["forms"], "headline_occurrences", "other_sense_occurrences", "kjv_text", "context_url"]
    output = io.StringIO(newline="")
    writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return output.getvalue().encode("utf-8-sig"), fields


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, help="Offline copy of the exact pinned KJV gzip.")
    parser.add_argument("--output-dir", type=Path, default=Path("."))
    args = parser.parse_args()
    try:
        kjv = load_source(args.input)
        words, ledgers = recount(kjv)
        files = {}
        for key, rows in ledgers.items():
            if not rows:
                words[key]["audit"] = None
                continue
            data, fields = csv_bytes(key, rows)
            name = f"{key}-count-audit.csv"
            files[name] = data
            words[key]["audit"] = {"url": f"https://trustbible.org/data/{name}", "sha256": hashlib.sha256(data).hexdigest(),
                                   "rows": len(rows), "columns": fields,
                                   "encoding": "UTF-8 with BOM; comma-delimited, LF"}
        method = {
            "dataset": "Whole-word counts for ten frequently searched words in a pinned English KJV",
            "method_version": "1.0", "verified_on": "2026-11-01",
            "source": {"url": SOURCE_URL, "sha256": SOURCE_SHA256, "upstream": "https://github.com/aruljohn/Bible-kjv",
                       "imported_on": "2026-09-06", "printing": "Unknown", "verse_records": len(kjv),
                       "scope": "Verse text only; 66-book Protestant canon, no headings, notes, Apocrypha or metadata."},
            "rule": ("Whole word or phrase, any case (re.IGNORECASE): a letter, digit, underscore or ASCII hyphen beside the match "
                     "disqualifies it; possessives (’s) stay included. Same rule as https://trustbible.org/en/bible-word-counter/. "
                     "An occurrence is one match; a verse counts once however many matches it holds."),
            "words": words,
            "reproduction": {"script_url": "https://trustbible.org/data/count-words.py",
                             "script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                             "requires": "Python 3 standard library only; internet for the default input download.",
                             "command": "python count-words.py --output-dir results",
                             "offline_command": "python count-words.py --input kjv.jsonl.gz --output-dir results",
                             "outputs": [*files, "word-counts-method.json"]},
            "limits": ["A word count is not a count of every reference to a person, place or idea: pronouns, titles and synonyms are not counted.",
                       "English words are not Hebrew or Greek source words; one English word can translate several source words and the reverse."],
        }
        args.output_dir.mkdir(parents=True, exist_ok=True)
        for name, data in files.items():
            (args.output_dir / name).write_bytes(data)
        (args.output_dir / "word-counts-method.json").write_text(json.dumps(method, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
    except (OSError, ValueError, KeyError, urllib.error.URLError) as error:
        parser.exit(1, f"Recount failed: {error}\n")
    print("PASS: source hash and unique verse inventory verified.")
    for key, word in words.items():
        print(f"{key}: {word['headline']['occurrences']:,} occurrences / {word['headline']['verses']:,} verses "
              f"(all forms {word['all_forms']['occurrences']:,} / {word['all_forms']['verses']:,})")
    print(f"Output: {args.output_dir.resolve()}")


if __name__ == "__main__":
    main()
