#!/usr/bin/env python3
"""
Monetize unmonetized SoundCloud tracks in your open Safari tab.
Gary Makinson | Content rating: Explicit
"""

from __future__ import annotations

import argparse
import json
import random
import subprocess
import sys
import time
from pathlib import Path

ARTIST = "Gary Makinson"
URL = "https://artists.soundcloud.com/monetization/soundcloud"
CONTENT_RATING = "explicit"
REPORT_PATH = Path.home() / "Desktop" / "SoundCloud" / "Reports" / "soundcloud_monetize_report.json"


def log(msg: str) -> None:
    print(msg, flush=True)


def recover_safari() -> None:
    """Reload monetization page after Safari JS hiccups."""
    subprocess.run(["open", "-a", "Safari", URL], check=False)
    pause(2, 3)


def run_js(js: str, retries: int = 3) -> dict:
    last_err = ""
    for attempt in range(1, retries + 1):
        proc = subprocess.run(
            ["osascript", "-"],
            input=f'''
tell application "Safari"
    activate
    if (count of windows) is 0 then
        make new document
        set URL of front document to "{URL}"
        delay 3
    else
        set foundTab to missing value
        repeat with w in windows
            repeat with t in tabs of w
                if (URL of t as text) contains "monetization/soundcloud" then
                    set current tab of w to t
                    set index of w to 1
                    set foundTab to t
                    exit repeat
                end if
            end repeat
            if foundTab is not missing value then exit repeat
        end repeat
        if foundTab is missing value then
            tell front window
                set current tab to (make new tab with properties {{URL:"{URL}"}})
            end tell
            delay 2
        end if
    end if
    set jsResult to do JavaScript {json.dumps(js)} in current tab of front window
end tell
return jsResult
''',
            capture_output=True,
            text=True,
        )
        if proc.returncode != 0:
            last_err = (proc.stderr or proc.stdout or "").strip()
            if "Allow JavaScript from Apple Events" in last_err:
                raise RuntimeError(
                    "Turn ON: Safari > Develop > Allow JavaScript from Apple Events"
                )
            if attempt < retries:
                log(f"  Safari JS retry {attempt}/{retries}")
                if "jsResult is not defined" in last_err or "execution error" in last_err:
                    recover_safari()
                else:
                    pause(1, 2)
                continue
            raise RuntimeError(last_err or "JavaScript failed in Safari")
        raw = proc.stdout.strip()
        if not raw:
            if attempt < retries:
                pause(1, 2)
                continue
            return {}
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {"raw": raw}
    raise RuntimeError(last_err or "JavaScript failed in Safari")


SCROLL_JS = """
(function() {
  const before = document.body.scrollHeight;
  window.scrollTo(0, document.body.scrollHeight);
  return JSON.stringify({scrollY: window.scrollY, height: before});
})();
"""

SCROLL_TOP_JS = 'window.scrollTo(0, 0); JSON.stringify({ok:true});'

COUNT_RAW_JS = """
(function() {
  const body = document.body.innerText || '';
  return JSON.stringify({
    monetize: (body.match(/Monetize this track/gi) || []).length,
    resubmit: (body.match(/Resubmit this track/gi) || []).length
  });
})();
"""

TRACK_ACTION_RE = r"/monetize this track|resubmit this track/i"

COUNT_JS = f"""
(function() {{
  function trackTitleFromBtn(btn) {{
    let p = btn;
    for (let i = 0; i < 12 && p; i++) {{
      const lines = (p.innerText||'').trim().split('\\n').map(s => s.trim()).filter(Boolean);
      if (lines.length >= 2 && /gary makinson/i.test(lines[1]||'')) return lines[0];
      p = p.parentElement;
    }}
    return '';
  }}
  const seen = new Set();
  [...document.querySelectorAll('button,a,[role=button]')].forEach(el => {{
    const t = (el.innerText||'').trim();
    if (!{TRACK_ACTION_RE}.test(t) || el.offsetParent === null) return;
    const title = trackTitleFromBtn(el);
    if (title) seen.add(title);
  }});
  return JSON.stringify({{count: seen.size}});
}})();
"""

