import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
import type {
  ClaudeTokenCodeRejectedEvent,
  ClaudeTokenLinkEvent,
  ClaudeTokenOutputEvent,
  ClaudeTokenProgressEvent,
} from "../lib/types";

/**
 * Front-end half of the shared Claude Code token flow.
 *
 * The token itself never crosses the IPC boundary — `has_claude_token` returns
 * a boolean and the streamed output is redacted backend-side. Nothing in here
 * stores, parses, or renders a credential; the transcript is displayed as-is
 * precisely because it has already been scrubbed.
 */

/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
const PROGRESS_EVENT = "claude-token-progress";
const OUTPUT_EVENT = "claude-token-output";
const LINK_EVENT = "claude-token-link";
const CODE_REJECTED_EVENT = "claude-token-code-rejected";

/** Bound on the retained transcript. The tail is the interesting part. */
const MAX_OUTPUT = 64 * 1024;

/** Bound on retained sign-in candidates. The backend already deduplicates
 *  consecutive repeats; this stops a container that prints a fresh hyperlink
 *  every frame from growing state without limit. */
const MAX_LINKS = 16;

/**
 * Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
 * backend writes its errors as complete, actionable sentences ("The container
 * for 'x' is not running. Start it, then run authentication again."). So use
 * them verbatim rather than stringifying an opaque value, and only synthesise
 * a message when the rejection is something else — a thrown `Error`, or an IPC
 * channel that died without one.
 */
export function authErrorMessage(e: unknown, fallback: string): string {
  if (typeof e === "string" && e.trim()) return e.trim();
  if (e instanceof Error && e.message.trim()) return e.message.trim();
  return fallback;
}

/**
 * Choose one sign-in URL from a list of candidates.
 *
 * **Every candidate is container output, so all of them are
 * attacker-controlled if the sandboxed agent misbehaves.** The winner is
 * rendered under a heading that says "Sign in with Anthropic" and handed to the
 * host browser, which makes this the highest-value URL in the app to spoof: a
 * user who follows it types their real Anthropic credentials into whatever it
 * resolves to. Three rules follow, and none of them are optional:
 *
 *  - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
 *    host allowlist. Only Anthropic's own domains can be a sign-in link;
 *    userinfo (`https://claude.ai@evil.tld/...`) and control characters are
 *    rejected there.
 *  - The **first** surviving candidate wins. The previous rule was
 *    longest-wins, which handed the choice to the attacker: pad a hostile URL
 *    and it displaces the real one that came before it.
 *  - The one exception is a candidate that *extends* the current pick, i.e.
 *    starts with it. That is the case longest-wins existed for — a repainting
 *    TUI can land a truncated copy of the same link in the transcript before
 *    the complete one — and it cannot swap the origin, because a longer string
 *    with the same prefix has the same host.
 */
export function pickSignInUrl(candidates: readonly string[]): string | null {
  const cleaned = candidates
    .map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
    .filter((url): url is string => url !== null);

  const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
  const pool = oauth.length > 0 ? oauth : cleaned;

  let best: string | null = null;
  for (const url of pool) {
    if (best === null || url.startsWith(best)) best = url;
  }
  return best;
}

/**
 * Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
 *
 * **This is the fallback, not the primary route.** The CLI emits the URL as an
 * OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
 * terminal width — measured at 80 columns, a 346-character URL arrives as five
 * 80-character pieces on five lines. Nothing scraping the visible text can put
 * those back together: the pieces share no prefix, so the "extends the current
 * pick" rule cannot join them, and joining adjacent lines by guesswork on an
 * untrusted stream is exactly the sort of thing the rules above exist to
 * forbid. What comes out is the first 80 characters — a URL that parses, that
 * points at claude.com, and that cannot authorise anything.
 *
 * So the backend lifts the whole URL out of the hyperlink parameter and sends
 * it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
 * This remains for CLI versions that print a bare URL with no hyperlink at all,
 * where a URL narrow enough not to wrap is recovered correctly.
 */
export function extractSignInUrl(text: string): string | null {
  // eslint-disable-next-line no-control-regex
  const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
  if (!matches) return null;

  // Trailing punctuation belongs to the prose, not the URL.
  return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
}

// ─────────────────────────────────────────────────────────────────────────────
// Token presence
// ─────────────────────────────────────────────────────────────────────────────

export type ClaudeTokenStatus = "checking" | "stored" | "absent" | "unavailable";

/** Whether a shared token exists, plus a way to re-check after a change. */
export function useClaudeTokenStatus() {
  const [status, setStatus] = useState<ClaudeTokenStatus>("checking");
  const [error, setError] = useState<string | null>(null);

  const refresh = useCallback(async () => {
    try {
      const present = await commands.hasClaudeToken();
      setStatus(present ? "stored" : "absent");
      setError(null);
    } catch (e) {
      setStatus("unavailable");
      setError(
        authErrorMessage(
          e,
          "Could not read the OS keychain, so whether a shared token exists is unknown.",
        ),
      );
    }
  }, []);

  useEffect(() => {
    void refresh();
  }, [refresh]);

  return { status, error, refresh };
}

// ─────────────────────────────────────────────────────────────────────────────
// Acquisition
// ─────────────────────────────────────────────────────────────────────────────

export type AcquisitionPhase = "running" | "succeeded" | "failed";

