// ui/collaboration.jsx — the two things a team files against a COMPONENT: a shared
// comment thread and uploaded supporting documents.
//
// These hang off the component (an ECU build with an uploaded SBOM), not off each
// vulnerability. A team's working notes and the artefacts an audit asks for — the
// test report for this build, the supplier advisory they acted on — describe the
// unit under test, not one of the hundreds of CVEs its SBOM happens to match, so
// filing them per-CVE scattered one conversation across a whole ledger.
//
// Both are rendered by ComponentDetail (screens/components.jsx) and both talk to
// /api/components/{id}/… . They live in their own file rather than in that screen
// because they are self-contained, sizeable, and share the helpers below.

/* ------------------------------------------------------------------ shared */
// The `dsec` section chrome the detail pages use. Small local copy of the one in
// screens/detail.jsx: that file's version is private to the vulnerability page, and
// this file loads before it.
function CollabSection({ icon, title, kicker, children }) {
  return (
    <section className="dsec">
      <div className="dsec-head">
        <span className="dsec-ic"><Icon name={icon} size={16} /></span>
        <h3>{title}</h3>
        {kicker && <span className="dsec-kick">{kicker}</span>}
      </div>
      <div className="dsec-body">{children}</div>
    </section>
  );
}

// The signed-in user, read from the shell's localStorage record. Comments and
// evidence only need it to decide whose rows offer Edit / Reupload; the server is
// the authority and refuses a change to someone else's row regardless, so a stale
// or missing value here is a cosmetic issue, never a security one.
function currentUser() {
  try { return JSON.parse(localStorage.getItem("vc_auth_v1")) || {}; } catch (_) { return {}; }
}

// "3 minutes ago" / "12 Mar 2025" — recent activity reads as elapsed time (which is
// what matters in a live thread), anything older as a plain date.
function timeAgo(iso) {
  if (!iso) return "";
  const then = new Date(iso);
  if (isNaN(then)) return "";
  const secs = Math.round((Date.now() - then.getTime()) / 1000);
  if (secs < 60) return "just now";
  const mins = Math.round(secs / 60);
  if (mins < 60) return mins + (mins === 1 ? " minute ago" : " minutes ago");
  const hrs = Math.round(mins / 60);
  if (hrs < 24) return hrs + (hrs === 1 ? " hour ago" : " hours ago");
  const days = Math.round(hrs / 24);
  if (days < 7) return days + (days === 1 ? " day ago" : " days ago");
  return then.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}

function initialsOf(name) {
  return (name || "?").split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join("") || "?";
}

/* --------------------------------------------------------------- comments */
/* A shared discussion thread on the component. Every comment is stored server-side
   against the caller's COMPANY, so all their teammates see the same thread on any
   device — nothing lives in this browser.

   A thread is the one thing on this page expected to change while someone is
   reading it, so it also polls: every 25s, and immediately on tab focus. Both
   skip while the tab is hidden (a backgrounded page must not keep hitting the
   API) and while a reply is being typed, so a poll can never wipe a draft. */
const COMMENT_POLL_MS = 25000;
const COMMENT_MAX = 4000;