def click_monetize_js(skipped: set[str] | None = None) -> str:
    skipped_json = json.dumps(sorted(skipped or []))
    return f"""
(function() {{
  const skipped = new Set({skipped_json}.map(s => s.toLowerCase()));
  function trackTitleFromBtn(btn) {{
    let p = btn;
    for (let i = 0; i < 12 && p; i++) {{
      const lines = (p.innerText||'').trim().split('\\n').map(s => s.trim()).filter(Boolean);
      if (lines.length >= 2 && /gary makinson/i.test(lines[1]||'')) return lines[0];
      p = p.parentElement;
    }}
    return '';
  }}
  const map = new Map();
  [...document.querySelectorAll('button,a,[role=button]')].forEach(el => {{
    const t = (el.innerText||'').trim();
    if (!{TRACK_ACTION_RE}.test(t) || el.offsetParent === null) return;
    const title = trackTitleFromBtn(el);
    if (!title || map.has(title) || skipped.has(title.toLowerCase())) return;
    map.set(title, el);
  }});
  const entries = [...map.entries()];
  if (!entries.length) return JSON.stringify({{ok:false, msg:'No unmonetized tracks found.'}});
  const [title, btn] = entries[0];
  btn.click();
  return JSON.stringify({{ok:true, title:title, remaining:entries.length - 1}});
}})();
"""

RELOAD_JS = f'location.href = "{URL}"; "reloaded";'

def open_content_rating_js() -> str:
    return """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
  const sw = document.querySelector('input[name="songwriter"]');
  let root = sw ? sw.parentElement : document.body;
  for (let i = 0; i < 20 && root && root !== document.body; i++) {
    if ([...root.querySelectorAll('button')].some(b => /^submit$/i.test((b.innerText||'').trim()))) break;
    root = root.parentElement;
  }
  root = root || document.body;
  const triggers = [...root.querySelectorAll('button,[role=combobox],label,div,span')]
    .filter(el => el.offsetParent !== null)
    .filter(el => {
      const t = norm(el.innerText);
      if (/select content rating/i.test(t)) return true;
      if (el.getAttribute('role') === 'combobox') {
        const box = el.closest('div,section,fieldset,form');
        return box && /content rating/i.test(box.innerText || '');
      }
      return false;
    })
    .sort((a, b) => norm(a.innerText).length - norm(b.innerText).length);
  if (triggers.length) {
    triggers[0].click();
    return JSON.stringify({ok: true, text: norm(triggers[0].innerText).slice(0, 80)});
  }
  return JSON.stringify({ok: false});
})();
"""


def select_content_rating_js(rating: str = CONTENT_RATING) -> str:
    want = json.dumps(rating.lower())
    return f"""
(function() {{
  const want = {want};
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const matches = (text) => {{
    if (want === 'explicit') return /\\bexplicit\\b/i.test(text) && !/not explicit/i.test(text);
    if (want.includes('not')) return /not explicit/i.test(text);
    return norm(text) === want;
  }};
  let picked = false;
  const clickEl = (el) => {{
    el.scrollIntoView({{block: 'center'}});
    el.click();
    const inner = el.querySelector('div,span,button,input');
    if (inner) inner.click();
    picked = true;
  }};
  for (const el of [...document.querySelectorAll('li[role=option],[role=menuitem],[role=radio],label')]) {{
    if (!el.offsetParent) continue;
    const t = norm(el.innerText);
    if (!matches(t)) continue;
    clickEl(el);
    break;
  }}
  if (!picked) {{
    for (const el of [...document.querySelectorAll('button,li,div,span,label')].filter(e => e.offsetParent)) {{
      const t = norm(el.innerText);
      if (!matches(t)) continue;
      clickEl(el);
      break;
    }}
  }}
  return JSON.stringify({{ok: picked, want}});
}})();
"""

FORM_ROOT_JS = """
(function() {
  const sw = document.querySelector('input[name="songwriter"]');
  if (!sw) return JSON.stringify({ok:false});
  let p = sw.parentElement;
  for (let i = 0; i < 20 && p; i++) {
    const hasSubmit = [...p.querySelectorAll('button')].some(b =>
      /^submit$/i.test((b.innerText || '').trim()));
    if (hasSubmit) return JSON.stringify({ok:true, id: p.id || '', tag: p.tagName});
    p = p.parentElement;
  }
  return JSON.stringify({ok:true, tag:'BODY'});
})();
"""

