/* global React, ReactDOM, TranscribePane, MetadataPane, GH, GHOnboarding, GHSettingsModal */
const { useState, useEffect, useCallback, useRef, useMemo } = React;

// ---- deep set immutable ----
function setIn(obj, path, value) {
  if (path.length === 0) return value;
  const [k, ...rest] = path;
  const isArr = Array.isArray(obj);
  const next = isArr ? [...(obj || [])] : { ...(obj || {}) };
  next[k] = setIn(obj == null ? undefined : obj[k], rest, value);
  return next;
}

// ---- main app ----
function App() {
  const [letterIds, setLetterIds] = useState(null);
  const [letterId, setLetterId] = useState(null);
  const [letter, setLetter] = useState(null);
  const [original, setOriginal] = useState(null); // for dirty check
  const [tab, setTab] = useState("transcribe");
  const [error, setError] = useState(null);
  const [saveState, setSaveState] = useState(""); // text shown next to save
  const [lastPR, setLastPR] = useState(null); // { html_url, number }

  // GitHub auth state
  const [ghCfg, setGhCfg] = useState(() => GH.loadGHConfig());
  const [ghToken, setGhToken] = useState(() => GH.loadGHToken());
  const [ghUser, setGhUser] = useState(() => GH.loadGHUser());
  const [showSettings, setShowSettings] = useState(false);

  // Remote-change detection
  const [baseFileSha, setBaseFileSha] = useState(null);     // sha of file when we loaded it
  const [baseCommitSha, setBaseCommitSha] = useState(null); // commit of file when we loaded it
  const [remoteUpdate, setRemoteUpdate] = useState(null);   // { commitSha } when something newer landed
  const pollRef = useRef(null);

  const ghReady = !!(ghCfg && ghToken && ghUser);
  const repoParsed = ghCfg ? GH.parseRepoUrl(ghCfg.repo) : null;

  // Discover letters by listing letters/ at the repo root via GitHub API.
  // Falls back to a static letters/index.json if present (back-compat).
  useEffect(() => {
    if (!ghReady || !repoParsed) return;
    setError(null);
    (async () => {
      try {
        const entries = await GH.listDir(ghToken, {
          owner: repoParsed.owner, repo: repoParsed.repo,
          path: "letters", ref: ghCfg.branch,
        });
        const ids = entries
          .filter(e => e.type === "dir" && /^letter_\w+/i.test(e.name))
          .map(e => e.name)
          .sort();
        if (ids.length === 0) throw new Error("No letter_* folders found in letters/");
        setLetterIds(ids);
        if (!letterId || !ids.includes(letterId)) setLetterId(ids[0]);
      } catch (e) {
        setError(String(e.message || e));
      }
    })();
  }, [ghReady, ghCfg?.repo, ghCfg?.branch]);

  // Load selected letter — fetch via GitHub API so we get the file SHA + can read
  // edits that haven't been deployed by Pages yet.
  useEffect(() => {
    if (!ghReady || !letterId || !repoParsed) return;
    setLetter(null);
    setOriginal(null);
    setBaseFileSha(null);
    setBaseCommitSha(null);
    setRemoteUpdate(null);

    const path = `letters/${letterId}/final.json`;
    (async () => {
      try {
        const file = await GH.getFile(ghToken, {
          owner: repoParsed.owner, repo: repoParsed.repo,
          path, ref: ghCfg.branch,
        });
        if (!file) throw new Error(`File not found: ${path}`);
        const data = JSON.parse(file.content);
        if (data?.transcription?.wordPositions) {
          for (const w of data.transcription.wordPositions) {
            w._original_final_text = w.final_text;
          }
        }
        setLetter(data);
        setOriginal(JSON.stringify(stripInternal(data)));
        setBaseFileSha(file.sha);
        const commitSha = await GH.getLatestCommitForPath(ghToken, {
          owner: repoParsed.owner, repo: repoParsed.repo,
          path, ref: ghCfg.branch,
        });
        setBaseCommitSha(commitSha);
      } catch (e) {
        setError(String(e.message || e));
      }
    })();
  }, [letterId, ghReady, ghCfg?.repo, ghCfg?.branch]);

  // Live-refresh poll: every 30s, ask GitHub for the latest commit touching
  // this file. If newer than the one we loaded, surface a banner.
  useEffect(() => {
    if (!ghReady || !letterId || !repoParsed || !baseCommitSha) return;
    const path = `letters/${letterId}/final.json`;
    let cancelled = false;
    async function check() {
      try {
        const sha = await GH.getLatestCommitForPath(ghToken, {
          owner: repoParsed.owner, repo: repoParsed.repo,
          path, ref: ghCfg.branch,
        });
        if (cancelled) return;
        if (sha && sha !== baseCommitSha) {
          setRemoteUpdate({ commitSha: sha });
        }
      } catch {/* swallow */}
    }
    pollRef.current = setInterval(check, 30000);
    return () => { cancelled = true; clearInterval(pollRef.current); };
  }, [ghReady, letterId, baseCommitSha, ghCfg?.branch, ghCfg?.repo]);

  const dirty = useMemo(() => {
    if (!letter || !original) return false;
    return JSON.stringify(stripInternal(letter)) !== original;
  }, [letter, original]);

  // ---- mutators (unchanged shape) ----
  const updateWord = useCallback((page, wordIndex, text) => {
    setLetter(prev => {
      if (!prev) return prev;
      const idx = prev.transcription.wordPositions.findIndex(
        w => w.page === page && w.wordIndex === wordIndex
      );
      if (idx === -1) return prev;
      const nextWords = prev.transcription.wordPositions.slice();
      const cur = nextWords[idx];
      const isUserAdded = cur.source === "user_added";
      nextWords[idx] = {
        ...cur,
        final_text: text,
        ...(isUserAdded ? {} : {
          source: "user_corrected",
          textractConfidence: 100,
        }),
      };
      return syncTranscriptionStrings({
        ...prev,
        transcription: { ...prev.transcription, wordPositions: nextWords },
      });
    });
  }, []);

  const deleteWord = useCallback((page, wordIndex) => {
    setLetter(prev => {
      if (!prev) return prev;
      const wp = prev.transcription.wordPositions.filter(
        w => !(w.page === page && w.wordIndex === wordIndex)
      );
      return syncTranscriptionStrings({
        ...prev,
        transcription: { ...prev.transcription, wordPositions: wp },
      });
    });
  }, []);

  const splitWord = useCallback((page, wordIndex, textA, textB) => {
    setLetter(prev => {
      if (!prev) return prev;
      const wp = prev.transcription.wordPositions;
      const idx = wp.findIndex(w => w.page === page && w.wordIndex === wordIndex);
      if (idx === -1) return prev;
      const orig = wp[idx];
      const halfW = orig.width / 2;
      const newIdx = nextWordIndex(wp, page);
      const a = {
        ...orig,
        width: halfW,
        final_text: textA,
        source: "user_corrected",
        textractConfidence: 100,
        _original_final_text: orig._original_final_text,
      };
      const b = {
        ...orig,
        wordIndex: newIdx,
        left: orig.left + halfW,
        width: halfW,
        final_text: textB,
        source: "user_corrected",
        textractConfidence: 100,
        _original_final_text: undefined,
      };
      const next = wp.slice();
      next.splice(idx, 1, a, b);
      return syncTranscriptionStrings({
        ...prev,
        transcription: { ...prev.transcription, wordPositions: next },
      });
    });
  }, []);

  const addWord = useCallback((page, rect, text, insertTarget, pageWordsAtCall) => {
    setLetter(prev => {
      if (!prev) return prev;
      const wp = prev.transcription.wordPositions;
      const newIdx = nextWordIndex(wp, page);
      const ref = pageWordsAtCall.find(w => w.wordIndex === insertTarget?.wordIndex);
      let lineIndex = 0, leftForOrder = rect.left, lineTop = rect.top;
      let lineLeft = rect.left, lineWidth = rect.width, lineHeight = rect.height;
      if (ref) {
        lineIndex = ref.lineIndex ?? 0;
        lineTop = ref.lineTop ?? rect.top;
        lineLeft = ref.lineLeft ?? rect.left;
        lineWidth = ref.lineWidth ?? rect.width;
        lineHeight = ref.lineHeight ?? rect.height;
        const sameLine = pageWordsAtCall
          .filter(w => (w.lineIndex ?? 0) === lineIndex)
          .sort((a, b) => a.left - b.left);
        const refPos = sameLine.findIndex(w => w.wordIndex === ref.wordIndex);
        if (insertTarget.side === "before") {
          const prevW = sameLine[refPos - 1];
          leftForOrder = prevW
            ? (prevW.left + prevW.width + ref.left) / 2
            : ref.left - 0.0001;
        } else {
          const nextW = sameLine[refPos + 1];
          leftForOrder = nextW
            ? (ref.left + ref.width + nextW.left) / 2
            : ref.left + ref.width + 0.0001;
        }
      }
      const newWord = {
        wordIndex: newIdx,
        textract_text: null, sonnet_text: null, opus_text: null,
        final_text: text,
        textractConfidence: 100, alignment_score: 0,
        source: "user_added",
        zone: ref?.zone ?? "body",
        left: rect.left, top: rect.top, width: rect.width, height: rect.height,
        lineIndex, lineTop, lineLeft, lineWidth, lineHeight,
        page,
        _orderLeft: leftForOrder,
      };
      const next = [...wp, newWord];
      return syncTranscriptionStrings({
        ...prev,
        transcription: { ...prev.transcription, wordPositions: next },
      });
    });
  }, []);

  const updateMetadata = useCallback((path, value) => {
    setLetter(prev => prev ? setIn(prev, ["metadata", ...path], value) : prev);
  }, []);

  // ---- save → open PR ----
  const onSave = async () => {
    if (!letter || !ghReady || !repoParsed) return;
    setSaveState("Checking for remote changes…");
    setLastPR(null);
    const clean = stripInternal(letter);
    const path = `letters/${letterId}/final.json`;

    try {
      // Conflict check: if the file SHA on the base branch has changed
      // since we loaded, warn before overwriting.
      const fresh = await GH.getFile(ghToken, {
        owner: repoParsed.owner, repo: repoParsed.repo,
        path, ref: ghCfg.branch,
      });
      if (fresh && baseFileSha && fresh.sha !== baseFileSha) {
        const ok = window.confirm(
          "This letter has been updated on GitHub since you opened it.\n\n" +
          "Saving now will create a PR based on the older version. " +
          "It's safer to Reload first and re-apply your edits.\n\n" +
          "OK = save anyway   |   Cancel = stop and reload manually"
        );
        if (!ok) { setSaveState(""); return; }
      }

      setSaveState("Creating pull request…");
      const pr = await GH.saveLetterViaPR({
        token: ghToken,
        repo: repoParsed,
        baseBranch: ghCfg.branch,
        letterId,
        jsonString: JSON.stringify(clean, null, 2),
        baseFileSha: fresh?.sha || baseFileSha,
        viewerLogin: ghUser?.login,
      });
      // Once the PR exists we treat the working copy as "saved" so dirty=false.
      setOriginal(JSON.stringify(clean));
      setLastPR({ html_url: pr.html_url, number: pr.number });
      setSaveState(`PR #${pr.number} opened — review &amp; merge to publish`);
    } catch (e) {
      setSaveState("");
      alert("Save failed: " + (e.message || e));
    }
  };

  // ---- reload remote into the editor ----
  const onReloadRemote = async () => {
    if (dirty && !confirm("Reloading will discard your unsaved edits. Continue?")) return;
    // Force a re-fetch by bumping letterId effect — easiest way: nudge state.
    const id = letterId;
    setLetterId(null);
    setTimeout(() => setLetterId(id), 0);
  };

  // ---- onboarding / settings handlers ----
  const onOnboarded = ({ cfg, token, user }) => {
    setGhCfg(cfg); setGhToken(token); setGhUser(user);
  };
  const onSettingsSaved = ({ cfg, token }) => {
    setGhCfg(cfg);
    if (token) setGhToken(token);
    setShowSettings(false);
    // Re-fetch the current letter against the (possibly new) repo/branch.
    const id = letterId;
    setLetterId(null);
    setTimeout(() => setLetterId(id), 0);
  };
  const onDisconnect = () => {
    if (!confirm("Disconnect from GitHub? You'll need to sign in again.")) return;
    GH.saveGHToken("");
    GH.saveGHUser(null);
    setGhToken(""); setGhUser(null);
    setShowSettings(false);
  };

  // ---- render ----
  if (!ghReady) {
    return <GHOnboarding onDone={onOnboarded} />;
  }

  return (
    <div className="app">
      {remoteUpdate && (
        <div className="remote-banner">
          <span className="remote-dot" />
          <span>This letter was updated on GitHub by someone else.</span>
          <div style={{ flex: 1 }} />
          <button onClick={onReloadRemote}>Reload</button>
          <button className="subtle" onClick={() => setRemoteUpdate(null)}>Dismiss</button>
        </div>
      )}

      <div className="topbar">
        <div className="brand">
          Letters Editor
          <span className="sub">transcription &amp; metadata review</span>
        </div>
        <div className="spacer" />
        <div className="picker">
          <label htmlFor="letter-picker">Letter</label>
          <select
            id="letter-picker"
            value={letterId || ""}
            onChange={(e) => {
              if (dirty && !confirm("You have unsaved changes. Switch anyway?")) return;
              setLetterId(e.target.value);
            }}
          >
            {(letterIds || []).map(id => (
              <option key={id} value={id}>{id}</option>
            ))}
          </select>
        </div>
        <div className={"save-state" + (dirty ? " dirty" : "")}>
          {lastPR && !dirty ? (
            <a href={lastPR.html_url} target="_blank" rel="noreferrer" className="pr-link">
              PR #{lastPR.number} opened ↗
            </a>
          ) : (
            saveState || (dirty ? "Unsaved changes" : "All changes saved")
          )}
        </div>
        <button className="primary" onClick={onSave} disabled={!dirty || !letter}>
          Save → Open PR
        </button>
        <button
          className="gh-chip"
          onClick={() => setShowSettings(true)}
          title="GitHub settings"
        >
          {ghUser?.avatar_url && <img src={ghUser.avatar_url} alt="" />}
          <span>{ghUser?.login || "settings"}</span>
        </button>
      </div>

      {error && (
        <div className="error-bar">
          <span>{error}</span>
          <button className="subtle" onClick={() => setError(null)}>×</button>
        </div>
      )}

      <div className="tabs">
        <button
          className={tab === "transcribe" ? "active" : ""}
          onClick={() => setTab("transcribe")}
        >
          Transcribe
          {dirty && tabHasChanges(letter, original, "transcribe") && <span className="dirty-dot" />}
        </button>
        <button
          className={tab === "metadata" ? "active" : ""}
          onClick={() => setTab("metadata")}
        >
          Metadata
          {dirty && tabHasChanges(letter, original, "metadata") && <span className="dirty-dot" />}
        </button>
      </div>

      <div className="tab-body">
        {!letter ? (
          <div className="loading">Loading {letterId}…</div>
        ) : tab === "transcribe" ? (
          <TranscribePane
            letterId={letterId}
            letter={letter}
            updateWord={updateWord}
            addWord={addWord}
            deleteWord={deleteWord}
            splitWord={splitWord}
            repoParsed={repoParsed}
            branch="main"
          />
        ) : (
          <MetadataPane
            letter={letter}
            updateMetadata={updateMetadata}
          />
        )}
      </div>

      {showSettings && (
        <GHSettingsModal
          initial={{ cfg: ghCfg, user: ghUser }}
          onClose={() => setShowSettings(false)}
          onSaved={onSettingsSaved}
          onDisconnect={onDisconnect}
        />
      )}
    </div>
  );
}

