Displays the progress of the manga matching process, including progress bar, status, and time estimate.

export const MatchingProgressPanel: React.FC<MatchingProgressProps> = ({
isCancelling,
progress,
statusMessage,
timeEstimate,
onCancelProcess,
onPauseProcess,
onResumeProcess,
bypassCache,
freshSearch,
disableControls = false,
isPaused = false,
isManuallyPaused = false,
isRateLimitActive = false,
}) => {
const progressPercent = progress.total
? Math.min(100, Math.round((progress.current / progress.total) * 100))
: 0;

const totalProcessed = progress.total
? Math.min(progress.current, progress.total)
: progress.current;
const remainingCount = progress.total
? Math.max(progress.total - progress.current, 0)
: 0;

const [elapsedSeconds, setElapsedSeconds] = useState<number>(() => {
if (!timeEstimate.startTime || progress.current <= 0) {
return 0;
}
return Math.max(
0,
Math.round((Date.now() - timeEstimate.startTime) / 1000),
);
});

// Track when the time estimate was last updated to calculate live remaining time
const [estimateSnapshot, setEstimateSnapshot] = useState<{
remainingSeconds: number;
capturedAt: number;
}>(() => ({
remainingSeconds: timeEstimate.estimatedRemainingSeconds,
capturedAt: Date.now(),
}));

// Update snapshot when timeEstimate changes
useEffect(() => {
setEstimateSnapshot({
remainingSeconds: timeEstimate.estimatedRemainingSeconds,
capturedAt: Date.now(),
});
}, [timeEstimate.estimatedRemainingSeconds]);

// Calculate live remaining time that decreases as actual time passes
const [liveRemainingSeconds, setLiveRemainingSeconds] = useState<number>(
() => timeEstimate.estimatedRemainingSeconds,
);

// Update elapsed time and live remaining time every second when matching is active
useEffect(() => {
if (!timeEstimate.startTime || progress.current <= 0) {
setElapsedSeconds(0);
setLiveRemainingSeconds(0);
return;
}

// Initial update
const updateTimes = () => {
const now = Date.now();
setElapsedSeconds(
Math.max(0, Math.round((now - timeEstimate.startTime) / 1000)),
);

// Calculate how much time has actually passed since the estimate was captured
const secondsSinceEstimate = Math.floor(
(now - estimateSnapshot.capturedAt) / 1000,
);
// Subtract elapsed time from the snapshot to get live remaining time
const calculatedRemaining = Math.max(
0,
estimateSnapshot.remainingSeconds - secondsSinceEstimate,
);
setLiveRemainingSeconds(calculatedRemaining);
};

updateTimes();

// Update every second while matching is active
const intervalId = setInterval(updateTimes, 1000);

return () => clearInterval(intervalId);
}, [timeEstimate.startTime, progress.current, estimateSnapshot]);

const formattedElapsed = formatCompactDuration(elapsedSeconds);
const averageSecondsPerManga =
timeEstimate.averageTimePerManga > 0
? timeEstimate.averageTimePerManga / 1000
: 0;
const formattedAverageDuration = formatCompactDuration(
Math.round(averageSecondsPerManga),
);

const showEta = progress.current > 0 && liveRemainingSeconds > 0;

const stats = useMemo(
() =>
generateStats({
progress,
totalProcessed,
remainingCount,
progressPercent,
formattedElapsed,
elapsedSeconds,
formattedAverageDuration,
averageSecondsPerManga,
}),
[
progress,
totalProcessed,
remainingCount,
progressPercent,
formattedElapsed,
elapsedSeconds,
formattedAverageDuration,
averageSecondsPerManga,
],
);

const pauseButtonDisabled =
disableControls || isCancelling || isPaused || isRateLimitActive;
const resumeButtonDisabled =
disableControls || isCancelling || !isManuallyPaused || isRateLimitActive;

return (
<Card className="relative isolate mb-8 overflow-hidden border">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.18)_0%,rgba(255,255,255,0)_70%)] dark:bg-[radial-gradient(circle_at_top,_rgba(30,64,175,0.14)_0%,rgba(15,23,42,0)_82%)]" />

<CardHeader className="relative z-10 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
{(() => {
const badgeConfig = getBadgeConfig({
isCancelling,
isPaused,
isRateLimitActive,
});

return (
<Badge
variant={isCancelling ? "destructive" : "secondary"}
className={cn(
"flex items-center gap-2 rounded-full border border-transparent px-3 py-1.5 text-xs font-semibold tracking-[0.2em] uppercase",
badgeConfig.colorClass,
)}
>
{badgeConfig.content}
</Badge>
);
})()}
{(bypassCache || freshSearch) && (
<Badge
variant="outline"
className="flex items-center gap-1.5 rounded-full border-blue-500/30 bg-blue-500/10 px-3 py-1 text-[11px] font-semibold tracking-[0.2em] text-blue-600 uppercase dark:border-blue-500/25 dark:bg-blue-500/12 dark:text-blue-200"
>
<RotateCcw className="h-3 w-3" />
Fresh AniList search
</Badge>
)}
</div>

<CardTitle className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{isCancelling
? "Wrapping up safely..."
: statusMessage || "Matching your manga library"}
</CardTitle>
</CardHeader>