VERIFY_CONTENT_RATING_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
  const wantExplicit = true;
  const matches = (text) => {
    const t = norm(text).toLowerCase();
    if (wantExplicit) return /\\bexplicit\\b/i.test(t) && !/not explicit/i.test(t);
    return /not explicit/i.test(t);
  };
  const selected = [...document.querySelectorAll(
    'li[role=option][aria-selected=true], [role=option][aria-selected=true], [role=listbox] [aria-selected=true]'
  )].map(el => norm(el.innerText)).filter(Boolean);
  if (selected.some(matches)) {
    return JSON.stringify({ok: true, text: selected.join(', ').slice(0, 120)});
  }
  const sw = document.querySelector('input[name="songwriter"]');
  let root = sw ? sw.parentElement : document.body;
  for (let i = 0; i < 20 && root && root !== document.body; i++) {
    if ([...root.querySelectorAll('button')].some(b => /^submit$/i.test((b.innerText||'').trim()))) break;
    root = root.parentElement;
  }
  root = root || document.body;
  const texts = [...root.querySelectorAll('div,section,fieldset,button,label')]
    .map(el => norm(el.innerText))
    .filter(t => /content rating/i.test(t))
    .sort((a, b) => a.length - b.length);
  const text = texts[0] || '';
  const stillNeedSelect = /select content rating/i.test(text);
  const hasExplicit = /\\bexplicit\\b/i.test(text) && !/not explicit/i.test(text);
  return JSON.stringify({ok: hasExplicit && !stillNeedSelect, text: text.slice(0, 120)});
})();
"""

VERIFY_FORM_READY_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const sw = document.querySelector('input[name="songwriter"]');
  let root = sw ? sw.parentElement : document.body;
  for (let i = 0; i < 20 && root && root !== document.body; i++) {
    if ([...root.querySelectorAll('button')].some(b => /^submit$/i.test((b.innerText||'').trim()))) break;
    root = root.parentElement;
  }
  root = root || document.body;
  const writerOk = !!root.querySelector('input[name="songwriter"][value="true"]')?.checked;
  const rightsOk = [...root.querySelectorAll('input[type=checkbox]')].some(cb => {
    const ctx = norm(cb.closest('div,label,section')?.innerText || '');
    return ctx.includes('rights') && ctx.includes('monetize') && cb.checked;
  });
  const ratingTexts = [...root.querySelectorAll('div,section,fieldset')]
    .map(el => (el.innerText || '').replace(/\\s+/g, ' ').trim())
    .filter(t => /content rating/i.test(t))
    .sort((a, b) => a.length - b.length);
  const ratingBlock = ratingTexts[0] || '';
  const ratingOk = /\\bexplicit\\b/i.test(ratingBlock) && !/select content rating/i.test(ratingBlock);
  const artistOk = [...root.querySelectorAll('input,textarea')].filter(inp => {
    if (inp.type === 'radio' || inp.type === 'checkbox' || inp.type === 'hidden') return false;
    const parent = norm(inp.closest('div,section,fieldset')?.innerText || '');
    return /main artist|composer/.test(parent) && (inp.value || '').trim().length > 0;
  }).length >= 2;
  const submit = [...root.querySelectorAll('button[type=submit],button')]
    .find(b => norm(b.innerText) === 'submit');
  const submitReady = !!(submit && !submit.disabled);
  const ok = submitReady || (writerOk && rightsOk && ratingOk && artistOk);
  return JSON.stringify({
    ok, submitReady, writerOk, rightsOk, ratingOk, artistOk,
    submitDisabled: submit ? submit.disabled : true
  });
})();
"""


CLICK_WRITER_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
  const isWriterSelected = () =>
    document.querySelector('input[name="songwriter"][value="true"]')?.checked === true;

  const clickWrongOff = () => {
    const wrong = document.querySelector('input[name="songwriter"][value="false"]');
    if (!wrong || !wrong.checked) return;
    const wrongLabel = wrong.closest('label') ||
      [...document.querySelectorAll('label')].find(l =>
        /another artist\\/writer wrote the song/i.test(norm(l.innerText)));
    if (wrongLabel) wrongLabel.click();
    else {
      wrong.click();
      wrong.dispatchEvent(new Event('change', {bubbles: true}));
    }
  };

  const clickRightOn = () => {
    const labels = [...document.querySelectorAll('label')]
      .filter(l => {
        const t = norm(l.innerText);
        return /i wrote this song/i.test(t) && !/another artist/i.test(t);
      })
      .sort((a, b) => norm(a.innerText).length - norm(b.innerText).length);

    if (labels.length) {
      const label = labels[0];
      label.click();
      const circle = label.querySelector('div.rounded-full, div[class*="rounded-full"]');
      if (circle) circle.click();
      const inp = label.querySelector('input[name="songwriter"][value="true"]');
      if (inp) {
        inp.click();
        inp.dispatchEvent(new Event('change', {bubbles: true}));
        inp.dispatchEvent(new Event('input', {bubbles: true}));
      }
      return true;
    }

    const right = document.querySelector('input[name="songwriter"][value="true"]');
    if (right) {
      right.closest('label')?.click();
      right.click();
      right.dispatchEvent(new Event('change', {bubbles: true}));
      return true;
    }
    return false;
  };

  clickWrongOff();
  clickRightOn();
  if (!isWriterSelected()) clickRightOn();
  return JSON.stringify({ok: isWriterSelected()});
})();
"""

CLICK_RIGHTS_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const isChecked = () => [...document.querySelectorAll('input[type=checkbox]')].some(cb => {
    const ctx = norm(cb.closest('div,label,section')?.innerText || '');
    return ctx.includes('rights') && ctx.includes('monetize') && cb.checked;
  });
  const labels = [...document.querySelectorAll('label')]
    .filter(l => {
      const t = norm(l.innerText);
      return t.includes('rights') && t.includes('monetize') && t.length < 120;
    })
    .sort((a, b) => norm(a.innerText).length - norm(b.innerText).length);
  for (const lab of labels) {
    lab.click();
    const cb = lab.querySelector('input[type=checkbox]') ||
      ((id => id && document.getElementById(id))(lab.getAttribute('for')));
    if (cb && !cb.checked) cb.click();
  }
  document.querySelectorAll('input[type=checkbox]').forEach(cb => {
    const ctx = norm(cb.closest('div,label,section')?.innerText || '');
    if (ctx.includes('rights') && ctx.includes('monetize') && !cb.checked) cb.click();
  });
  return JSON.stringify({ok: isChecked()});
})();
"""

