/* global React, ReactDOM, SparkyScreens, SparkyIcons */
const { useState, useEffect, useRef } = React;
const S = window.SparkyScreens;
const I = window.SparkyIcons;

const CONFIG = {
  N8N_BASE: 'https://api.looptradie.com',
  LIVEKIT_URL: 'wss://test1-ntnp1zzo.livekit.cloud',
  OAUTH_CLIENT_ID: '681217',
  USE_WEBHOOK_DISPATCH: false,
};

const DASHBOARD_URL = `${CONFIG.N8N_BASE}/webhook/sm8-dashboard`;

const N8N_WEBHOOK_URL = `${CONFIG.N8N_BASE}/webhook/generate-voice-token`;
const DISPATCH_URL    = `${CONFIG.N8N_BASE}/webhook/dispatch-agent`;
const OAUTH_REDIRECT_URI = `${CONFIG.N8N_BASE}/webhook/servicem8-oauth-callback`;
const SESSION_KEY = 'sm8_session';

const OAUTH_SCOPES = [
  'vendor', 'read_staff',
  'manage_customers', 'manage_customer_contacts',
  'manage_jobs', 'create_jobs', 'manage_job_contacts',
  'manage_job_materials', 'read_job_payments',
  'read_schedule', 'manage_schedule',
  'publish_job_notes', 'read_job_notes',
  'read_inventory',
];

const OAUTH_URL = [
  'https://go.servicem8.com/oauth/authorize',
  '?response_type=code',
  `&client_id=${CONFIG.OAUTH_CLIENT_ID}`,
  `&scope=${encodeURIComponent(OAUTH_SCOPES.join(' '))}`,
  `&redirect_uri=${encodeURIComponent(OAUTH_REDIRECT_URI)}`,
  '&state=tenant_001',
].join('');