function stripInternal(letter) {
  if (!letter) return letter;
  const wp = letter.transcription?.wordPositions;
  if (!wp) return letter;
  return {
    ...letter,
    transcription: {
      ...letter.transcription,
      wordPositions: wp.map(w => {
        const { _original_final_text, _orderLeft, ...rest } = w;
        return rest;
      }),
    },
  };
}

function nextWordIndex(wordPositions, page) {
  let max = -1;
  for (const w of wordPositions) {
    if (w.page === page && typeof w.wordIndex === "number" && w.wordIndex > max) {
      max = w.wordIndex;
    }
  }
  return max + 1;
}

function syncTranscriptionStrings(letter) {
  if (!letter?.transcription?.wordPositions) return letter;
  const wp = letter.transcription.wordPositions;
  const byPage = {};
  const pages = [...new Set(wp.map(w => w.page))].sort((a, b) => a - b);
  for (const p of pages) {
    const pageWords = wp.filter(w => w.page === p).slice().sort((a, b) => {
      const la = a.lineIndex ?? 0, lb = b.lineIndex ?? 0;
      if (la !== lb) return la - lb;
      const al = a._orderLeft ?? a.left ?? 0;
      const bl = b._orderLeft ?? b.left ?? 0;
      return al - bl;
    });
    const lines = [];
    let curLine = -1;
    for (const w of pageWords) {
      const li = w.lineIndex ?? 0;
      if (li !== curLine) { lines.push([]); curLine = li; }
      lines[lines.length - 1].push(w.final_text ?? "");
    }
    byPage[p] = lines.map(l => l.join(" ")).join("\n");
  }
  const combined = pages
    .map(p => `--- page_${p} ---\n${byPage[p]}\n`)
    .join("\n---\n\n");
  return {
    ...letter,
    transcription: {
      ...letter.transcription,
      byPage, combined,
    },
  };
}

function tabHasChanges(letter, original, which) {
  if (!letter || !original) return false;
  try {
    const orig = JSON.parse(original);
    const cur = stripInternal(letter);
    if (which === "transcribe") {
      return JSON.stringify(cur.transcription) !== JSON.stringify(orig.transcription);
    }
    return JSON.stringify(cur.metadata) !== JSON.stringify(orig.metadata);
  } catch {
    return false;
  }
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
