// TextLint: a style checker for what is typed. It marks passive voice, filler words,
// repeated words, double spaces, overlong sentences and cliches, and lists each with its
// line and column. Where there is one right answer a button makes the change at once
// ("very" goes, "in order to" becomes "to", the second "the" goes), and Fix all makes
// every such change; passive voice and long sentences need a writer, so they are only
// pointed out. Show selects the words in the box. Each rule can be turned off.
//
//   <TextLint value={post.body} name="body"/>
//   <TextLint rules={["filler", "repeated", "spaces"]} long_sentence={25} on_change={(r) => { left = len(r.issues) }}/>
//
// value: the text to start with. name: the field's name ("text"), so a plain <form> sends
// it. label: the field's label ("Text to check"). rows: the box's height in lines (8).
// id: a prefix for its ids ("text-lint"), unique on the page. rules: the rules on at
// the start, of "passive", "filler", "repeated", "spaces", "long" and "cliche" (all of
// them). long_sentence: the most words a sentence may have (30). limit: the most
// characters checked (50000); past it, it checks the start and says so. show: the most
// issues listed (200), the rest counted. on_change(result): inside one of your
// components, given { text, issues } after each change. class: more classes for its
// outer element.
//
// text_lint_issues(text, rules, long_sentence, limit) is the checker, for a program's own
// use: a list, in order, of { id, rule, start, end, line, column, text, message, fix,
// edit }, positions counting characters and line and column from 1. fix says what the
// button does and edit is { start, end, with }; both are null where there is no fix.
// text_lint_apply(text, edits) makes a list of edits, skipping any that overlap.

import ui/theme.qwe use ui_cls
import ui/readability_meter.qwe use readability_meter_sentences, readability_meter_words

let text_lint_css = css`
.ui-text-lint { display: grid; gap: 14px; min-width: 0; max-width: 760px; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }
.ui-text-lint-field { display: grid; gap: 5px; min-width: 0; }
.ui-text-lint-field label { font-size: 13px; font-weight: 500; }
.ui-text-lint-field textarea { box-sizing: border-box; display: block; width: 100%; min-width: 0; padding: 9px 12px; resize: vertical; border: 1px solid var(--ui-border);
  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); }
.ui-text-lint-field textarea:hover { border-color: var(--ui-strong); }
.ui-text-lint-field textarea:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }
.ui-text-lint-rules { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 0; padding: 0; border: 0; min-width: 0; }
.ui-text-lint-rules legend { margin-bottom: 4px; padding: 0; font-size: 13px; font-weight: 500; }
.ui-text-lint-rules label { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
.ui-text-lint-rules input { margin: 0; accent-color: var(--ui-accent); }
.ui-text-lint-rules input:focus-visible { outline: none; box-shadow: var(--ui-focus); }
.ui-text-lint-count { color: var(--ui-muted); font-variant-numeric: tabular-nums; }
.ui-text-lint-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; }
.ui-text-lint-said { margin: 0; font-weight: 600; }
.ui-text-lint-note { margin: 0; color: var(--ui-muted); font-size: 13px; }
.ui-text-lint-btn { height: 28px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg);
  font: 500 12px/1 var(--ui-font); cursor: pointer; }
.ui-text-lint-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }
.ui-text-lint-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }
.ui-text-lint-marked { max-height: 260px; overflow: auto; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle);
  white-space: pre-wrap; overflow-wrap: anywhere; }
.ui-text-lint-marked:focus-visible { outline: none; box-shadow: var(--ui-focus); }
.ui-text-lint-marked mark { border-radius: 2px; background: none; color: inherit; }
.ui-text-lint-mark-passive { background: var(--ui-info-soft) !important; }
.ui-text-lint-mark-filler { background: var(--ui-warning-soft) !important; }
.ui-text-lint-mark-repeated, .ui-text-lint-mark-spaces { background: var(--ui-danger-soft) !important; }
.ui-text-lint-mark-cliche { background: var(--ui-accent-soft) !important; }
.ui-text-lint-mark-long { text-decoration: underline wavy var(--ui-warning); text-underline-offset: 3px; }
.ui-text-lint-issues { display: grid; gap: 8px; min-width: 0; outline: none; }
.ui-text-lint-issues ol { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.ui-text-lint-issue { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 8px 10px; border: 1px solid var(--ui-border);
  border-radius: var(--ui-radius-sm); background: var(--ui-surface); }
.ui-text-lint-issue p { margin: 0; overflow-wrap: anywhere; }
.ui-text-lint-where { display: block; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.ui-text-lint-act { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
@media (max-width: 480px) { .ui-text-lint-issue { grid-template-columns: 1fr; } .ui-text-lint-act { justify-content: flex-start; } }
`