function App() {
  const [screen, setScreen]             = useState('loading');
  const [sessionCtx, setSessionCtx]     = useState(null);
  const [secondsLeft, setSecondsLeft]   = useState(null);  // null = not yet loaded from server
  const [secondsUsed, setSecondsUsed]   = useState(0);
  const [planSeconds, setPlanSeconds]   = useState(1200);
  const [callCount, setCallCount]       = useState(0);
  const [isTrial, setIsTrial]           = useState(true);
  const [tenantName, setTenantName]     = useState('');

  // live call state
  const [callStream, setCallStream]     = useState([]);
  const [callState, setCallState]       = useState('listening');
  const [callCaption, setCallCaption]   = useState('Listening…');
  const [isMuted, setIsMuted]           = useState(false);
  const [checkoutBanner, setCheckoutBanner] = useState(null); // 'success' | 'cancel' | null
  const [callError, setCallError]           = useState(null); // error message string | null

  // dashboard data
  const [jobsToday, setJobsToday]           = useState(null);
  const [unreadMessages, setUnreadMessages] = useState(null);
  const [nextJob, setNextJob]               = useState(null);
  const [schedule, setSchedule]             = useState([]);
  const [tenantTz, setTenantTz]             = useState(null);

  // desktop layout detection
  const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 900);
  useEffect(() => {
    const mq = window.matchMedia('(min-width: 900px)');
    const h = e => setIsDesktop(e.matches);
    mq.addEventListener('change', h);
    return () => mq.removeEventListener('change', h);
  }, []);

  const roomRef            = useRef(null);
  const callIntervalRef    = useRef(null);
  const callSecondsRef     = useRef(0);
  const heartbeatIntervalRef = useRef(null);
  // segMap: bubble-key -> { who, text, firstTs, seq }
  // Rendered ordered by firstTs then seq, so bubbles never jump or duplicate.
  // AGENT: one stable segment.id per utterance, growing text — replaced in place.
  // USER: xAI emits a NEW segment.id per interim hypothesis, each already final=true,
  //   with revised (not always prefix-clean) text. So we keep ONE open user bubble per
  //   turn and overwrite it with the latest text; an agent segment closes the turn.
  const segMap     = useRef(new Map());
  const seqCounter = useRef(0);
  // userBubble: the currently-open user bubble { key, ts } for the active turn, or null
  const userBubble = useRef(null);

  useEffect(() => { init(); }, []);

  function signOut() {
    const session = sessionCtx?.session_token;
    if (session) fetch(`${CONFIG.N8N_BASE}/webhook/sign-out`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ session }),
    }).catch(() => {});
    localStorage.removeItem(SESSION_KEY);
    setSessionCtx(null);
    setScreen('onboarding');
  }

  async function fetchDashboard(token) {
    if (!token) return;
    try {
      // Let the backend derive "today" from the tenant timezone (it falls back to that when no
      // date is supplied). Sending the device-local date caused "today" to mismatch the tenant
      // day when the device tz differs, hiding same-day bookings.
      const res = await fetch(`${DASHBOARD_URL}?session=${token}`);
      if (!res.ok) return;
      const data = await res.json();
      setJobsToday(data.jobs_today ?? 0);
      setUnreadMessages(data.unread_messages ?? 0);
      setNextJob(data.next_job ?? null);
      setSchedule(data.schedule ?? []);
      if (data.timezone) setTenantTz(data.timezone);
    } catch(e) {}
  }

  async function init() {
    const params   = new URLSearchParams(window.location.search);
    const urlToken = params.get('session');
    const checkout = params.get('checkout'); // 'success' | 'cancel' from Stripe redirect

    if (checkout) {
      window.history.replaceState({}, '', window.location.pathname);
      if (checkout === 'success') setCheckoutBanner('success');
    }

    if (urlToken) {
      try {
        const res  = await fetch(`${N8N_WEBHOOK_URL}?session=${urlToken}`);
        if (res.ok) {
          const data = await res.json();
          if (data.first_name) {
            const ctx = { first_name: data.first_name, session_token: data.session_token || urlToken };
            localStorage.setItem(SESSION_KEY, JSON.stringify(ctx));
            setSessionCtx(ctx);
            applyBalanceData(data);
            window.history.replaceState({}, '', window.location.pathname);
            setScreen('main');
            fetchDashboard(ctx.session_token);
            return;
          }
        }
      } catch(e) {}
    }

    const stored = localStorage.getItem(SESSION_KEY);
    if (stored) {
      try {
        const ctx = JSON.parse(stored);
        if (ctx.first_name) {
          setSessionCtx(ctx);
          setScreen('main');
          refreshBalance(ctx.session_token);
          fetchDashboard(ctx.session_token);
          return;
        }
      } catch(e) {}
    }

    if (window.smClient) {
      try {
        const data = await window.smClient.invoke('get_session_context', {});
        if (data && data.session_token) {
          setSessionCtx(data);
          setScreen('main');
          return;
        }
      } catch(e) {}
    }

    setScreen('onboarding');
  }

  function applyBalanceData(data) {
    if (data.seconds_balance    !== undefined) setSecondsLeft(Number(data.seconds_balance));
    if (data.seconds_used_total !== undefined) setSecondsUsed(Number(data.seconds_used_total));
    if (data.plan_seconds       !== undefined) setPlanSeconds(Number(data.plan_seconds));
    if (data.call_count         !== undefined) setCallCount(Number(data.call_count));
    if (data.is_trial           !== undefined) setIsTrial(!!data.is_trial);
    if (data.tenant_name)                      setTenantName(data.tenant_name);
  }

  async function refreshBalance(token) {
    const t = token ?? sessionCtx?.session_token;
    if (!t) return;
    try {
      const res  = await fetch(`${N8N_WEBHOOK_URL}?session=${t}`);
      if (!res.ok) return;
      applyBalanceData(await res.json());
    } catch(e) {}
  }

  async function recordCall(session, seconds) {
    try {
      const res = await fetch(`${CONFIG.N8N_BASE}/webhook/record-call`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ session, seconds }),
      });
      if (res.ok) applyBalanceData(await res.json());
    } catch(e) {}
  }

  async function startCall(jobUuid, promptText) {
    if (!sessionCtx) return;
    if (secondsLeft !== null && secondsLeft <= 0) { setScreen('timeexpired'); return; }
    setCallStream([]);
    setCallState('connecting');
    setCallCaption('Getting secure token…');
    setCallError(null);
    setIsMuted(false);
    segMap.current = new Map();
    seqCounter.current = 0;
    userBubble.current = null;
    setScreen('call');

    try {
      const tz  = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone);
      const jobParam = jobUuid ? `&job_uuid=${encodeURIComponent(jobUuid)}` : '';
      const res = await fetch(`${N8N_WEBHOOK_URL}?session=${sessionCtx.session_token}&call=1&timezone=${tz}${jobParam}`);
      if (!res.ok) {
        let msg = 'Could not start the call. Please try again.';
        try { const j = await res.json(); if (j.error) msg = j.error; } catch {}
        setCallError(msg);
        return;
      }
      const data = await res.json();
      applyBalanceData(data);

      const { Room, RoomEvent } = window.LivekitClient;
      const room = new Room();
      roomRef.current = room;

      // Unlock the browser audio context now, while we still have the user gesture in scope.
      // TrackSubscribed fires asynchronously (after fetch + connect), so el.play() there would
      // be blocked by autoplay policy without this pre-unlock.
      room.startAudio().catch(() => {});

      room.on(RoomEvent.TrackSubscribed, (track) => {
        if (track.kind !== 'audio') return;
        const el = track.attach();
        el.autoplay = true;
        el.muted    = false;
        document.body.appendChild(el);
        el.play().catch(() => {});
      });

      room.on(RoomEvent.TranscriptionReceived, (segments, participant) => {
        const isLocal = !!participant?.isLocal;   // local = user mic STT, remote = agent
        const map     = segMap.current;
        const now     = Date.now();

        for (const seg of segments) {
          const text = (seg.text || '').trim();
          if (!text) continue;

          if (!isLocal) {
            // AGENT: stable segment.id, growing text — replace in place or open a new
            // bubble. An agent segment also closes any open user bubble (turn boundary).
            userBubble.current = null;
            const existing = map.get(seg.id);
            if (existing) existing.text = text;
            else map.set(seg.id, { who: 'agent', text, firstTs: now, seq: seqCounter.current++ });
            continue;
          }

          // USER: xAI emits a new segment.id per interim hypothesis (each final=true) with
          // revised text that is not always a clean prefix. Keep ONE open bubble for the
          // turn and overwrite it with the latest text; a >10s gap starts a fresh turn.
          const ub   = userBubble.current;
          const open = ub && map.has(ub.key) && (now - ub.ts) < 10000;
          if (open) {
            map.get(ub.key).text = text;
            ub.ts = now;
          } else {
            const key = `user-${now}-${seqCounter.current}`;
            map.set(key, { who: 'you', text, firstTs: now, seq: seqCounter.current++ });
            userBubble.current = { key, ts: now };
          }
        }

        // Order strictly by first-seen time, then insertion seq — never jumps on updates
        const bubbles = [...map.values()]
          .sort((a, b) => a.firstTs - b.firstTs || a.seq - b.seq)
          .map(b => ({ who: b.who, text: b.text }));
        setCallStream(bubbles);

        setCallState(isLocal ? 'listening' : 'speaking');
        setCallCaption(isLocal ? 'Listening…' : 'Speaking…');

        const joined = segments.map(s => s.text).join('').trim();
        if (isLocal && /\bclose\s+(chat|call)\b/i.test(joined)) {
          setTimeout(() => { if (roomRef.current) roomRef.current.disconnect(); }, 800);
        }
      });

      room.on(RoomEvent.Disconnected, async () => {
        if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; }
        if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; }
        const elapsed = callSecondsRef.current;
        callSecondsRef.current = 0;
        roomRef.current = null;
        // Record call duration — this persists the deduction in the DB
        if (elapsed > 0) await recordCall(sessionCtx?.session_token, elapsed);
        await new Promise(r => setTimeout(r, 500));
        // If balance is now 0, show the time-expired upgrade screen
        setScreen(s => {
          const bal = Number(secondsLeft ?? 1);
          return bal <= 0 ? 'timeexpired' : 'main';
        });
        refreshBalance();
        fetchDashboard(sessionCtx?.session_token);
      });

      setCallCaption('Connecting to AI…');
      await room.connect(CONFIG.LIVEKIT_URL, data.token);

      const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
      await room.localParticipant.setMetadata(JSON.stringify({
        first_name:    data.first_name   || sessionCtx.first_name || '',
        session_token: data.session_token || sessionCtx.session_token,
        timezone:      userTimezone,
      }));

      await new Promise(r => setTimeout(r, 1000));

      if (CONFIG.USE_WEBHOOK_DISPATCH) {
        fetch(DISPATCH_URL, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ room_name: data.room_name }),
        }).catch(() => {});
      }

      setCallCaption('Waiting for assistant…');
      await new Promise(resolve => {
        const check = () => {
          if (Array.from(room.remoteParticipants.values()).some(p => p.identity.startsWith('agent'))) {
            resolve(); return;
          }
          setTimeout(check, 500);
        };
        room.on('participantConnected', p => { if (p.identity.startsWith('agent')) resolve(); });
        check();
      });

      // Start countdown only once the agent has joined — connecting time is not billed
      callSecondsRef.current = 0;
      callIntervalRef.current = setInterval(() => {
        callSecondsRef.current += 1;
        setSecondsLeft(s => {
          const next = Math.max(0, s - 1);
          if (next === 0) {
            setTimeout(() => {
              if (roomRef.current) roomRef.current.disconnect();
            }, 0);
          }
          return next;
        });
      }, 1000);

      // Heartbeat: persist elapsed seconds to server every 60s so other devices see live balance
      heartbeatIntervalRef.current = setInterval(async () => {
        const elapsed = callSecondsRef.current;
        if (elapsed > 0 && sessionCtx?.session_token) {
          try {
            await fetch(`${CONFIG.N8N_BASE}/webhook/heartbeat`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ session: sessionCtx.session_token, seconds: elapsed }),
            });
          } catch {}
        }
      }, 60000);

      await new Promise(r => setTimeout(r, 500));
      await room.localParticipant.setMicrophoneEnabled(true);
      setCallState('listening');
      setCallCaption('Listening…');

      await new Promise(r => setTimeout(r, 800));
      if (promptText) {
        // Suggestion chip: add to stream as a user bubble then send
        await sendText(promptText);
      } else {
        // Plain call: send a silent kick so the agent delivers its greeting.
        // Don't add to the stream — the user didn't type or say this.
        try { await room.localParticipant.sendText('hi', { topic: 'lk.chat' }); } catch(e) {}
      }

    } catch(err) {
      console.error('[looptradie] call error:', err);
      if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; }
      if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; }
      roomRef.current = null;
      setCallError('Connection failed. Check your internet and try again.');
    }
  }

  async function toggleMute() {
    if (!roomRef.current) return;
    const next = !isMuted;
    try {
      await roomRef.current.localParticipant.setMicrophoneEnabled(!next);
      setIsMuted(next);
    } catch(e) {}
  }

  async function endCall() {
    if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; }
    if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; }
    if (roomRef.current) await roomRef.current.disconnect();
    roomRef.current = null;
    setIsMuted(false);
    setScreen('main');
  }

  async function sendText(text) {
    if (!text || !roomRef.current) return;
    // Insert into segMap so typed messages are ordered chronologically alongside
    // transcription bubbles. Use a synthetic id — it never collides with LiveKit ids.
    // Close any open user bubble so a typed message is its own bubble and the next
    // spoken segment opens a fresh one.
    userBubble.current = null;
    const id = `typed-${Date.now()}`;
    segMap.current.set(id, { who: 'you', text, firstTs: Date.now(), seq: seqCounter.current++ });
    setCallStream([...segMap.current.values()]
      .sort((a, b) => a.firstTs - b.firstTs || a.seq - b.seq)
      .map(b => ({ who: b.who, text: b.text })));
    try {
      await roomRef.current.localParticipant.sendText(text, { topic: 'lk.chat' });
    } catch(e) {}
  }

  async function onChoosePlan(plan) {
    if (!sessionCtx) return;
    try {
      const res = await fetch(`${CONFIG.N8N_BASE}/webhook/create-checkout-session`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ session: sessionCtx.session_token, plan }),
      });
      if (!res.ok) throw new Error('checkout ' + res.status);
      const data = await res.json();
      if (data.url) window.location.href = data.url;
    } catch(e) {
      console.error('[looptradie] checkout error:', e);
    }
  }

  const goNav = id => {
    // If a call is active, redirect to the call screen regardless of where the user tried to go
    if (roomRef.current) { setScreen('call'); return; }
    if (id === 'main')          setScreen('main');
    else if (id === 'schedule') setScreen('schedule');
    else if (id === 'usage')    setScreen('usage');
    else if (id === 'settings') setScreen('settings');
    else if (id === 'privacy')  setScreen('privacy');
  };

  if (screen === 'loading') {
    return (
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'rgba(255,255,255,0.4)', fontSize: 14 }}>
        <I.LoopMark size={40} variant="gradient"/>
      </div>
    );
  }

  const secLeft = secondsLeft;  // null until server responds — screens handle null

  const screenMap = {
    onboarding:  <S.Onboarding onNext={() => setScreen('connect')}/>,
    connect:     <S.Connect oauthUrl={OAUTH_URL} onNext={() => setScreen('main')} onBack={() => setScreen('onboarding')}/>,
    main:        <S.Main isDesktop={isDesktop} onNav={goNav} onCall={() => startCall()} onStartJob={uuid => startCall(uuid)} onSuggest={text => startCall(null, text)} secondsLeft={secLeft} userName={sessionCtx?.first_name} jobsToday={jobsToday} unreadMessages={unreadMessages} nextJob={nextJob} checkoutBanner={checkoutBanner} onDismissBanner={() => setCheckoutBanner(null)} callActive={!!roomRef.current} onReturnToCall={() => setScreen('call')} tenantTz={tenantTz}/>,
    schedule:    <S.Schedule isDesktop={isDesktop} onNav={goNav} schedule={schedule} tenantTz={tenantTz}/>,
    call:        <S.Call isDesktop={isDesktop} live onEnd={endCall} onNav={goNav} stream={callStream} callState={callState} caption={callCaption} onSendText={sendText} secondsLeft={secLeft} error={callError} onDismissError={() => { setCallError(null); setScreen('main'); }} isMuted={isMuted} onToggleMute={toggleMute}/>,
    usage:       <S.Usage isDesktop={isDesktop} onNav={goNav} secondsLeft={secondsLeft} secondsUsed={secondsUsed} planSeconds={planSeconds} callCount={callCount} isTrial={isTrial} onChoosePlan={onChoosePlan}/>,
    settings:    <S.Settings isDesktop={isDesktop} onNav={goNav} onSignOut={signOut} tenantName={tenantName} isTrial={isTrial} secondsLeft={secLeft}/>,
    privacy:     <S.Privacy isDesktop={isDesktop} onNav={goNav}/>,
    timeexpired: <S.TimeExpired onNav={goNav}/>,
  };

  const desktopScreens = ['main','schedule','usage','settings','privacy','call','timeexpired'];
  if (isDesktop && desktopScreens.includes(screen)) {
    const showRight = screen === 'main' || screen === 'call';
    return (
      <div className="dshell">
        <S.DesktopNav screen={screen} onNav={goNav} userName={sessionCtx?.first_name} tenantName={tenantName}/>
        <div className="dcenter">
          {screenMap[screen]}
        </div>
        {showRight && (
          <div className="dright">
            {screen === 'main'
              ? <S.DesktopSchedulePanel schedule={schedule} nextJob={nextJob} onNav={goNav} tenantTz={tenantTz}/>
              : <S.DesktopTranscriptPanel stream={callStream} callState={callState}/>
            }
          </div>
        )}
      </div>
    );
  }

  return screenMap[screen] ?? screenMap.onboarding;
}

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