from __future__ import annotations

import argparse
import csv
import json
import logging
import re
import string
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, TextIO, Tuple, Union

# Try pyahocorasick (C-extension). Provide a pure-Python fallback otherwise.
try:
    import ahocorasick  # pyahocorasick

    _HAS_PYAC = True
except Exception:
    _HAS_PYAC = False

    class _PyFallbackAutomaton:
        """Minimal replacement for pyahocorasick.Automaton.

        O(#patterns * len(text)) so slower, but keeps behavior consistent.
        """

        def __init__(self) -> None:
            self._patterns: List[Tuple[str, int]] = []

        def add_word(self, term: str, idx: int) -> None:
            self._patterns.append((term, idx))

        def make_automaton(self) -> None:
            self._patterns.sort(key=lambda x: (-len(x[0]), x[0]))

        def iter(self, text: str) -> Iterable[Tuple[int, int]]:
            for pat, idx in self._patterns:
                start = 0
                L = len(pat)
                while True:
                    pos = text.find(pat, start)
                    if pos == -1:
                        break
                    yield (pos + L - 1, idx)
                    start = pos + 1

    class ahocorasick:  # type: ignore
        @staticmethod
        def Automaton() -> _PyFallbackAutomaton:
            return _PyFallbackAutomaton()


__description__ = (
    "Fast Aho–Corasick term extractor with NegEx-style negation/uncertainty."
)
__author__ = "Vidul Panickan"
__email__ = "apvidul@gmail.com"
__license__ = "MIT"

_logger = logging.getLogger(__name__)
BOUNDARY_CHARS = frozenset(" \t\n\r" + string.punctuation)


# -------------------- Abbreviation-aware sentence splitter --------------------


class _AdvancedSentencizer:
    def __init__(self) -> None:
        self.abbrevs: Set[str] = {
            "dr",
            "mr",
            "mrs",
            "ms",
            "vs",
            "etc",
            "inc",
            "corp",
            "pt",
            "pts",
            "no",
            "fig",
            "est",
            "approx",
            "jan",
            "feb",
            "mar",
            "apr",
            "jun",
            "jul",
            "aug",
            "sep",
            "sept",
            "oct",
            "nov",
            "dec",
            "hr",
            "min",
        }
        self._abbrev_re = re.compile(
            r"\b(" + "|".join(re.escape(a) for a in self.abbrevs) + r")\.$",
            re.IGNORECASE,
        )

    def span_tokenize(self, text: str) -> List[Tuple[int, int]]:
        spans: List[Tuple[int, int]] = []
        start = 0
        i = 0
        n = len(text)
        while i < n:
            ch = text[i]
            if ch in ".!?;":
                window = text[max(0, i - 12) : i + 1]
                if not self._abbrev_re.search(window):
                    j = i + 1
                    while j < n and text[j].isspace():
                        j += 1
                    if start < i + 1:
                        spans.append((start, i + 1))
                    start = j
                    i = j
                    continue
            elif ch == "\n":
                j = i + 1
                while j < n and text[j].isspace():
                    j += 1
                if start < i + 1:
                    spans.append((start, i + 1))
                start = j
                i = j
                continue
            i += 1
        if start < n:
            spans.append((start, n))
        return [(s, e) for s, e in spans if s < e]


# -------------------- NegEx-style scope engine --------------------