VERIFY_WRITER_JS = """
(function() {
  const right = document.querySelector('input[name="songwriter"][value="true"]');
  const wrong = document.querySelector('input[name="songwriter"][value="false"]');
  return JSON.stringify({
    ok: right?.checked === true && wrong?.checked !== true,
    rightChecked: right?.checked === true,
    wrongChecked: wrong?.checked === true
  });
})();
"""


def fill_artist_fields_js() -> str:
    artist = json.dumps(ARTIST)
    return f"""
(function() {{
  const artist = {artist};
  const sw = document.querySelector('input[name="songwriter"]');
  let root = sw ? sw.parentElement : document.body;
  for (let i = 0; i < 20 && root && root !== document.body; i++) {{
    if ([...root.querySelectorAll('button')].some(b => /^submit$/i.test((b.innerText||'').trim()))) break;
    root = root.parentElement;
  }}
  root = root || document.body;
  let filled = 0;
  const fire = (el, val) => {{
    el.focus();
    el.value = val;
    el.dispatchEvent(new Event('input', {{bubbles:true}}));
    el.dispatchEvent(new Event('change', {{bubbles:true}}));
    filled++;
  }};
  const clickNoNear = (headingRe) => {{
    for (const el of root.querySelectorAll('label,p,span,legend,div')) {{
      if (!headingRe.test(el.innerText||'')) continue;
      let p = el.parentElement;
      for (let i = 0; i < 8 && p; i++) {{
        for (const ch of p.querySelectorAll('label,[role=radio],input[type=radio]')) {{
          const t = (ch.innerText||'').trim();
          if (/^no$/i.test(t) && !/another artist/i.test(p.innerText||'')) {{
            ch.click(); return true;
          }}
        }}
        p = p.parentElement;
      }}
    }}
    return false;
  }};

  root.querySelectorAll('input,textarea').forEach(inp => {{
    if (inp.type === 'radio' || inp.type === 'checkbox' || inp.type === 'hidden') return;
    const n = (inp.name||'').toLowerCase();
    const p = (inp.placeholder||'').toLowerCase();
    const parent = (inp.closest('div,section,fieldset,label')?.innerText||'').toLowerCase();
    if (/another artist|search for tracks|email/i.test(parent)) return;
    const looksArtist = ['composer','contributor','main artist','songwriter','performer','artist']
      .some(w => n.includes(w) || p.includes(w) || parent.includes(w));
    const isPlainText = !inp.type || inp.type === 'text' || inp.type === 'search';
    if (looksArtist || (isPlainText && !(inp.value||'').trim())) fire(inp, artist);
  }});

  clickNoNear(/do you have an isrc/i);
  clickNoNear(/is this track a remix|remix or a cover/i);
  return JSON.stringify({{ok:true, filled:filled}});
}})();
"""