let text_lint_rule_ids = ["passive", "filler", "repeated", "spaces", "long", "cliche"]
let text_lint_rule_names = { passive: "Passive voice", filler: "Filler words", repeated: "Repeated words", spaces: "Double spaces", long: "Long sentences", cliche: "Cliches" }

// Filler, with what says the same in fewer words ("" when nothing is needed in its place).
let text_lint_fillers = { very: "", really: "", just: "", actually: "", basically: "", literally: "", simply: "", quite: "", totally: "", honestly: "", obviously: "",
                          "kind of": "", "sort of": "", "needless to say": "", "it goes without saying that": "", "as a matter of fact": "",
                          "in order to": "to", "due to the fact that": "because", "at this point in time": "now", "in the event that": "if", "a large number of": "many", "the fact that": "that" }

// Cliches, with a plain word where one will do (null: it needs rewriting).
let text_lint_cliches = { "at the end of the day": "in the end", "think outside the box": "think differently", "low-hanging fruit": "easy wins", "move the needle": "make a difference",
                          "in a nutshell": "briefly", "tip of the iceberg": null, "avoid like the plague": "avoid", "game changer": "breakthrough", "game-changer": "breakthrough",
                          "level playing field": "fair contest", "perfect storm": null, "needle in a haystack": null, "paradigm shift": "major change", "touch base": "talk",
                          "circle back": "come back", "best of both worlds": null, "easier said than done": "hard", "few and far between": "rare", "last but not least": "finally",
                          "only time will tell": null, "time will tell": null, "hit the ground running": "start quickly", "raise the bar": "set higher standards",
                          "push the envelope": null, "elephant in the room": "obvious problem", "cutting edge": "advanced", "cutting-edge": "advanced", "state of the art": "modern",
                          "state-of-the-art": "modern", "on the same page": "in agreement", "going forward": "from now on", "think about it": null, "at first glance": "at first",
                          "bite the bullet": null, "ballpark figure": "estimate", "the bottom line": null, "win-win": null, "like clockwork": "reliably", "crystal clear": "clear" }

// Past participles that do not end in -ed.
let text_lint_participles = "arisen awoken beaten begun bent bitten blown born borne bought brought built burnt caught chosen dealt done drawn driven drunk eaten fallen felt fought found flown forbidden forgotten forgiven frozen given grown held hidden hung hurt kept known laid led lent made meant met paid ridden risen said seen sent shaken shown shut sold sought spent spoken stolen struck sung sunk swept sworn taken taught thrown told torn understood undone won worn written"

// Words in -ed after "is" or "was" that say what something is, not what was done to it.
let text_lint_not_passive = "bed bored excited fed hundred indeed interested married naked need pleased red scared seed shed sacred speed supposed tired used wicked worried annoyed ashamed concerned confused delighted disappointed embarrassed exhausted frightened surprised thrilled".split(" ")

// A pattern for phrases: longest first, so that "only time will tell" wins over its end,
// and any space may be a line break.
fn text_lint_phrases(phrases: list) -> str =
    reverse(sort_by(phrases, (p) => len(p))) | map((p) => replace(p, " ", "\\s+")) | join("|")

let text_lint_filler_re = `\\b(?:${text_lint_phrases(keys(text_lint_fillers))})\\b`
let text_lint_cliche_re = `\\b(?:${text_lint_phrases(keys(text_lint_cliches))})\\b`
let text_lint_passive_re = `\\b(?:am|is|are|was|were|be|been|being)\\s+(?:[a-z]+ly\\s+)?([a-z]+ed|${replace(text_lint_participles, " ", "|")})\\b`