class _NegEx:
    def __init__(self) -> None:
        self.PRE_NEG = list(
            dict.fromkeys(
                [
                    "no",
                    "none",
                    "never",
                    "without",
                    "without any",
                    "absence of",
                    "absent of",
                    "lack of",
                    "lacking",
                    "lacks",
                    "no evidence of",
                    "no evidence for",
                    "no evidence to suggest",
                    "no sign of",
                    "no signs of",
                    "no clinical signs of",
                    "no indication of",
                    "no indications of",
                    "no clinical indication of",
                    "no suggestion of",
                    "not suggestive of",
                    "no history of",
                    "no prior history of",
                    "no past history of",
                    "no previous history of",
                    "no known history of",
                    "negative history of",
                    "negative hx of",
                    "no documented",
                    "no documentation of",
                    "no report of",
                    "no reported",
                    "denies",
                    "denied",
                    "denying",
                    "negative for",
                    "neg for",
                    "fails to reveal",
                    "fail to reveal",
                    "failed to reveal",
                    "failure to reveal",
                    "did not show",
                    "didn't show",
                    "does not show",
                    "doesn't show",
                    "do not show",
                    "don't show",
                    "did not demonstrate",
                    "didn't demonstrate",
                    "does not demonstrate",
                    "doesn't demonstrate",
                    "did not reveal",
                    "didn't reveal",
                    "does not reveal",
                    "doesn't reveal",
                    "did not find",
                    "didn't find",
                    "does not find",
                    "doesn't find",
                    "found no",
                    "did not identify",
                    "didn't identify",
                    "does not identify",
                    "doesn't identify",
                    "did not detect",
                    "didn't detect",
                    "does not detect",
                    "doesn't detect",
                    "not detected",
                    "undetected",
                    "not visualized",
                    "not appreciated",
                    "no abnormality",
                    "no abnormalities",
                    "unremarkable for",
                    "no acute",
                    "no new acute",
                    "no focal",
                    "no gross",
                    "no obvious",
                    "no significant",
                    "no clinically significant",
                    "no appreciable",
                    "not appreciable",
                    "free of",
                    "free from",
                    "cleared of",
                    "no longer",
                    "no more",
                    "rules out",
                    "effectively rules out",
                    "not",
                    "the same is true for",
                    "no complaints of",
                    "not associated with",
                    "does not have",
                    "do not have",
                    "did not have",
                    "doesn't have",
                    "don't have",
                    "didn't have",
                    "did not complain of",
                    "does not complain of",
                    "didn't complain of",
                    "doesn't complain of",
                    "did not experience",
                    "does not experience",
                    "didn't experience",
                    "doesn't experience",
                    "never had",
                    "never developed",
                    "did not develop",
                    "does not develop",
                    "didn't develop",
                    "doesn't develop",
                    "did not exhibit",
                    "does not exhibit",
                    "didn't exhibit",
                    "doesn't exhibit",
                    "did not display",
                    "does not display",
                    "didn't display",
                    "doesn't display",
                    "did not manifest",
                    "does not manifest",
                    "didn't manifest",
                    "doesn't manifest",
                    "failed to identify",
                    "fails to identify",
                    "failure to identify",
                    "failed to detect",
                    "fails to detect",
                    "failure to detect",
                    "failed to demonstrate",
                    "fails to demonstrate",
                    "failure to demonstrate",
                    "failed to appreciate",
                    "fails to appreciate",
                    "failure to appreciate",
                    "unable to identify",
                    "unable to detect",
                    "unable to visualize",
                    "unable to demonstrate",
                    "unable to find",
                    "unable to see",
                    "unable to appreciate",
                    "unable to locate",
                    "unable to elicit",
                    "clear of",
                    "asymptomatic",
                    "asymptomatic for",
                ]
            )
        )
        self.POST_NEG = list(
            dict.fromkeys(
                [
                    "absent",
                    "is absent",
                    "was absent",
                    "are absent",
                    "were absent",
                    "not present",
                    "is not present",
                    "was not present",
                    "not seen",
                    "is not seen",
                    "was not seen",
                    "were not seen",
                    "not found",
                    "is not found",
                    "was not found",
                    "not identified",
                    "is not identified",
                    "was not identified",
                    "not demonstrated",
                    "is not demonstrated",
                    "was not demonstrated",
                    "not evident",
                    "is not evident",
                    "was not evident",
                    "not observed",
                    "is not observed",
                    "was not observed",
                    "not visualized",
                    "is not visualized",
                    "was not visualized",
                    "not appreciated",
                    "is not appreciated",
                    "was not appreciated",
                    "not noted",
                    "is not noted",
                    "was not noted",
                    "not detected",
                    "is not detected",
                    "was not detected",
                    "was ruled out",
                    "were ruled out",
                    "is ruled out",
                    "are ruled out",
                    "has been ruled out",
                    "have been ruled out",
                    "had been ruled out",
                    "was excluded",
                    "were excluded",
                    "is excluded",
                    "are excluded",
                    "has been excluded",
                    "have been excluded",
                    "had been excluded",
                    "ruled out by",
                    "effectively ruled out",
                    "resolved",
                    "has resolved",
                    "have resolved",
                    "had resolved",
                    "cleared",
                    "has cleared",
                    "have cleared",
                    "subsided",
                    "has subsided",
                    "is negative",
                    "was negative",
                    "are negative",
                    "were negative",
                    "came back negative",
                    "returned negative",
                    "resulted negative",
                    "tested negative",
                    "screens negative",
                    "negative",
                    "unlikely",
                    "highly unlikely",
                    "very unlikely",
                    "doubtful",
                    "improbable",
                    "isn't present",
                    "wasn't present",
                    "aren't present",
                    "weren't present",
                    "isn't seen",
                    "wasn't seen",
                    "aren't seen",
                    "weren't seen",
                    "isn't found",
                    "wasn't found",
                    "aren't found",
                    "weren't found",
                    "isn't identified",
                    "wasn't identified",
                    "isn't demonstrated",
                    "wasn't demonstrated",
                    "isn't evident",
                    "wasn't evident",
                    "isn't observed",
                    "wasn't observed",
                    "isn't visualized",
                    "wasn't visualized",
                    "isn't appreciated",
                    "wasn't appreciated",
                    "isn't noted",
                    "wasn't noted",
                    "isn't detected",
                    "wasn't detected",
                    "free",
                    "afebrile",
                    "gone",
                    "has gone",
                    "have gone",
                    "had gone",
                    "disappeared",
                    "has disappeared",
                    "have disappeared",
                    "had disappeared",
                    "ceased",
                    "has ceased",
                    "have ceased",
                    "had ceased",
                ]
            )
        )
        self.PRE_UNC = list(
            dict.fromkeys(
                [
                    "rule out",
                    "r/o",
                    "ro",
                    "to rule out",
                    "to r/o",
                    "evaluate for",
                    "eval for",
                    "work up for",
                    "workup for",
                    "monitor for",
                    "monitoring for",
                    "observe for",
                    "observation for",
                    "watch for",
                    "follow for",
                    "follow-up for",
                    "following for",
                    "correlate clinically",
                    "clinical correlation advised",
                    "clinical correlation recommended",
                    "possible",
                    "possibly",
                    "probable",
                    "probably",
                    "potential",
                    "potentially",
                    "perhaps",
                    "may be",
                    "might be",
                    "could be",
                    "can be",
                    "may have",
                    "might have",
                    "could have",
                    "may represent",
                    "might represent",
                    "could represent",
                    "suspect",
                    "suspicion for",
                    "suspicion of",
                    "suspicious for",
                    "worry for",
                    "worrisome for",
                    "consider",
                    "consideration for",
                    "under consideration",
                    "differential includes",
                    "ddx includes",
                    "question of",
                    "questionable",
                    "raises question of",
                    "raises concern for",
                    "compatible with",
                    "consistent with",
                    "equivocal",
                    "indeterminate",
                    "unclear",
                    "uncertain",
                    "impression of",
                    "appears to be",
                    "seems to be",
                    "thought to be",
                    "favors",
                    "more likely",
                    "most likely",
                    "less likely",
                    "hold",
                    "suspected",
                    "presumed",
                    "presumptive",
                    "maybe",
                    "concern for",
                    "likely",
                ]
            )
        )
        self.POST_UNC = list(
            dict.fromkeys(
                [
                    "cannot be ruled out",
                    "can not be ruled out",
                    "can't be ruled out",
                    "cannot be entirely ruled out",
                    "cannot be completely ruled out",
                    "could not be ruled out",
                    "may not be ruled out",
                    "might not be ruled out",
                    "remains to be ruled out",
                    "cannot be excluded",
                    "can not be excluded",
                    "can't be excluded",
                    "could not be excluded",
                    "may not be excluded",
                    "might not be excluded",
                    "not excluded",
                    "not be excluded",
                    "is possible",
                    "was possible",
                    "remains possible",
                    "is likely",
                    "was likely",
                    "remains likely",
                    "is probable",
                    "was probable",
                    "under investigation",
                    "being investigated",
                    "under evaluation",
                    "being evaluated",
                    "being worked up",
                    "undergoing workup",
                    "is suspected",
                    "was suspected",
                    "remains suspected",
                    "is unclear",
                    "was unclear",
                    "remains unclear",
                ]
            )
        )
        self.PSEUDO = list(
            dict.fromkeys(
                [
                    "not rule out",
                    "not be ruled out",
                    "cannot rule out",
                    "can not rule out",
                    "can't rule out",
                    "could not rule out",
                    "may not rule out",
                    "might not rule out",
                    "cannot completely rule out",
                    "cannot entirely rule out",
                    "cannot be completely ruled out",
                    "cannot be entirely ruled out",
                    "cannot exclude",
                    "can not exclude",
                    "can't exclude",
                    "could not exclude",
                    "may not exclude",
                    "might not exclude",
                    "cannot be excluded",
                    "can not be excluded",
                    "can't be excluded",
                    "could not be excluded",
                    "may not be excluded",
                    "might not be excluded",
                    "not excluded",
                    "not be excluded",
                    "no doubt that",
                    "no doubt about",
                    "no question that",
                    "without doubt",
                    "definitely has",
                    "certainly has",
                    "clearly has",
                    "obviously has",
                    "undoubtedly has",
                    "not only",
                    "not necessarily",
                    "not certain if",
                    "not certain whether",
                    "not sure if",
                    "not sure whether",
                    "not known if",
                    "not clear if",
                    "no change in",
                    "no significant change",
                    "no interval change",
                    "no increase in",
                    "no decrease in",
                    "no improvement in",
                    "no worsening in",
                    "no worsening of",
                    "gram-negative",
                    "gram negative",
                    "not to pursue",
                    "no denying",
                    "no denying that",
                    "cannot deny",
                    "can't deny",
                    "not out of the question",
                    "unable to rule out",
                    "unable to exclude",
                ]
            )
        )
        self.TERMINATORS = list(
            dict.fromkeys(
                [
                    "but",
                    "however",
                    "except",
                    "other than",
                    "rather than",
                    "aside from",
                    "apart from",
                    "although",
                    "though",
                    "yet",
                    "nevertheless",
                    "whereas",
                ]
            )
        )
        self.TEMPORAL_TERMINATORS = list(
            dict.fromkeys(
                [
                    "now",
                    "currently",
                    "presently",
                    "today",
                    "tonight",
                    "this morning",
                    "this afternoon",
                    "this evening",
                    "at present",
                    "right now",
                    "at this time",
                    "presenting with",
                    "presents with",
                    "presented with",
                    "on presentation",
                    "developed",
                    "subsequently",
                    "later",
                    "since",
                    "since then",
                    "post-op",
                    "postoperative",
                    "postoperatively",
                    "post op",
                ]
            )
        )
        self.REENTRY_AFTER_COMMA = list(
            dict.fromkeys(
                [
                    "reports",
                    "reporting",
                    "reported",
                    "states",
                    "stated",
                    "notes",
                    "noted",
                    "noting",
                    "describes",
                    "described",
                    "describing",
                    "complains of",
                    "complaining of",
                    "endorses",
                    "admits to",
                    "admitting to",
                    "admitted to",
                    "presents with",
                    "presenting with",
                    "presented with",
                    "developed",
                    "now",
                    "currently",
                    "today",
                    "at present",
                ]
            )
        )
        self.RELPRON: List[str] = ["which", "that", "who", "whom", "whose"]

        self._coord_re = re.compile(r"(,|/|\band\b|\bor\b|\bnor\b)", re.IGNORECASE)
        self._relpron_re = self._compile_phrase_list(self.RELPRON)
        self._reentry_re = self._compile_phrase_list(self.REENTRY_AFTER_COMMA)
        self._pre_neg_re = self._compile_phrase_list(self.PRE_NEG)
        self._post_neg_re = self._compile_post_with_hyphen_safe_negative(self.POST_NEG)
        self._pre_unc_re = self._compile_phrase_list(self.PRE_UNC)
        self._post_unc_re = self._compile_phrase_list(self.POST_UNC)
        self._term_re = self._compile_phrase_list(
            self.TERMINATORS + self.TEMPORAL_TERMINATORS
        )
        self._pseudo_re = self._compile_phrase_list(self.PSEUDO)
        self._qmark_unc_re = re.compile(r"(?<!\w)\?(?=\s*\w)", re.IGNORECASE)
        self._pre_neg_abbrev_re = re.compile(
            r"(?<!\w)(?:neg(?:ative)?\s+for|-\s*ve\s+for|\(-\)\s*for)\b", re.IGNORECASE
        )
        self._word_re = re.compile(r"\w+")
        self._sentencizer = _AdvancedSentencizer()

    @staticmethod
    def _compile_phrase_list(phrases: List[str]) -> re.Pattern:
        parts: List[str] = []
        for p in phrases:
            esc = re.escape(p.lower()).replace(r"\ ", r"\s+")
            parts.append(f"(?:{esc})")
        return (
            re.compile(r"\b(?:%s)\b" % "|".join(parts), re.IGNORECASE)
            if parts
            else re.compile(r"$^")
        )

    @staticmethod
    def _compile_post_with_hyphen_safe_negative(phrases: List[str]) -> re.Pattern:
        parts: List[str] = []
        for p in phrases:
            if p == "negative":
                parts.append(r"(?<!-)\bnegative\b")
            else:
                esc = re.escape(p.lower()).replace(r"\ ", r"\s+")
                parts.append(f"(?:{esc})")
        return (
            re.compile(r"\b(?:%s)\b" % "|".join(parts), re.IGNORECASE)
            if parts
            else re.compile(r"$^")
        )

    def scopes(
        self, text: str, window_words: Optional[int]
    ) -> Dict[str, List[Tuple[int, int]]]:
        neg_all: List[Tuple[int, int]] = []
        unc_all: List[Tuple[int, int]] = []
        for s0, s1 in self._sentencizer.span_tokenize(text):
            sent = text[s0:s1]
            n_sp, u_sp = self._scopes_in_sentence(sent, s0, window_words)
            neg_all.extend(n_sp)
            unc_all.extend(u_sp)
        return {
            "negation": self._merge_spans(neg_all),
            "uncertainty": self._merge_spans(unc_all),
        }

    def _scopes_in_sentence(
        self, sent: str, base: int, window_words: Optional[int]
    ) -> Tuple[List[Tuple[int, int]], List[Tuple[int, int]]]:
        neg_spans: List[Tuple[int, int]] = []
        unc_spans: List[Tuple[int, int]] = []
        length = len(sent)
        pseudo_ranges = [(m.start(), m.end()) for m in self._pseudo_re.finditer(sent)]

        def overlapped(a: int, b: int, c: int, d: int) -> bool:
            return not (b <= c or a >= d)

        def in_pseudo(a: int, b: int) -> bool:
            return any(overlapped(a, b, ps, pe) for ps, pe in pseudo_ranges)

        term_spans: List[Tuple[int, int]] = [
            (m.start(), m.end()) for m in self._term_re.finditer(sent)
        ]
        for m in self._reentry_re.finditer(sent):
            i = m.start() - 1
            while i >= 0 and sent[i].isspace():
                i -= 1
            if i >= 0 and sent[i] == ",":
                term_spans.append((m.start(), m.end()))
        term_spans.sort()
        term_starts = [s for s, _ in term_spans]
        term_ends = [e for _, e in term_spans]

        rel_starts = [m.start() for m in self._relpron_re.finditer(sent)]
        coord_ends = [m.end() for m in self._coord_re.finditer(sent)]

        def next_term_after(idx: int) -> int:
            for st in term_starts:
                if st > idx:
                    return st
            return length

        def prev_term_before(idx: int) -> int:
            prev = 0
            for en in term_ends:
                if en < idx:
                    prev = en
                else:
                    break
            return prev

        def last_relpron_before(idx: int) -> Optional[int]:
            last = None
            for st in rel_starts:
                if st < idx:
                    last = st
                else:
                    break
            return last

        def last_coord_end_before(idx: int) -> int:
            prev = 0
            for en in coord_ends:
                if en <= idx:
                    prev = en
                else:
                    break
            return prev

        words = list(re.finditer(r"\w+", sent))

        def right_window_limit(idx: int, N: int) -> int:
            if not N:
                return length
            count = 0
            endpos = length
            for m in words:
                if m.end() <= idx:
                    continue
                count += 1
                endpos = m.end()
                if count >= N:
                    break
            return endpos

        def left_window_limit(idx: int, N: int) -> int:
            if not N:
                return 0
            left_words = [m for m in words if m.end() <= idx]
            if not left_words:
                return 0
            i = max(0, len(left_words) - N)
            return left_words[i].start()

        # PRE NEG (→ right)
        for m in self._pre_neg_re.finditer(sent):
            a, b = m.start(), m.end()
            if not in_pseudo(a, b):
                scope_start = b
                scope_end = next_term_after(b)
                if window_words:
                    scope_end = min(scope_end, right_window_limit(b, window_words))
                if scope_start < scope_end:
                    neg_spans.append((base + scope_start, base + scope_end))
        # Abbrev forms: "-ve for", "neg for"
        for m in self._pre_neg_abbrev_re.finditer(sent):
            a, b = m.start(), m.end()
            if not in_pseudo(a, b):
                scope_start = b
                scope_end = min(next_term_after(b), right_window_limit(b, 6))
                if scope_start < scope_end:
                    neg_spans.append((base + scope_start, base + scope_end))

        # POST NEG (← left)
        for m in self._post_neg_re.finditer(sent):
            a, b = m.start(), m.end()
            if in_pseudo(a, b):
                continue
            scope_end = a
            scope_start = prev_term_before(a)
            rp = last_relpron_before(a)
            if rp is not None:
                cut = last_coord_end_before(rp)
                if cut > scope_start:
                    scope_start = cut
            if window_words:
                scope_start = max(scope_start, left_window_limit(a, window_words))
            if scope_start < scope_end:
                neg_spans.append((base + scope_start, base + scope_end))

        # PRE UNC (→ right)
        for m in self._pre_unc_re.finditer(sent):
            a, b = m.start(), m.end()
            scope_start = b
            scope_end = next_term_after(b)
            if window_words:
                scope_end = min(scope_end, right_window_limit(b, window_words))
            if scope_start < scope_end:
                unc_spans.append((base + scope_start, base + scope_end))
        # "?TERM" short right scope
        for m in self._qmark_unc_re.finditer(sent):
            a, b = m.start(), m.end()
            scope_start = b
            scope_end = min(next_term_after(b), right_window_limit(b, 2))
            if scope_start < scope_end:
                unc_spans.append((base + scope_start, base + scope_end))

        # POST UNC (← left)
        for m in self._post_unc_re.finditer(sent):
            a, b = m.start(), m.end()
            scope_end = a
            scope_start = prev_term_before(a)
            rp = last_relpron_before(a)
            if rp is not None:
                cut = last_coord_end_before(rp)
                if cut > scope_start:
                    scope_start = cut
            if window_words:
                scope_start = max(scope_start, left_window_limit(a, window_words))
            if scope_start < scope_end:
                unc_spans.append((base + scope_start, base + scope_end))

        # neither ... nor ...
        NEITHER = re.compile(r"\bneither\b", re.IGNORECASE)
        NOR = re.compile(r"\bnor\b", re.IGNORECASE)
        for m_nei in NEITHER.finditer(sent):
            nei_end = m_nei.end()
            m_nor = NOR.search(sent, nei_end)
            if not m_nor:
                scope_start = nei_end
                scope_end = next_term_after(nei_end)
                if window_words:
                    scope_end = min(
                        scope_end, right_window_limit(nei_end, window_words)
                    )
                if scope_start < scope_end:
                    neg_spans.append((base + scope_start, base + scope_end))
            else:
                nor_start = m_nor.start()
                nor_end = m_nor.end()
                if nei_end < nor_start:
                    neg_spans.append((base + nei_end, base + nor_start))
                right_end = next_term_after(nor_end)
                if window_words:
                    right_end = min(
                        right_end, right_window_limit(nor_end, window_words)
                    )
                if nor_end < right_end:
                    neg_spans.append((base + nor_end, base + right_end))

        return neg_spans, unc_spans

    @staticmethod
    def _merge_spans(spans: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
        if not spans:
            return []
        spans.sort()
        merged: List[Tuple[int, int]] = [spans[0]]
        for s, e in spans[1:]:
            ls, le = merged[-1]
            if s <= le:
                merged[-1] = (ls, max(le, e))
            else:
                merged.append((s, e))
        return merged


# -------------------- Main extractor --------------------


class FlashExtractor:
    """Fast Aho–Corasick matcher with NegEx-style context handling.

    Dictionary input options:
    - dict[str, object]: {"fever": "R50.9", "headache": "R51"}
    - list[str] or list[tuple[str, object]]
    - str path to a file:
        * If `sep` is None or "", treat as a **term list** (one term per line).
        * If `sep` is provided, read **term,code** from the first two columns.
          If `header=True`, the first line is skipped.

    After loading, by default a short preview (first rows) is printed showing:
    **first column = term string, second column = code**.
    """

    def __init__(
        self,
        dictionary: Union[List[Union[str, Tuple[str, object]]], Dict[str, object], str],
        sep: Optional[str] = None,
        header: bool = True,
        code_joiner: str = ";",
        show_preview: bool = True,
        preview_rows: int = 5,
    ) -> None:

        self._sep = sep
        self._header = bool(header)
        self._code_joiner = code_joiner if code_joiner is not None else ";"
        self._codes_available = False

        term_map: Dict[str, List] = {}
        dupes = 0
        add_codes = self._make_code_adder(self._code_joiner)

        for term_orig, code in self._iter_items(dictionary):
            term_raw = str(term_orig).strip()
            if not term_raw:
                continue
            key = term_raw.lower()
            entry = term_map.get(key)
            if entry is None:
                entry = [term_raw, [], set()]
                term_map[key] = entry
            else:
                dupes += 1
            if add_codes(entry[1], entry[2], code):
                self._codes_available = True

        # Build patterns (original_term, code_str|None, normalized_term, length)
        self._patterns: List[Tuple[str, Optional[str], str, int]] = []
        for key, (term_orig, codes_list, _codes_set) in term_map.items():
            code_str = self._code_joiner.join(codes_list) if codes_list else None
            self._patterns.append((term_orig, code_str, key, len(key)))

        self._build_automaton()
        self._negex = _NegEx()

        self._load_stats = (
            len(self._patterns),
            sum(1 for _, c, _, _ in self._patterns if c is not None),
            dupes,
        )

        if show_preview:
            self._print_preview(preview_rows, stream=sys.stdout)

    # Public preview helper (used by the CLI)
    def preview(self, n: int = 5) -> List[Tuple[str, Optional[str]]]:
        rows: List[Tuple[str, Optional[str]]] = []
        for term_orig, code_str, _, _ in self._patterns[: max(0, n)]:
            rows.append((term_orig, code_str))
        return rows

    def _print_preview(self, n: int = 5, stream: Optional[TextIO] = None) -> None:
        if stream is None:
            stream = sys.stdout
        total, codes, dupes = self._load_stats
        print(
            f"Loaded {total} terms (codes for {codes}; duplicates {dupes}).",
            file=stream,
        )
        print(f"Preview (first {min(n, total)} rows):", file=stream)
        print("TERM\tCODE", file=stream)
        for term, code in self.preview(n):
            print(f"{term}\t{'' if code is None else code}", file=stream)

    def run(
        self,
        text: str,
        *,
        string: bool = True,
        code: Optional[bool] = None,
        start: bool = True,
        end: bool = True,
        window_size: Optional[int] = None,
        negation_check: bool = False,
        negation_window: Optional[int] = None,
    ) -> List[tuple]:

        if not isinstance(text, str):
            raise TypeError("text must be a string")

        if code is None:
            emit_code = self._codes_available
        else:
            if code and not self._codes_available:
                raise ValueError(
                    "`code=True` requested but dictionary contains no codes."
                )
            emit_code = bool(code)

        n = len(text)
        if n == 0:
            return []

        emit_string = bool(string)
        emit_start = bool(start)
        emit_end = bool(end)
        emit_ctx = window_size is not None
        win = int(window_size) if emit_ctx else 0

        text_norm = text.lower()

        neg_spans: List[Tuple[int, int]] = []
        unc_spans: List[Tuple[int, int]] = []
        if negation_check:
            scopes = self._negex.scopes(text, window_words=negation_window)
            neg_spans = scopes["negation"]
            unc_spans = scopes["uncertainty"]

        # Longest-per-start boundary-safe selection
        best_for_start: Dict[int, Tuple[int, int]] = {}
        for end_idx, pat_idx in self._ac.iter(text_norm):
            _, _, term_norm, L = self._patterns[pat_idx]
            start_idx = end_idx - L + 1
            if start_idx < 0:
                continue
            if text_norm[start_idx : end_idx + 1] != term_norm:
                continue
            if start_idx > 0 and (text[start_idx - 1] not in BOUNDARY_CHARS):
                continue
            if end_idx < n - 1 and (text[end_idx + 1] not in BOUNDARY_CHARS):
                continue
            prev = best_for_start.get(start_idx)
            if (prev is None) or (end_idx > prev[0]):
                best_for_start[start_idx] = (end_idx, pat_idx)

        results: List[tuple] = []
        last_end = -1
        for s in sorted(best_for_start.keys()):
            e, pat_idx = best_for_start[s]
            if s <= last_end:
                continue
            term_orig, code_str, _, _ = self._patterns[pat_idx]

            status: Optional[str] = None
            if negation_check:
                if _span_overlaps_any((s, e + 1), neg_spans):
                    status = "N"
                elif _span_overlaps_any((s, e + 1), unc_spans):
                    status = "U"
                else:
                    status = "Y"

            row: List[object] = []
            if emit_string:
                out_str = text[s : e + 1]
                if negation_check and status:
                    out_str = f"{out_str}:{status}"
                row.append(out_str)

            if emit_code:
                if code_str is None:
                    row.append(None)
                else:
                    if negation_check and status:
                        parts = [p for p in code_str.split(self._code_joiner) if p]
                        row.append(
                            self._code_joiner.join(f"{p}:{status}" for p in parts)
                        )
                    else:
                        row.append(code_str)

            if emit_start:
                row.append(s)
            if emit_end:
                row.append(e)
            if emit_ctx:
                ctxL = max(0, s - win)
                ctxR = min(n, e + 1 + win)
                row.append(text[ctxL:ctxR])

            results.append(tuple(row))
            last_end = e

        return results

    # -------------------- helpers --------------------

    @staticmethod
    def _make_code_adder(code_joiner: str):
        def _add(codes_list: List[str], codes_set: Set[str], code_obj) -> bool:
            if code_obj is None:
                return False
            added = False

            def _add_one(raw) -> None:
                nonlocal added
                if raw is None:
                    return
                s = str(raw).strip()
                if not s or s in codes_set:
                    return
                codes_set.add(s)
                codes_list.append(s)
                added = True

            if isinstance(code_obj, str):
                if code_joiner and code_joiner in code_obj:
                    for part in code_obj.split(code_joiner):
                        _add_one(part)
                else:
                    _add_one(code_obj)
            elif isinstance(code_obj, (list, tuple, set)):
                for item in code_obj:
                    _add_one(item)
            else:
                _add_one(code_obj)
            return added

        return _add

    def _iter_items(
        self,
        dictionary: Union[List[Union[str, Tuple[str, object]]], Dict[str, object], str],
    ) -> Iterable[Tuple[str, object]]:
        if isinstance(dictionary, dict):
            for k, v in dictionary.items():
                if not isinstance(k, str):
                    raise TypeError("All terms must be strings.")
                yield (k, v)
            return
        if isinstance(dictionary, list):
            if not dictionary:
                return
            if isinstance(dictionary[0], tuple) and len(dictionary[0]) == 2:
                for t, c in dictionary:
                    if not isinstance(t, str):
                        raise TypeError("All terms must be strings.")
                    yield (t, c)
            else:
                for t in dictionary:
                    if not isinstance(t, str):
                        raise TypeError("All terms must be strings.")
                    yield (t, None)
            return
        if isinstance(dictionary, str):
            p = Path(dictionary)
            if not p.exists():
                raise FileNotFoundError(f"Dictionary file not found: {dictionary}")
            with p.open("r", encoding="utf-8", newline="") as f:
                if self._sep is None or self._sep == "":
                    # Term list: one term per line
                    if self._header:
                        next(f, None)
                    for line in f:
                        term = line.strip()
                        if term:
                            yield (term, None)
                else:
                    reader = csv.reader(f, delimiter=self._sep)
                    _ = next(reader, None) if self._header else None
                    for row in reader:
                        if not row:
                            continue
                        term = row[0]
                        code = row[1] if len(row) > 1 and row[1] != "" else None
                        if not isinstance(term, str):
                            raise TypeError("All terms must be strings.")
                        yield (term, code)
            return
        raise TypeError(
            "Unsupported dictionary type. Use list[str], list[(str, code)], dict[str, code], or file path."
        )

    def _build_automaton(self) -> None:
        A = ahocorasick.Automaton()
        for idx, (_, _code_str, term_norm, _L) in enumerate(self._patterns):
            A.add_word(term_norm, idx)
        A.make_automaton()
        self._ac = A

    # -------- JSONL convenience --------

    def extract_note(
        self,
        note_id: str,
        text: str,
        *,
        include_code: Optional[bool] = None,
        include_offsets: bool = True,
        include_context: Optional[int] = None,
        negation_check: bool = False,
        negation_window: Optional[int] = None,
    ) -> Dict[str, object]:
        results = self.run(
            text,
            string=True,
            code=include_code,
            start=include_offsets,
            end=include_offsets,
            window_size=include_context,
            negation_check=negation_check,
            negation_window=negation_window,
        )
        extractions: List[tuple] = []
        for row in results:
            term = row[0]
            idx = 1
            code_val = None
            if include_code is None:
                if self._codes_available:
                    code_val = row[idx]
                    idx += 1
            elif include_code:
                code_val = row[idx]
                idx += 1
            start_pos = None
            end_pos = None
            if include_offsets:
                start_pos = row[idx]
                end_pos = row[idx + 1]
                idx += 2
            context = None
            if include_context is not None:
                context = row[idx]
                idx += 1
            if include_code is None and not self._codes_available:
                extraction = (term, start_pos, end_pos)
                if include_context is not None:
                    extraction = (term, start_pos, end_pos, context)
            elif include_code:
                extraction = (term, code_val, start_pos, end_pos)
                if include_context is not None:
                    extraction = (term, code_val, start_pos, end_pos, context)
            else:
                extraction = (term, start_pos, end_pos)
                if include_context is not None:
                    extraction = (term, start_pos, end_pos, context)
            extractions.append(extraction)
        return {"note_id": note_id, "text": text, "extractions": extractions}

    def save_jsonl(
        self,
        notes: Iterable[Tuple[str, str]],
        out_path: Union[str, Path],
        *,
        include_code: Optional[bool] = None,
        include_offsets: bool = True,
        include_context: Optional[int] = None,
        negation_check: bool = False,
        negation_window: Optional[int] = None,
        encoding: str = "utf-8",
    ) -> None:
        out_path = Path(out_path)
        with out_path.open("w", encoding=encoding) as f:
            for note_id, text in notes:
                record = self.extract_note(
                    note_id,
                    text,
                    include_code=include_code,
                    include_offsets=include_offsets,
                    include_context=include_context,
                    negation_check=negation_check,
                    negation_window=negation_window,
                )
                f.write(json.dumps(record, ensure_ascii=False) + "\n")


def _span_overlaps_any(span: Tuple[int, int], spans: List[Tuple[int, int]]) -> bool:
    s, e = span
    for ss, ee in spans:
        if not (e <= ss or s >= ee):
            return True
    return False


# -------------------- Convenience factory --------------------


def flash_extractor(
    dictionary: Union[List[Union[str, Tuple[str, object]]], Dict[str, object], str],
    sep: Optional[str] = None,
    header: bool = True,
    code_joiner: str = ";",
    show_preview: bool = True,
    preview_rows: int = 5,
) -> FlashExtractor:
    return FlashExtractor(
        dictionary=dictionary,
        sep=sep,
        header=header,
        code_joiner=code_joiner,
        show_preview=show_preview,
        preview_rows=preview_rows,
    )


# -------------------- CLI: "flash-extract" --------------------


def cli() -> None:
    parser = argparse.ArgumentParser(description=__description__)
    parser.add_argument(
        "dictionary",
        help="Path to dictionary file. If --sep is omitted, treated as a term list (one term per line).",
    )
    parser.add_argument("text", help="Path to a text file or '-' to read from stdin.")
    parser.add_argument(
        "--sep",
        default=None,
        help="Field separator for dictionary (e.g., ',', ';', 'tab'). "
        "Omit or pass empty to treat as term list.",
    )
    parser.add_argument(
        "--no-header", action="store_true", help="Dictionary file has no header row."
    )
    parser.add_argument(
        "--negation",
        action="store_true",
        help="Enable NegEx-style negation/uncertainty tagging.",
    )
    parser.add_argument(
        "--neg-window",
        type=int,
        default=None,
        help="Limit negation/uncertainty scope to N words.",
    )
    parser.add_argument(
        "--context",
        type=int,
        default=None,
        help="Include this many context characters around each match.",
    )
    parser.add_argument(
        "--note-id",
        default=None,
        help="Note identifier for JSONL output; defaults to filename or 'stdin'.",
    )
    parser.add_argument(
        "--preview-rows",
        type=int,
        default=5,
        help="How many dictionary rows to preview.",
    )
    parser.add_argument(
        "--no-preview",
        dest="preview",
        action="store_false",
        help="Suppress dictionary preview banner.",
    )
    parser.set_defaults(preview=True)
    args = parser.parse_args()

    sep = args.sep
    if sep is not None:
        if sep.lower() == "tab":
            sep = "\t"
        if sep == "":
            sep = None  # treat as term list

    header = not args.no_header

    # Instantiate without auto-preview (we will print preview to stderr to keep stdout clean JSON)
    extractor = flash_extractor(
        args.dictionary, sep=sep, header=header, show_preview=False
    )

    if args.preview:
        # Print preview to stderr
        total, codes, dupes = extractor._load_stats  # type: ignore[attr-defined]
        print(
            f"Loaded {total} terms (codes for {codes}; duplicates {dupes}).",
            file=sys.stderr,
        )
        rows = extractor.preview(args.preview_rows)
        print(
            f"Preview (first {min(args.preview_rows, len(rows))} rows):",
            file=sys.stderr,
        )
        print("TERM\tCODE", file=sys.stderr)
        for term, code in rows:
            print(f"{term}\t{'' if code is None else code}", file=sys.stderr)

    # Read text
    if args.text == "-":
        text = sys.stdin.read()
        note_id = args.note_id or "stdin"
    else:
        path = Path(args.text)
        text = path.read_text(encoding="utf-8")
        note_id = args.note_id or path.name

    record = extractor.extract_note(
        note_id,
        text,
        include_code=None,  # auto: include codes if dictionary had them
        include_offsets=True,
        include_context=args.context,
        negation_check=args.negation,
        negation_window=args.neg_window,
    )
    print(json.dumps(record, ensure_ascii=False))