SUBMIT_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const sw = document.querySelector('input[name="songwriter"]');
  let root = sw ? sw.parentElement : document.body;
  for (let i = 0; i < 20 && root && root !== document.body; i++) {
    if ([...root.querySelectorAll('button')].some(b => /^submit$/i.test((b.innerText||'').trim()))) break;
    root = root.parentElement;
  }
  root = root || document.body;
  const submit = [...root.querySelectorAll('button[type=submit],button')]
    .filter(el => el.offsetParent !== null)
    .find(el => {
      const t = norm(el.innerText);
      return t === 'submit' || t.includes('submit for monetization') || t.includes('resubmit for');
    });
  if (!submit) return JSON.stringify({submitted: false, reason: 'no-submit-button'});
  if (submit.disabled) return JSON.stringify({submitted: false, reason: 'submit-disabled', disabled: true});
  submit.scrollIntoView({block: 'center'});
  submit.focus();
  submit.click();
  const form = submit.closest('form');
  if (form && form.requestSubmit) form.requestSubmit(submit);
  return JSON.stringify({submitted: true, disabled: false});
})();
"""

VERIFY_SUBMITTED_JS = """
(function() {
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const hasForm = !!document.querySelector('input[name="songwriter"]');
  const submit = [...document.querySelectorAll('button')]
    .find(b => norm(b.innerText) === 'submit' && b.offsetParent !== null);
  const modalOpen = hasForm || !!submit;
  return JSON.stringify({ok: !modalOpen, modalOpen, submitStillVisible: !!submit});
})();
"""


def verify_track_monetized_js(title: str) -> str:
    title_json = json.dumps(title)
    return f"""