// Words that say the same twice on purpose.
let text_lint_doubles = ["had", "that"]

fn text_lint_capital(s: str) -> str = s == "" ? "" : upper(s[0]) + join(drop(split(s, ""), 1), "")

fn text_lint_is_capital(s: str) -> bool = s != "" && upper(s[0]) == s[0] && lower(s[0]) != s[0]

// The edit that takes a word out with the space and comma after it (or the space before
// it, at the end of a line), and gives the next word a capital if this one started a sentence.
fn text_lint_removal(chars: list, m) {
    let n = len(chars)
    let e = m.end
    if e < n && chars[e] == "," && e + 1 < n && chars[e + 1] == " " { e += 2 }
    else if e < n && chars[e] == " " { e += 1 }
    let s = m.start > 0 && e == m.end && chars[m.start - 1] == " " ? m.start - 1 : m.start
    let starts = text_lint_is_capital(m.text) && e < n && lower(chars[e]) != upper(chars[e])
    starts ? { start: s, end: e + 1, with: upper(chars[e]) } : { start: s, end: e, with: "" }
}

// The filler words found by a pattern, as issues with no place yet.
fn text_lint_filler(text: str, chars: list) -> list =
    re.find_all(text, text_lint_filler_re, "i") | map((m) => {
        let plain = re.replace(lower(m.text), "\\s+", " ")
        let instead = text_lint_fillers[plain]
        let word = `"${plain}"`
        let edit = instead == "" ? text_lint_removal(chars, m)
            : { start: m.start, end: m.end, with: text_lint_is_capital(m.text) ? text_lint_capital(instead) : instead }
        { rule: "filler", start: m.start, end: m.end, text: m.text, edit,
          message: instead == "" ? `${word} adds little.` : `${word} says more than it needs to.`,
          fix: instead == "" ? "Remove" : `Use "${instead}"` }
    })

fn text_lint_cliche(text: str) -> list =
    re.find_all(text, text_lint_cliche_re, "i") | map((m) => {
        let plain = re.replace(lower(m.text), "\\s+", " ")
        let instead = text_lint_cliches[plain]
        { rule: "cliche", start: m.start, end: m.end, text: m.text, message: `"${plain}" is a cliche.`,
          fix: instead == null ? null : `Use "${instead}"`,
          edit: instead == null ? null : { start: m.start, end: m.end, with: text_lint_is_capital(m.text) ? text_lint_capital(instead) : instead } }
    })

fn text_lint_passive(text: str) -> list =
    re.find_all(text, text_lint_passive_re, "i")
        | filter((m) => !contains(text_lint_not_passive, lower(m.groups[0])))
        | map((m) => { rule: "passive", start: m.start, end: m.end, text: m.text, message: "Passive voice: say who does it, if you can.", fix: null, edit: null })

// A word said twice with only spaces between, in any alphabet. A pattern cannot refer to
// what it matched, so it compares neighbours.
fn text_lint_repeated(chars: list, words: list) -> list {
    let pairs = zip(words, drop(words, 1)) | filter((p) => {
        let a = lower(p[0].text)
        let gap = p[1].start - p[0].end
        a == lower(p[1].text) && !contains(text_lint_doubles, a) && gap <= 8 && re.test(join(slice(chars, p[0].end, p[1].start), ""), "^[ \\t]*\\n?[ \\t]*$") && re.test(a, "[^0-9.,:]")
    })
    pairs | map((p) => { rule: "repeated", start: p[0].start, end: p[1].end, text: `${p[0].text} ${p[1].text}`, message: `"${lower(p[0].text)}" is repeated.`,
                         fix: "Remove the second", edit: { start: p[0].end, end: p[1].end, with: "" } })
}

// Two spaces or more between words; those at the start or end of a line lay it out.
fn text_lint_spaces(text: str, chars: list) -> list =
    re.find_all(text, " {2,}")
        | filter((m) => m.start > 0 && m.end < len(chars) && chars[m.start - 1] != "\n" && chars[m.end] != "\n")
        | map((m) => { rule: "spaces", start: m.start, end: m.end, text: m.text, message: `${len(m.text)} spaces in a row.`, fix: "Use one space",
                       edit: { start: m.start, end: m.end, with: " " } })