export interface ClaudeTokenAcquisition {
  phase: AcquisitionPhase;
  /** Milestone messages from `claude-token-progress`, oldest first. */
  progress: string[];
  /** Redacted transcript from `claude-token-output`. */
  output: string;
  signInUrl: string | null;
  /** Set when the flow ends badly; always a full sentence the user can act on. */
  error: string | null;
  submitting: boolean;
  codeSubmitted: boolean;
  submitError: string | null;
  /**
   * How many codes `claude setup-token` has refused. Non-zero means the CLI is
   * still alive and waiting for another one — a recoverable state, not the end
   * of the flow.
   */
  codeRejections: number;
  submitCode: (code: string) => Promise<boolean>;
}

/**
 * Runs one `acquire_claude_token` flow for the lifetime of the calling
 * component. Starts on mount, so mount this only when the user has asked for
 * it — the backend allows a single flow at a time.
 *
 * `onSucceeded` fires once, after the token has been stored.
 */
export function useClaudeTokenAcquisition(
  projectId: string,
  onSucceeded?: () => void,
): ClaudeTokenAcquisition {
  const [phase, setPhase] = useState<AcquisitionPhase>("running");
  const [progress, setProgress] = useState<string[]>([]);
  const [output, setOutput] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);
  const [codeSubmitted, setCodeSubmitted] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);
  const [codeRejections, setCodeRejections] = useState(0);
  // Candidates from `claude-token-link`, in arrival order. Kept as a list
  // rather than a single value so `pickSignInUrl` applies the same first-wins
  // rule here as it does to the scraped transcript — the CLI reprints the same
  // hyperlink after every retry, and a *different* one arriving later must not
  // be able to displace the one the user was already shown.
  const [links, setLinks] = useState<string[]>([]);

  // Held in a ref so a fresh callback identity cannot restart the flow.
  const succeededRef = useRef(onSucceeded);
  succeededRef.current = onSucceeded;

  useEffect(() => {
    let cancelled = false;
    const unlisteners: UnlistenFn[] = [];

    const register = async <T,>(name: string, handle: (payload: T) => void) => {
      const unlisten = await listen<T>(name, (event) => handle(event.payload));
      // Registration is async: if the component went away while we were
      // awaiting, drop the listener now rather than leaking it.
      if (cancelled) {
        unlisten();
        return;
      }
      unlisteners.push(unlisten);
    };

    void (async () => {
      try {
        await register<ClaudeTokenProgressEvent>(PROGRESS_EVENT, (payload) => {
          if (payload.project_id !== projectId) return;
          setProgress((prev) =>
            prev[prev.length - 1] === payload.message
              ? prev
              : [...prev, payload.message],
          );
        });
        await register<ClaudeTokenOutputEvent>(OUTPUT_EVENT, (payload) => {
          if (payload.project_id !== projectId) return;
          setOutput((prev) => {
            const next = prev + payload.chunk;
            return next.length > MAX_OUTPUT
              ? next.slice(next.length - MAX_OUTPUT)
              : next;
          });
        });
        await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
          if (payload.project_id !== projectId) return;
          setLinks((prev) =>
            prev.includes(payload.url) || prev.length >= MAX_LINKS
              ? prev
              : [...prev, payload.url],
          );
        });
        await register<ClaudeTokenCodeRejectedEvent>(
          CODE_REJECTED_EVENT,
          (payload) => {
            if (payload.project_id !== projectId) return;
            // The CLI is alive and back at its prompt, so this is a correction
            // the user can act on — not a failure. Re-open the input and say
            // why, rather than leaving "Finishing sign-in" on screen forever.
            setCodeRejections((n) => n + 1);
            setCodeSubmitted(false);
            setSubmitError(payload.message);
          },
        );
      } catch (e) {
        if (cancelled) return;
        setPhase("failed");
        setError(
          authErrorMessage(
            e,
            "Could not subscribe to the authentication events, so the flow was not started. Restart Triple-C and try again.",
          ),
        );
        return;
      }

      if (cancelled) return;

      try {
        await commands.acquireClaudeToken(projectId);
        if (cancelled) return;
        setPhase("succeeded");
        succeededRef.current?.();
      } catch (e) {
        if (cancelled) return;
        setPhase("failed");
        setError(
          authErrorMessage(
            e,
            "`claude setup-token` did not finish. No token was stored — try again.",
          ),
        );
      }
    })();

    return () => {
      cancelled = true;
      for (const unlisten of unlisteners) {
        try {
          unlisten();
        } catch {
          // Nothing useful to do while tearing down.
        }
      }
    };
  }, [projectId]);

  const submitCode = useCallback(async (code: string) => {
    const trimmed = code.trim();
    if (!trimmed) {
      setSubmitError("Enter the code shown after signing in.");
      return false;
    }
    setSubmitting(true);
    setSubmitError(null);
    try {
      await commands.submitClaudeTokenCode(trimmed);
      setCodeSubmitted(true);
      return true;
    } catch (e) {
      setSubmitError(
        authErrorMessage(
          e,
          "Could not deliver the code to `claude setup-token`. Copy it again and retry.",
        ),
      );
      return false;
    } finally {
      setSubmitting(false);
    }
  }, []);

  // The hyperlink parameter wins whenever there is one: it is the only place
  // the CLI emits the URL contiguously. Scraping the visible text is the
  // fallback for versions that print a bare URL — see `extractSignInUrl`.
  const signInUrl = useMemo(
    () => pickSignInUrl(links) ?? extractSignInUrl(output),
    [links, output],
  );

  return {
    phase,
    progress,
    output,
    signInUrl,
    error,
    submitting,
    codeSubmitted,
    submitError,
    codeRejections,
    submitCode,
  };
}