(function() {{
  const want = {title_json};
  const squash = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
  const wantSq = squash(want);
  const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const matches = (a, b) => {{
    const x = squash(a), y = squash(b);
    return x === y || x.includes(y) || y.includes(x);
  }};
  let status = 'not-found';
  const buttons = [...document.querySelectorAll('button,a,[role=button]')].filter(el => el.offsetParent !== null);
  for (const btn of buttons) {{
    const track = (() => {{
      let p = btn;
      for (let i = 0; i < 12 && p; i++) {{
        const lines = (p.innerText||'').trim().split('\\n').map(s => s.trim()).filter(Boolean);
        if (lines.length >= 2 && /gary makinson/i.test(lines[1]||'')) return lines[0];
        p = p.parentElement;
      }}
      return '';
    }})();
    if (!track || !matches(track, want)) continue;
    const label = norm(btn.innerText);
    if (/monetize this track|resubmit this track/i.test(label)) {{
      status = 'still-unmonetized';
      break;
    }}
    if (/cancel monetization|monetizing|in review/i.test(label)) {{ status = 'submitted'; break; }}
  }}
  if (status === 'not-found') {{
    const body = document.body.innerText || '';
    const lines = body.split('\\n').map(s => s.trim()).filter(Boolean);
    for (let i = 0; i < lines.length; i++) {{
      if (!matches(lines[i], want)) continue;
      const snippet = lines.slice(i, i + 8).join(' ').toLowerCase();
      if (/cancel monetization|monetizing|in review|submitted/i.test(snippet)) status = 'submitted';
      else if (/monetize this track|resubmit this track/i.test(snippet)) status = 'still-unmonetized';
      else status = 'found';
      break;
    }}
  }}
  const ok = status === 'submitted' || (status === 'found' && !/monetize this track|resubmit this track/i.test(want));
  return JSON.stringify({{ok: status === 'submitted' || status === 'found', status, want: want}});
}})();
"""


def pause(low: float = 0.4, high: float = 0.8) -> None:
    time.sleep(random.uniform(low, high))


def select_content_rating(retries: int = 2) -> dict:
    """Open dropdown, pick the configured rating, and verify selection stuck."""
    last = {"ok": False}
    for attempt in range(1, retries + 1):
        opened = run_js(open_content_rating_js())
        pause(0.35, 0.55)
        picked = run_js(select_content_rating_js())
        pause(0.35, 0.55)
        if not picked.get("ok"):
            run_js(select_content_rating_js())
            pause(0.25, 0.4)
        verify = run_js(VERIFY_CONTENT_RATING_JS)
        if verify.get("ok"):
            last = {"ok": True, "attempt": attempt, "text": verify.get("text", "")}
            return last
        log(f"  Content rating retry {attempt}/{retries}")
        pause(0.2, 0.4)
    return last


def wait_for_submit_ready(max_rounds: int = 6) -> dict:
    """Fill form and poll until Submit button is enabled."""
    last = run_js(VERIFY_FORM_READY_JS)
    if last.get("ok") or last.get("submitReady"):
        return last
    for round_num in range(1, max_rounds + 1):
        if not last.get("writerOk"):
            click_i_wrote_this_song(retries=2)
        run_js(fill_artist_fields_js())
        if not last.get("rightsOk"):
            click_rights(retries=2)
        if not last.get("ratingOk"):
            select_content_rating(retries=2)
        pause(0.3, 0.5)
        last = run_js(VERIFY_FORM_READY_JS)
        if last.get("ok") or last.get("submitReady"):
            return last
        log(
            f"  Waiting for Submit ({round_num}/{max_rounds}): "
            f"writer={last.get('writerOk')} artist={last.get('artistOk')} "
            f"rights={last.get('rightsOk')} rating={last.get('ratingOk')} "
            f"disabled={last.get('submitDisabled')}"
        )
    return last


def click_submit(retries: int = 4) -> dict:
    """Click Submit only when enabled; verify modal closed."""
    ready = wait_for_submit_ready()
    if not ready.get("ok") and not ready.get("submitReady"):
        log("  Submit still disabled after form prep.")
        return {"submitted": False, "verified": False, "reason": "submit-disabled"}

    last = {"submitted": False, "verified": False}
    for attempt in range(1, retries + 1):
        submit = run_js(SUBMIT_JS)
        pause(1.4, 2.2)
        verified = run_js(VERIFY_SUBMITTED_JS)
        last = {
            "submitted": submit.get("submitted", False),
            "verified": verified.get("ok", False),
            "reason": submit.get("reason", ""),
            "attempt": attempt,
        }
        if last["verified"]:
            last["submitted"] = True
            return last
        if submit.get("reason") == "submit-disabled":
            ready = wait_for_submit_ready(max_rounds=4)
            if not ready.get("ok") and not ready.get("submitReady"):
                break
        log(f"  Submit retry {attempt}/{retries} ({submit.get('reason', 'not-verified')})")
        pause(0.3, 0.6)
    return last


def click_rights(retries: int = 3) -> dict:
    last = {"ok": False}
    for attempt in range(1, retries + 1):
        run_js(fill_artist_fields_js())
        last = run_js(CLICK_RIGHTS_JS)
        pause(0.2, 0.4)
        ready = run_js(VERIFY_FORM_READY_JS)
        if last.get("ok") or ready.get("rightsOk"):
            last["ok"] = True
            return last
        log(f"  Rights checkbox retry {attempt}/{retries}")
        pause(0.2, 0.35)
    return last


def click_i_wrote_this_song(retries: int = 2) -> dict:
    """Select 'I wrote this song / I represent the writer(s)' with verify + retry."""
    last = {"ok": False}
    for attempt in range(1, retries + 1):
        last = run_js(CLICK_WRITER_JS)
        pause(0.2, 0.4)
        verify = run_js(VERIFY_WRITER_JS)
        if verify.get("ok"):
            last["ok"] = True
            last["attempt"] = attempt
            return last
        log(f"  Songwriter retry {attempt}/{retries} (wrong still selected)")
        pause(0.2, 0.35)
    return last


def scroll_page(times: int = 12, delay: float = 0.22) -> None:
    """Scroll to bottom so SoundCloud lazy-loads the full track list."""
    last_height = 0
    stable = 0
    for _ in range(times):
        result = run_js(SCROLL_JS)
        height = result.get("height", 0)
        if height == last_height:
            stable += 1
            if stable >= 2 and _ > 3:
                break
        else:
            stable = 0
        last_height = height
        time.sleep(delay)


def light_scroll(times: int = 2) -> None:
    """Quick scroll between tracks — enough to keep lazy-loaded rows visible."""
    for _ in range(times):
        run_js(SCROLL_JS)
        time.sleep(0.12)


def setup_help() -> None:
    log("ONE-TIME SAFARI SETUP:")
    log("1. Safari > Settings > Advanced > Show features for web developers")
    log("2. Develop > Allow JavaScript from Apple Events")
    log("3. Log in at artists.soundcloud.com/monetization/soundcloud")


def count_unmonetized(full: bool = True) -> int:
    """Count tracks needing monetize/resubmit."""
    if full:
        run_js(SCROLL_TOP_JS)
        scroll_page(times=14, delay=0.2)
        n = run_js(COUNT_JS).get("count", 0)
        raw = run_js(COUNT_RAW_JS)
        raw_total = raw.get("monetize", 0) + raw.get("resubmit", 0)
        if n == 0 or raw_total > n * 1.5:
            scroll_page(times=20, delay=0.22)
            n = run_js(COUNT_JS).get("count", 0)
            raw = run_js(COUNT_RAW_JS)
            raw_total = raw.get("monetize", 0) + raw.get("resubmit", 0)
        if n == 0 and raw_total > 0:
            log(f"  Count mismatch — {raw_total} buttons visible, recounting...")
            scroll_page(times=20, delay=0.24)
            n = run_js(COUNT_JS).get("count", 0)
        page = run_js(PAGE_READY_JS)
        page_total = page.get("monetize", 0) + page.get("resubmit", 0)
        body = run_js(BODY_COUNT_JS)
        body_total = body.get("monetize", 0) + body.get("resubmit", 0)
        return max(n, raw_total, page_total, body_total)
    light_scroll(2)
    return run_js(COUNT_JS).get("count", 0)


def confirm_no_tracks_left() -> bool:
    """Require three full scroll counts of zero before stopping."""
    if not page_is_ready():
        log("  Zero-check skipped — track list not loaded yet.")
        return False
    for attempt in range(1, 4):
        pause(4, 6)
        remaining = count_unmonetized(full=True)
        raw = run_js(COUNT_RAW_JS)
        raw_total = raw.get("monetize", 0) + raw.get("resubmit", 0)
        page = run_js(PAGE_READY_JS)
        page_total = page.get("monetize", 0) + page.get("resubmit", 0)
        body = run_js(BODY_COUNT_JS)
        body_total = body.get("monetize", 0) + body.get("resubmit", 0)
        if remaining > 0 or raw_total > 0 or page_total > 0 or body_total > 0:
            log(
                f"  Zero-check {attempt}/3 failed — "
                f"{remaining} unique / {raw_total} buttons / "
                f"{page_total} page / {body_total} body matches."
            )
            return False
    return True


def process_one(
    content_rating: str,
    skipped: set[str] | None = None,
) -> dict:
    n = count_unmonetized(full=True)
    if n == 0:
        recover_safari()
        pause(4, 6)
        n = count_unmonetized(full=True)
    log(f"Unmonetized tracks remaining: {n}")
    if n == 0:
        return {"ok": False, "msg": "All done — no unmonetized tracks."}

    click = run_js(click_monetize_js(skipped))
    if not click.get("ok"):
        light_scroll(4)
        scroll_page(times=8, delay=0.18)
        click = run_js(click_monetize_js(skipped))
        if not click.get("ok"):
            return click

    title = click.get("title") or "track"
    remaining = click.get("remaining", 0)
    log(f"Clicked Monetize: {title} (~{remaining} visible ahead)")
    pause(1.8, 2.8)

    writer = click_i_wrote_this_song()
    log(f"I wrote this song / represent writer: {writer.get('ok', False)}")

    for pass_num in range(1, 4):
        fill = run_js(fill_artist_fields_js())
        if fill.get("filled", 0) >= 3:
            break
        pause(0.35, 0.55)
    log(f"Filled Gary Makinson in {fill.get('filled', 0)} fields")

    writer = click_i_wrote_this_song(retries=2)
    log(f"Songwriter re-check before rating: {writer.get('ok', False)}")

    pause(0.3, 0.5)
    rights = click_rights(retries=3)
    log(f"Rights checkbox: {rights.get('ok', False)}")

    pause(0.3, 0.5)
    rating = select_content_rating(retries=5)
    log(f"Content rating Explicit: {rating.get('ok', False)}")

    writer = click_i_wrote_this_song(retries=2)
    log(f"Songwriter re-check before submit: {writer.get('ok', False)}")

    pause(0.3, 0.5)
    submit = click_submit()
    log(
        f"Submitted: {submit.get('submitted', False)} "
        f"(modal closed: {submit.get('verified', False)})"
    )

    if not submit.get("verified"):
        run_js(RELOAD_JS)
        pause(1.0, 1.5)
        return {
            "ok": False,
            "title": title,
            "msg": f"Submit failed for {title} — skipping for now",
            "skip": True,
            "submitted": False,
            "explicit": rating.get("ok", False),
            "writer": writer.get("ok", False),
            "remaining": remaining,
        }

    run_js(RELOAD_JS)
    pause(5, 8)
    scroll_page(times=8, delay=0.2)

    track_check = run_js(verify_track_monetized_js(title))
    log(
        f"Track verified on list: {track_check.get('ok', False)} "
        f"({track_check.get('status', 'unknown')})"
    )

    still_unmonetized = track_check.get("status") == "still-unmonetized"
    success = (
        writer.get("ok", False)
        and not still_unmonetized
        and (
            track_check.get("ok", False)
            or track_check.get("status") in ("submitted", "found")
            or submit.get("verified")
        )
    )
    return {
        "ok": success,
        "title": title,
        "submitted": submit.get("submitted", False),
        "verified": submit.get("verified", False),
        "monetized": track_check.get("ok", False),
        "trackStatus": track_check.get("status", ""),
        "explicit": rating.get("ok", False),
        "writer": writer.get("ok", False),
        "remaining": remaining,
        "msg": "" if success else f"Not verified monetized: {title}",
    }


PAGE_READY_JS = """
(function() {
  const body = document.body.innerText || '';
  return JSON.stringify({
    synced: /your tracks are synced/i.test(body),
    hasTracks: /cancel monetization|monetize this track|resubmit this track/i.test(body),
    monetize: (body.match(/Monetize this track/gi) || []).length,
    resubmit: (body.match(/Resubmit this track/gi) || []).length
  });
})();
"""

BODY_COUNT_JS = """
(function() {
  const body = document.body.innerText || '';
  return JSON.stringify({
    monetize: (body.match(/Monetize this track/gi) || []).length,
    resubmit: (body.match(/Resubmit this track/gi) || []).length,
    cancel: (body.match(/Cancel Monetization/gi) || []).length
  });
})();
"""


def page_is_ready() -> bool:
    state = run_js(PAGE_READY_JS)
    return bool(state.get("synced") or state.get("hasTracks"))


def wait_for_page() -> int:
    """Wait for monetization page to load and scroll all tracks in."""
    pause(4, 6)
    for attempt in range(1, 4):
        remaining = count_unmonetized(full=True)
        if remaining > 0:
            return remaining
        if page_is_ready():
            log(f"  Track list loaded, retrying count ({attempt}/3)...")
        else:
            log(f"  Waiting for track list ({attempt}/3)...")
        pause(3, 4)
    return count_unmonetized(full=True)


def run_all(limit: int, content_rating: str) -> int:
    results = []
    success = 0
    failed = 0
    skipped_titles: set[str] = set()
    attempts = 0
    idle_passes = 0
    max_attempts = max(limit * 3, 300)

    initial_remaining = wait_for_page()
    remaining = initial_remaining
    log(f"Starting batch — unmonetized tracks found: {remaining}")
    if remaining == 0 and confirm_no_tracks_left():
        REPORT_PATH.write_text(json.dumps(results, indent=2), encoding="utf-8")
        log("No unmonetized tracks to process.")
        return 0
    if remaining == 0:
        log("False zero on startup — continuing after full rescroll.")

    while success < limit and attempts < max_attempts:
        attempts += 1
        try:
            result = process_one(content_rating, skipped_titles)
        except Exception as exc:
            log(f"ERROR: {exc}")
            failed += 1
            results.append({"ok": False, "error": str(exc)})
            if "execution error" in str(exc) or "jsResult" in str(exc):
                recover_safari()
                scroll_page()
            pause(1, 2)
            continue

        results.append(result)
        if result.get("msg") == "All done — no unmonetized tracks.":
            idle_passes += 1
            if initial_remaining > 5 and success < max(3, initial_remaining - 3):
                log(
                    f"Only {success}/{initial_remaining} done — "
                    "refusing early exit, reloading Safari."
                )
                recover_safari()
                pause(5, 8)
                scroll_page(times=15, delay=0.2)
                if idle_passes >= 8:
                    log("Too many idle passes — stopping to avoid infinite loop.")
                    break
                continue
            idle_passes = 0
            pause(5, 8)
            scroll_page(times=15, delay=0.2)
            remaining = count_unmonetized(full=True)
            if remaining > 0:
                log(f"False zero after reload — {remaining} tracks still found, continuing.")
                continue
            if confirm_no_tracks_left():
                log("Confirmed: no unmonetized tracks left.")
                break
            log("False zero — tracks still on page, continuing.")
            continue

        if result.get("ok"):
            success += 1
            idle_passes = 0
            log(f"--- Verified done {success} (skipped: {len(skipped_titles)}) ---")
        elif result.get("skip") and result.get("title"):
            skipped_titles.add(result["title"])
            failed += 1
            log(result.get("msg", "Skipped track."))
        elif result.get("title"):
            failed += 1
            log(result.get("msg", "Track not verified — will retry on next pass."))
        else:
            failed += 1
            log(result.get("msg", "No track clicked — rescrolling."))
            scroll_page(times=10, delay=0.2)

        pause(0.4, 0.8)

    REPORT_PATH.write_text(json.dumps(results, indent=2), encoding="utf-8")
    remaining = count_unmonetized(full=True)
    log(
        f"Finished: {success} verified, {failed} failed, "
        f"{remaining} still unmonetized. Report: {REPORT_PATH}"
    )
    return 0


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--setup", action="store_true")
    parser.add_argument("--all", action="store_true", help="Monetize all unmonetized tracks")
    parser.add_argument("--limit", type=int, default=999)
    args = parser.parse_args()

    if args.setup:
        setup_help()
        return 0

    if args.all:
        recover_safari()
        pause(3, 4)
        return run_all(args.limit, CONTENT_RATING)

    parser.print_help()
    return 1


if __name__ == "__main__":
    sys.exit(main())