fn text_lint_long(text: str, most: int) -> list =
    readability_meter_sentences(text) | map((s) => { ...s, n: len(readability_meter_words(s.text)) })
        | filter((s) => s.n > most)
        | map((s) => { rule: "long", start: s.start, end: s.end, text: s.text, message: `${s.n} words in one sentence: the limit is ${most}.`, fix: null, edit: null })

// Every issue in order of position, with its line and column.
fn text_lint_issues(text: str, rules: list = text_lint_rule_ids, long_sentence: int = 30, limit: int = 50000) -> list {
    let t = len(text) > limit ? slice(text, 0, limit) : text
    if trim(t) == "" { return [] }
    let chars = split(t, "")
    let on = (r: str) => contains(rules, r)
    let found = (on("passive") ? text_lint_passive(t) : [])
        + (on("filler") ? text_lint_filler(t, chars) : [])
        + (on("repeated") ? text_lint_repeated(chars, readability_meter_words(t)) : [])
        + (on("spaces") ? text_lint_spaces(t, chars) : [])
        + (on("long") ? text_lint_long(t, long_sentence) : [])
        + (on("cliche") ? text_lint_cliche(t) : [])
    let sorted = sort_by(found, (i) => i.start)
    // Line starts, walked once alongside the issues.
    let starts = [0, ...(re.find_all(t, "\n") | map((m) => m.end))]
    let line = 0
    for i in sorted {
        while line + 1 < len(starts) && starts[line + 1] <= i.start { line += 1 }
        { ...i, id: `${i.rule}-${i.start}`, line: line + 1, column: i.start - starts[line] + 1 }
    }
}

// `text` with the edits made. One that overlaps an earlier one waits for the next
// check, as the positions of the rest still hold from the end backwards.
fn text_lint_apply(text: str, edits: list) -> str {
    let chars = split(text, "")
    let ordered = sort_by(edits, (e) => e.start)
    let at = 0
    let pieces = for e in ordered {
        if e.start < at { "" } else {
            let keep = join(slice(chars, at, e.start), "")
            at = e.end
            keep + e.with
        }
    }
    join(pieces, "") + join(slice(chars, at, len(chars)), "")
}

// The text cut where marks begin and end: [{ text, rule, long }], rule being the
// narrowest issue over that piece, long being a long sentence under it.
fn text_lint_segments(text: str, issues: list) -> list {
    let chars = split(text, "")
    let cuts = unique(sort([0, len(chars), ...(issues | map((i) => i.start)), ...(issues | map((i) => i.end))]))
    let parts = for [a, b] in zip(cuts, drop(cuts, 1)) {
        let over = issues | filter((i) => i.start <= a && i.end >= b)
        let marks = over | filter((i) => i.rule != "long")
        let narrow = len(marks) == 0 ? null : first(sort_by(marks, (i) => i.end - i.start))
        { text: join(slice(chars, a, b), ""), start: a, rule: narrow == null ? null : narrow.rule, long: any(over, (i) => i.rule == "long") }
    }
    parts
}

// Where a character is in the box, which counts an emoji as two.
fn text_lint_box_place(text: str, place: int) -> int =
    place + count(re.find_all(text, "[\u{10000}-\u{10FFFF}]"), (m) => m.start < place)