<CardContent className="relative z-10 space-y-6 pb-6">
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
Progress
</p>
<p className="text-2xl font-semibold text-slate-900 dark:text-white">
{totalProcessed.toLocaleString()}
{progress.total ? (
<span className="text-base font-normal text-slate-500 dark:text-slate-400">
{" "}
of {progress.total.toLocaleString()}
</span>
) : null}
</p>
</div>
<div className="flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/80 px-3 py-1 text-sm font-semibold text-slate-600 shadow-sm shadow-blue-500/10 dark:border-slate-700/60 dark:bg-slate-900/80 dark:text-slate-200">
<Sparkles className="h-4 w-4 text-blue-500 dark:text-blue-300" />
{progressPercent}% complete
</div>
</div>
<Progress
value={progressPercent}
className="h-3 w-full bg-slate-200/70 dark:bg-slate-800/70"
indicatorClassName="bg-gradient-to-r from-sky-500 via-indigo-500 to-violet-500 shadow-[0_0_16px_rgba(56,189,248,0.35)] dark:shadow-[0_0_14px_rgba(129,140,248,0.28)]"
/>
</div>

{showEta && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="relative overflow-hidden rounded-2xl border border-blue-200/60 bg-gradient-to-r from-blue-500/15 via-indigo-500/10 to-cyan-500/10 p-4 shadow-inner dark:border-blue-500/25 dark:from-blue-500/14 dark:via-indigo-500/12 dark:to-cyan-500/12"
>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/20 text-blue-600 dark:bg-blue-500/18 dark:text-blue-200">
<Timer className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 uppercase dark:text-blue-200">
Estimated finish
</p>
<p className="text-xl font-semibold text-slate-900 dark:text-white">
~{formatTimeRemaining(liveRemainingSeconds)}
</p>
<p className="text-xs text-slate-600 dark:text-slate-300">
{remainingCount.toLocaleString()} manga remaining
</p>
</div>
</div>
</motion.div>
)}

<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{stats.map(({ icon: Icon, label, value, hint }) => (
<div
key={label}
className="group relative overflow-hidden rounded-2xl border border-slate-200/70 bg-white/60 p-4 shadow-sm shadow-blue-500/10 transition duration-300 ease-out hover:-translate-y-1 hover:shadow-xl dark:border-slate-700/60 dark:bg-slate-900/55"
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/70 to-transparent opacity-60 dark:from-slate-900/45" />
<div className="relative z-10 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-500/15 text-blue-500 dark:bg-blue-500/16 dark:text-blue-200">
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-[11px] font-semibold tracking-[0.18em] text-slate-500 uppercase dark:text-slate-400">
{label}
</p>
<p className="text-xl font-semibold text-slate-900 dark:text-slate-50">
{value}
</p>
{hint && (
<p className="text-xs text-slate-500 dark:text-slate-400/90">
{hint}
</p>
)}
</div>
</div>
</div>
))}
</div>

{progress.currentTitle && (
<motion.div
key={progress.currentTitle}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="relative overflow-hidden rounded-2xl border border-purple-200/60 bg-gradient-to-r from-purple-500/10 via-blue-500/10 to-slate-100/40 p-4 dark:border-purple-500/25 dark:from-purple-500/14 dark:via-blue-500/14 dark:to-slate-900/30"
style={{ minHeight: 80, maxHeight: 120 }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-slate-600 dark:text-slate-300">
<Sparkles className="h-4 w-4 text-purple-500 dark:text-purple-200/90" />
Currently matching
</div>
<span className="rounded-full bg-white/70 px-3 py-1 text-[11px] font-semibold tracking-[0.2em] text-purple-600 uppercase shadow-sm dark:bg-white/10 dark:text-purple-200">
Live
</span>
</div>
<p
className="mt-2 truncate text-lg font-semibold text-slate-900 dark:text-white"
title={progress.currentTitle}
style={{ maxWidth: "100%" }}
>
{progress.currentTitle}
</p>
</motion.div>
)}

{(bypassCache || freshSearch) && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="flex items-start gap-3 rounded-2xl border border-blue-200/60 bg-blue-50/60 p-4 text-sm text-blue-700 shadow-sm dark:border-blue-500/25 dark:bg-blue-900/15 dark:text-blue-200"
>
<div className="mt-1 flex h-8 w-8 items-center justify-center rounded-full bg-blue-500/20 text-blue-600 dark:bg-blue-500/22 dark:text-blue-200">
<RotateCcw className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold">
Fresh AniList searches enabled
</p>
<p className="text-xs text-blue-700/80 dark:text-blue-200/80">
Bypassing cached results to guarantee the latest data for
matches.
</p>
</div>
</motion.div>
)}
</CardContent>

<CardFooter className="relative z-10 pt-0 pb-6">
<div className="flex w-full flex-col gap-3 sm:flex-row">
<PauseResumeButton
isPaused={isPaused}
isManuallyPaused={isManuallyPaused}
onResumeProcess={onResumeProcess}
onPauseProcess={onPauseProcess}
resumeButtonDisabled={resumeButtonDisabled}
pauseButtonDisabled={pauseButtonDisabled}
/>
<Button
variant={isCancelling ? "outline" : "default"}
size="lg"
onClick={onCancelProcess}
disabled={isCancelling || disableControls}
className={cn(
"group relative w-full overflow-hidden rounded-2xl text-base font-semibold transition-all duration-200",
isCancelling
? "border border-amber-400/50 bg-amber-50/80 text-amber-600 shadow-inner shadow-amber-200/40 dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-200"
: "bg-gradient-to-r from-rose-500 via-red-500 to-orange-500 text-white shadow-lg shadow-rose-500/30 hover:from-rose-600 hover:via-red-600 hover:to-orange-600 focus-visible:ring-rose-400",
)}
>
{isCancelling ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Cancelling...
</span>
) : (
<span className="flex items-center justify-center gap-2">
<AlertOctagon className="h-5 w-5" />
Cancel Process
</span>
)}
</Button>
</div>
</CardFooter>
</Card>
);
};
<MatchingProgressPanel
isCancelling={false}
progress={progress}
statusMessage="Matching..."
timeEstimate={estimate}
onCancelProcess={handleCancel}
/>