function CommentsSection({ componentId, scope, off }) {
  const [thread, setThread] = useState(null);      // null = first load
  const [error, setError] = useState("");
  const [draft, setDraft] = useState("");
  const [busy, setBusy] = useState(false);
  const [editing, setEditing] = useState(null);    // commentId being edited
  const [editDraft, setEditDraft] = useState("");
  // A Jira push that was ATTEMPTED and failed. Deliberately separate from `error`:
  // the comment itself saved fine, so this is a notice about the copy to Jira, not
  // a failure of what the user just did. A push that was never attempted (no issue
  // linked, switch off, no connection) says nothing at all.
  const [jiraNote, setJiraNote] = useState("");
  // Whether what this person writes would reach Jira under the integration account
  // although their company asked for comments under the author's name (0027). Only a
  // vulnerability thread is copied to Jira, so a component thread never asks.
  const [atl, setAtl] = useState(null);
  useEffect(() => {
    if (scope !== "vulnerabilities") return;
    let live = true;
    window.VCApi.jiraOAuthStatus().then((s) => { if (live) setAtl(s); }, () => {});
    return () => { live = false; };
  }, [scope]);
  const askAtl = !!(atl && atl.available && atl.inUse && !atl.connected);
  const me = useMemo(currentUser, []);
  // Ref-mirrored so the poll's callback always sees the live value without having
  // to tear down and re-create the interval on every keystroke.
  const draftRef = useRef("");
  draftRef.current = draft || editDraft;

  // Load + poll. `live` guards every setState against a late response arriving
  // after the user has navigated away.
  useEffect(() => {
    if (off) return; // nothing to read, and a thread already drawn stays as it is
    let live = true;
    let timer = null;

    function load(quiet) {
      if (!quiet) setThread(null);
      return window.VCApi.comments(componentId, undefined, scope).then(
        (list) => { if (live) { setThread(Array.isArray(list) ? list : []); setError(""); } },
        (e) => {
          if (!live) return;
          if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
          // A quiet background poll that fails must not replace a thread the user
          // is reading with an error — only the first load may do that.
          if (quiet) return;
          setThread([]);
          setError((e && e.message) || "Could not load comments.");
        });
    }

    function poll() {
      if (document.hidden || draftRef.current) return; // never clobber a draft
      load(true);
    }

    load(false);
    timer = setInterval(poll, COMMENT_POLL_MS);
    window.addEventListener("focus", poll);
    return () => { live = false; clearInterval(timer); window.removeEventListener("focus", poll); };
  }, [componentId, scope, off]);

  // What to say about the Jira copy, if anything. Only `attempted && !pushed` is
  // worth a word — everything else is either invisible configuration or worked.
  function noteFrom(saved) {
    const j = saved && saved.jira;
    return j && j.attempted && !j.pushed
      ? (j.detail || ("The comment could not be copied to " + (j.issueKey || "Jira") + "."))
      : "";
  }

  // Off with a draft kept: nothing is sent, Ctrl+Enter included. The note above says why.
  function post() {
    const body = draft.trim();
    if (off || !body || busy) return;
    setBusy(true);
    setError("");
    setJiraNote("");
    window.VCApi.addComment(componentId, body, scope).then(
      (created) => {
        setDraft("");
        // Append the server's row rather than the local draft: it carries the
        // authoritative id, author name and timestamp.
        setThread((t) => (t || []).concat(created ? [created] : []));
        setJiraNote(noteFrom(created));
        setBusy(false);
      },
      (e) => {
        if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setError((e && e.message) || "Could not post that comment.");
        setBusy(false);
      });
  }

  function saveEdit(c) {
    const body = editDraft.trim();
    if (off || !body) return;
    if (body === c.body) { setEditing(null); return; }
    setBusy(true);
    setJiraNote("");
    window.VCApi.editComment(componentId, c.commentId, body, scope).then(
      (updated) => {
        setThread((t) => (t || []).map((x) => (x.commentId === c.commentId ? (updated || x) : x)));
        setJiraNote(noteFrom(updated));
        setEditing(null);
        setBusy(false);
      },
      (e) => {
        if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setError((e && e.message) || "Could not save that edit.");
        setBusy(false);
      });
  }

  // There is deliberately no delete here. A comment is part of the component's history,
  // so it can be corrected (which stamps "edited") but never removed.

  // Ctrl/Cmd+Enter posts, matching every other comment box people use.
  function onKey(e) {
    if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); post(); }
  }

  const mine = (c) => me.isAdmin || (me.userId ? c.authorUserId === me.userId : c.authorUsername === me.name);
  const count = thread ? thread.length : 0;

  // Turned off under an open tab: hidden, unless someone is part-way through writing. Their
  // words stay until they have copied them, and the box says why it can no longer post.
  if (off && !draft && !editDraft) return null;

  return (
    <CollabSection icon="doc" title="Comments" kicker={count ? count + (count === 1 ? " comment" : " comments") : "shared with your team"}>
      {off && (
        <div className="cmt-jira-warn">
          <Icon name="alert" size={14} />
          <span>Comments were turned off for your company. Your text is still here to copy, but it can no longer be posted.</span>
        </div>
      )}
      {error && <div className="cmt-error"><Icon name="alert" size={14} />{error}</div>}

      {/* The comment saved; only the copy to Jira did not. Says so without implying
          the user's own action failed, and is dismissible because it is advisory. */}
      {jiraNote && (
        <div className="cmt-jira-warn">
          <Icon name="alert" size={14} />
          <span>Saved here, but not copied to Jira. {jiraNote}</span>
          <button className="cmt-act" onClick={() => setJiraNote("")} title="Dismiss">×</button>
        </div>
      )}

      <div className="cmt-list">
        {thread === null && <div className="cmt-empty">Loading comments…</div>}
        {thread !== null && thread.length === 0 && !error &&
          <div className="cmt-empty">No comments yet. Start the discussion — everyone in your organisation will see it.</div>}

        {(thread || []).map((c) => {
          // A comment that arrived FROM Jira: its author is a Jira user named only by
          // label (no account here), and it is Jira's to edit, never this thread's —
          // an edit made in Jira replaces the copy. So: the label, a chip saying where
          // it came from, and no pencil, even for an admin.
          const fromJira = c.origin === "jira";
          const who = fromJira ? (c.authorLabel || "Jira user") : (c.authorUsername || "Removed user");
          return (
          <article className={"cmt" + (fromJira ? " cmt-from-jira" : "")} key={c.commentId}>
            <span className="cmt-avatar" title={who}>{initialsOf(who)}</span>
            <div className="cmt-main">
              <header className="cmt-head">
                <strong className="cmt-author">{who}</strong>
                <span className="cmt-time" title={c.createdAt || ""}>{timeAgo(c.createdAt)}</span>
                {c.editedAt && <span className="cmt-edited" title={c.editedAt}>edited</span>}
                {/* Where the comment has been. Outbound: it reached the linked issue
                    (comment sync on, record filed). Inbound: it was written on that
                    issue and copied here. */}
                {fromJira
                  ? <span className="cmt-jira cmt-jira-in" title={"Written in Jira" + (c.jiraIssueKey ? " on " + c.jiraIssueKey : "")}>
                      via Jira{c.jiraIssueKey ? " · " + c.jiraIssueKey : ""}
                    </span>
                  : c.jiraIssueKey && (
                    <span className="cmt-jira" title={"Copied to " + c.jiraIssueKey}>{c.jiraIssueKey}</span>
                  )}
                {!fromJira && !off && mine(c) && editing !== c.commentId && (
                  <span className="cmt-acts">
                    <button className="cmt-act" onClick={() => { setEditing(c.commentId); setEditDraft(c.body); }} title="Edit comment">
                      <Icon name="pencil" size={13} />
                    </button>
                  </span>
                )}
              </header>
              {editing === c.commentId ? (
                <div className="cmt-editbox">
                  <textarea
                    className="cmt-input"
                    value={editDraft}
                    maxLength={COMMENT_MAX}
                    rows={3}
                    autoFocus
                    onChange={(e) => setEditDraft(e.target.value)} />
                  <div className="cmt-editacts">
                    <button className="btn btn-ghost btn-sm" onClick={() => { setEditing(null); setEditDraft(""); }}>Cancel</button>
                    <button className="btn btn-sm" disabled={off || busy || !editDraft.trim()} onClick={() => saveEdit(c)}>Save</button>
                  </div>
                </div>
              ) : (
                <p className="cmt-body">{c.body}</p>
              )}
            </div>
          </article>
          );
        })}
      </div>

      {/* Where the Atlassian connection is felt: right above the box a comment is
          written in. The connection itself is managed on My account; this only
          points there, and only while it would change who the comment is from. */}
      {askAtl && (
        <div className="cmt-atl">
          <Icon name="link" size={14} />
          <span>Your comments reach Jira as the integration account. Connect your Atlassian account to post them as you.</span>
          <button className="btn btn-sm" type="button" onClick={() => openMyAccount()}>Connect</button>
        </div>
      )}

      <div className="cmt-compose">
        <span className="cmt-avatar cmt-avatar-me">{initialsOf(me.name)}</span>
        <div className="cmt-composebox">
          <textarea
            className="cmt-input"
            placeholder="Add a comment for your team…"
            value={draft}
            maxLength={COMMENT_MAX}
            rows={3}
            onKeyDown={onKey}
            onChange={(e) => setDraft(e.target.value)} />
          <div className="cmt-composefoot">
            <span className="cmt-hint">
              Visible to everyone in your organisation · {COMMENT_MAX - draft.length} characters left
            </span>
            <button className="btn btn-sm" disabled={off || busy || !draft.trim()} onClick={post}>
              {busy ? <span className="btn-spin" /> : <Icon name="check" size={14} />}
              Post comment
            </button>
          </div>
        </div>
      </div>
    </CollabSection>
  );
}