component TextLint(value: str = "", name: str = "text", label: str = "Text to check", rows: int = 8, id: str = "text-lint", rules: list? = null,
                   long_sentence: int = 30, limit: int = 50000, show: int = 200, on_change = null, class: str? = null) {
    style global text_lint_css
    state text = value
    state on = rules ?? text_lint_rule_ids
    state done = ""
    let issues = text_lint_issues(text, on, long_sentence, limit)
    let shown = take(issues, show)
    let fixable = issues | filter((i) => i.edit != null)
    let checked = len(text) > limit ? slice(text, 0, limit) : text
    let segments = text_lint_segments(checked, shown)
    let of_rule = (r: str) => count(issues, (i) => i.rule == r)
    let said = trim(text) == "" ? "Type or paste some text to check it."
        : len(issues) == 0 ? "No issues found."
        : len(issues) == 1 ? "1 issue found." : `${len(issues)} issues found.`
    let change = (next: str, message: str) => {
        text = next
        done = message
        on_change && on_change({ text: next, issues: text_lint_issues(next, on, long_sentence, limit) })
    }
    // A fixed row goes, and the focus with it: the list keeps it, and Tab goes on from there.
    // The list is found before the change, as the button pressed may be gone after it.
    let fix = (e, i) => {
        let list = e.target.closest(".ui-text-lint").querySelector(".ui-text-lint-issues")
        change(text_lint_apply(text, [i.edit]), `Fixed "${i.text}".`)
        list.focus()
    }
    let fix_all = (e) => {
        let list = e.target.closest(".ui-text-lint").querySelector(".ui-text-lint-issues")
        change(text_lint_apply(text, fixable | map((i) => i.edit)), `Fixed ${len(fixable)} ${len(fixable) == 1 ? "issue" : "issues"}.`)
        list.focus()
    }
    let select = (e, i) => {
        let box = e.target.closest(".ui-text-lint").querySelector("textarea")
        box.focus()
        box.setSelectionRange(text_lint_box_place(text, i.start), text_lint_box_place(text, i.end))
    }
    let toggle = (rule: str, yes: bool) => {
        on = yes ? push(on, rule) : remove(on, rule)
        done = ""
    }
    <section class={ui_cls(["ui-text-lint", class])} aria-labelledby={`${id}-said`}>
        <div.ui-text-lint-field>
            <label for={`${id}-text`}>{label}</label>
            <textarea id={`${id}-text`} name={name} rows={rows} value={text} @input={(e) => { change(e.value, "") }}></textarea>
        </div>
        <fieldset.ui-text-lint-rules>
            <legend>Check for</legend>
            {for r in text_lint_rule_ids => <label key={r}>
                <input type="checkbox" id={`${id}-rule-${r}`} checked={contains(on, r)} @change={(e) => { toggle(r, e.checked) }}/>
                {text_lint_rule_names[r]} <span.ui-text-lint-count>({of_rule(r)})</span>
            </label>}
        </fieldset>
        <div.ui-text-lint-bar>
            <p.ui-text-lint-said id={`${id}-said`} aria-live="polite">{said}{done != "" && ` ${done}`}</p>
            {len(fixable) > 0 && <button.ui-text-lint-btn type="button" @click={(e) => { fix_all(e) }}>Fix all ({len(fixable)})</button>}
        </div>
        {len(text) > limit && <p.ui-text-lint-note>Only the first {format_number(limit, "en-US", 0)} characters were checked.</p>}
        {len(issues) > 0 && <div.ui-text-lint-marked id={`${id}-marked`} tabindex="0" role="group" aria-label="The text with its issues marked">
            {for s in segments => s.rule == null && !s.long ? s.text
                : <mark key={s.start} class={ui_cls([s.rule != null && `ui-text-lint-mark-${s.rule}`, s.long && "ui-text-lint-mark-long"])}>{s.text}</mark>}
        </div>}
        <div.ui-text-lint-issues id={`${id}-issues`} tabindex="-1">
            {len(issues) > 0 && <ol>
                {for i in shown => <li.ui-text-lint-issue key={i.id}>
                    <p>{i.message}<span.ui-text-lint-where>Line {i.line}, column {i.column}: {text_lint_rule_names[i.rule]}</span></p>
                    <div.ui-text-lint-act>
                        <button.ui-text-lint-btn type="button" aria-label={`Show "${i.text}" in the text`} @click={(e) => { select(e, i) }}>Show</button>
                        {i.edit != null && <button.ui-text-lint-btn type="button" @click={(e) => { fix(e, i) }}>{i.fix}</button>}
                    </div>
                </li>}
            </ol>}
            {len(issues) > show && <p.ui-text-lint-note>And {len(issues) - show} more: fix these to see them.</p>}
        </div>
    </section>
}