/* --------------------------------------------------------------- evidence */
/* Supporting documents filed against the component — the artefacts an audit asks
   for (a test report, a supplier advisory, a screenshot of the fix). Company-scoped
   like comments: a teammate on another machine sees the same files.

   Files are served through the authenticated API, never a public URL, so opening
   one means fetching the bytes with the bearer token and handing the browser an
   object URL. Those URLs are revoked on unmount so the blobs are not leaked. */
const EVIDENCE_ICON = { pdf: "doc", csv: "ledger", docx: "doc", jpg: "layers", jpeg: "layers", png: "layers" };

function prettyBytes(n) {
  if (n == null) return "";
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(0) + " KB";
  return (n / 1024 / 1024).toFixed(1) + " MB";
}

function EvidenceCard({ componentId, scope }) {
  const [files, setFiles] = useState(null);        // null = first load
  const [error, setError] = useState("");
  const [progress, setProgress] = useState(null);  // 0-100 while uploading
  const [dragging, setDragging] = useState(false);
  const [openingId, setOpeningId] = useState(null);   // row whose bytes are being fetched
  const [busyId, setBusyId] = useState(null);         // row being replaced
  const [jiraNote, setJiraNote] = useState("");       // the file saved; only the copy to Jira did not
  const inputRef = useRef(null);
  const replaceRef = useRef(null);
  // A ref, not state: it is never rendered, and it is written immediately before the
  // native file dialog opens but read only when the user picks — long after. A ref
  // cannot be stale between those two moments.
  const replaceTargetRef = useRef(null);
  const urlsRef = useRef([]);                      // object URLs to revoke on unmount
  const me = useMemo(currentUser, []);

  const ACCEPT = window.VCApi.EVIDENCE_ACCEPT;
  const MAX_BYTES = window.VCApi.EVIDENCE_MAX_BYTES;
  const ALLOWED = useMemo(() => ACCEPT.split(",").map((s) => s.trim().toLowerCase()), [ACCEPT]);

  useEffect(() => {
    let live = true;
    setFiles(null);
    window.VCApi.evidence(componentId, undefined, scope).then(
      (list) => { if (live) { setFiles(Array.isArray(list) ? list : []); setError(""); } },
      (e) => {
        if (!live) return;
        if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setFiles([]);
        setError((e && e.message) || "Could not load evidence.");
      });
    return () => { live = false; };
  }, [componentId, scope]);

  // Revoke every object URL this card handed the browser. Without this, opening a
  // few large PDFs would pin their bytes in memory for the life of the tab.
  useEffect(() => () => { urlsRef.current.forEach((u) => URL.revokeObjectURL(u)); urlsRef.current = []; }, []);

  // Why a file can't be accepted, or "" if it can. Checked here as well as
  // server-side so the user finds out instantly and a 15 MB file that was never
  // going to be accepted is not uploaded first. Shared by the new-upload and the
  // replace paths so the two can't drift.
  function rejectReason(file) {
    const dot = file.name.lastIndexOf(".");
    const ext = dot < 0 ? "" : file.name.slice(dot).toLowerCase();
    if (ALLOWED.indexOf(ext) < 0) return "Evidence must be a PDF, CSV, DOCX, JPG or PNG file.";
    if (file.size > MAX_BYTES) {
      return "That file is " + prettyBytes(file.size) + ". The limit is " + prettyBytes(MAX_BYTES) + ".";
    }
    return "";
  }

  function upload(file) {
    if (!file) return;
    const bad = rejectReason(file);
    if (bad) { setError(bad); return; }

    setError("");
    setProgress(0);
    window.VCApi.uploadEvidence(componentId, file, setProgress, scope).then(
      (created) => {
        // Newest first, matching the server's ordering.
        setFiles((f) => (created ? [created] : []).concat(f || []));
        setProgress(null);
        noteJira(created);
      },
      (e) => {
        if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setError((e && e.message) || "Could not upload that file.");
        setProgress(null);
      });
  }

  function pick(e) {
    const file = e.target.files && e.target.files[0];
    // Reset the input so re-picking the SAME file still fires onChange.
    e.target.value = "";
    upload(file);
  }

  function onDrop(e) {
    e.preventDefault();
    setDragging(false);
    const file = e.dataTransfer.files && e.dataTransfer.files[0];
    upload(file);
  }

  // `download` = save it; otherwise open it in a new tab (PDFs and images preview
  // in place, other formats download themselves — the server sets the disposition).
  function open(f, download) {
    setOpeningId(f.evidenceId);
    setError("");
    window.VCApi.evidenceBlob(componentId, f.evidenceId, scope).then(
      (blob) => {
        const url = URL.createObjectURL(blob);
        urlsRef.current.push(url);
        const a = document.createElement("a");
        a.href = url;
        if (download) a.download = f.fileName; else a.target = "_blank";
        a.rel = "noopener";
        document.body.appendChild(a);
        a.click();
        a.remove();
        setOpeningId(null);
      },
      (e) => {
        if (e && e.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setError((e && e.message) || "Could not open that file.");
        setOpeningId(null);
      });
  }

  // Reupload = replace this document with a newer version, keeping its place in the
  // list. Evidence is never deleted, so a superseded file is corrected this way.
  // One hidden input is reused for every row; replaceTargetRef says which row asked.
  function pickReplacement(f) {
    replaceTargetRef.current = f.evidenceId;
    setError("");
    if (replaceRef.current) replaceRef.current.click();
  }

  function onReplacementChosen(e) {
    const file = e.target.files && e.target.files[0];
    e.target.value = "";                 // so re-picking the same file still fires
    const targetId = replaceTargetRef.current;
    replaceTargetRef.current = null;
    if (!file || targetId == null) return;

    const bad = rejectReason(file);
    if (bad) { setError(bad); return; }

    setBusyId(targetId);
    window.VCApi.replaceEvidence(componentId, targetId, file, null, scope).then(
      (updated) => {
        // Swap in place — the row keeps its position, name/size/uploader all refresh.
        setFiles((list) => (list || []).map((x) => (x.evidenceId === targetId ? (updated || x) : x)));
        setBusyId(null);
        noteJira(updated);
      },
      (err) => {
        if (err && err.status === 401 && window.__vcOn401) { window.__vcOn401(); return; }
        setError((err && err.message) || "Could not replace that file.");
        setBusyId(null);
      });
  }

  // The write went through; only the copy to Jira did not. Same shape as the
  // thread's note: advisory, and it must not read as the upload having failed.
  function noteJira(row) {
    const j = row && row.jira;
    setJiraNote(j && j.attempted && !j.pushed ? (j.detail || ("The file was not attached to " + (j.issueKey || "the Jira issue") + ".")) : "");
  }

  // A file that arrived FROM Jira belongs to its Jira author and is replaced there
  // (the webhook files the new one); the server refuses a replace here with a 409,
  // so the button is not offered — admins included.
  const mine = (f) => f.origin !== "jira"
    && (me.isAdmin || (me.userId ? f.uploadedByUserId === me.userId : f.uploadedByUsername === me.name));
  const uploading = progress !== null;

  return (
    <div className="card side-card">
      <span className="side-title">Upload evidence</span>
      <p className="side-note">Attach supporting documents to this {scope === "vulnerabilities" ? "vulnerability" : "component"}. Shared with your whole organisation. A filed document can be replaced with a newer version, but never removed.</p>

      <div
        className={"evi-drop" + (dragging ? " is-drag" : "") + (uploading ? " is-busy" : "")}
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        onClick={() => !uploading && inputRef.current && inputRef.current.click()}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current && inputRef.current.click(); } }}>
        <input ref={inputRef} type="file" accept={ACCEPT} onChange={pick} hidden />
        {uploading ? (
          <>
            <span className="evi-drop-lab">Uploading… {progress}%</span>
            <span className="evi-progress"><span style={{ width: progress + "%" }} /></span>
          </>
        ) : (
          <>
            <Icon name="upload" size={18} />
            <span className="evi-drop-lab">Drop a file or <b>browse</b></span>
            <span className="evi-drop-sub">PDF · CSV · DOCX · JPG · PNG — up to {prettyBytes(MAX_BYTES)}</span>
          </>
        )}
      </div>

      {error && <div className="cmt-error"><Icon name="alert" size={14} />{error}</div>}
      {jiraNote && (
        <div className="cmt-jira-warn">
          <Icon name="alert" size={14} />
          <span>Filed here, but not attached in Jira. {jiraNote}</span>
          <button className="cmt-act" onClick={() => setJiraNote("")} title="Dismiss">×</button>
        </div>
      )}

      {/* One picker shared by every row's Reupload button; replaceTargetRef records
          which file the chosen one is replacing. */}
      <input ref={replaceRef} type="file" accept={ACCEPT} onChange={onReplacementChosen} hidden />

      <div className="evi-list">
        {files === null && <div className="evi-empty">Loading…</div>}
        {files !== null && files.length === 0 && !error &&
          <div className="evi-empty">No evidence filed yet.</div>}

        {(files || []).map((f) => {
          const ext = (f.fileName.split(".").pop() || "").toLowerCase();
          // Where the file has been. From Jira: its Jira author by label (no account
          // here) and a chip saying so. From here: the issue it was attached to, when
          // the company attaches evidence and this record is filed.
          const fromJira = f.origin === "jira";
          const who = fromJira ? (f.uploaderLabel || "Jira user") : f.uploadedByUsername;
          return (
            <div className={"evi-row" + (fromJira ? " evi-from-jira" : "")} key={f.evidenceId}>
              <span className="evi-ic"><Icon name={EVIDENCE_ICON[ext] || "doc"} size={15} /></span>
              <button className="evi-open" onClick={() => open(f, false)} title={"Open " + f.fileName}>
                <span className="evi-name">
                  <span className="evi-name-txt">{f.fileName}</span>
                  {fromJira
                    ? <span className="cmt-jira cmt-jira-in evi-jira" title={"Attached in Jira" + (f.jiraIssueKey ? " on " + f.jiraIssueKey : "")}>
                        via Jira{f.jiraIssueKey ? " · " + f.jiraIssueKey : ""}
                      </span>
                    : f.jiraIssueKey && (
                      <span className="cmt-jira evi-jira" title={"Attached to " + f.jiraIssueKey}>{f.jiraIssueKey}</span>
                    )}
                </span>
                <span className="evi-meta">
                  {prettyBytes(f.byteSize)}
                  {who ? " · " + who : ""}
                  {f.uploadedAt ? " · " + timeAgo(f.uploadedAt) : ""}
                </span>
              </button>
              <span className="evi-acts">
                {busyId === f.evidenceId
                  ? <span className="btn-spin evi-spin" title="Replacing…" />
                  : (
                    <>
                      {openingId === f.evidenceId
                        ? <span className="btn-spin evi-spin" />
                        : <button className="evi-act" onClick={() => open(f, true)} title="Download"><Icon name="download" size={14} /></button>}
                      {mine(f) &&
                        <button className="evi-act" onClick={() => pickReplacement(f)} title={"Reupload — replace " + f.fileName + " with a new version"}>
                          <Icon name="refresh" size={14} />
                        </button>}
                    </>
                  )}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Object.assign(window, { CommentsSection, EvidenceCard });
