import type { Page } from "playwright";
import { createLogger, sleep, type SemanticAction } from "@src/shared";
import type { SocialPlatformAdapter } from "@src/connectors";

const log = createLogger("executor");

export interface ExecutionResult {
  ok: boolean;
  error?: string;
  navigated: boolean;
  url_before: string;
  url_after: string;
  duration_ms: number;
}

/**
 * Action Executor: the *only* place semantic actions become browser operations (§24/§25).
 * Read-only by construction (§46): there is no code path for like/follow/comment/share/subscribe.
 */
export class ActionExecutor {
  constructor(private readonly page: Page, private readonly adapter: SocialPlatformAdapter, private readonly opts: { stayOnPlatform: boolean }) {}

  /** Every action is bounded (§66): a hung page must never stall the session. */
  async execute(action: SemanticAction, ctx: { entityUrl?: string }, timeoutMs = 60_000): Promise<ExecutionResult> {
    const page = this.page;
    const url_before = page.url();
    const t0 = Date.now();
    let timer: NodeJS.Timeout | undefined;
    const timeout = new Promise<ExecutionResult>((res) => {
      timer = setTimeout(() => res({ ok: false, error: `action timeout after ${timeoutMs} ms`, navigated: page.url() !== url_before, url_before, url_after: page.url(), duration_ms: Date.now() - t0 }), timeoutMs);
    });
    try {
      return await Promise.race([this.run(action, ctx, url_before, t0), timeout]);
    } finally {
      if (timer) clearTimeout(timer);
    }
  }

  private async run(action: SemanticAction, ctx: { entityUrl?: string }, url_before: string, t0: number): Promise<ExecutionResult> {
    const page = this.page;
    const done = (ok: boolean, error?: string): ExecutionResult => ({ ok, error, navigated: page.url() !== url_before, url_before, url_after: page.url(), duration_ms: Date.now() - t0 });
    try {
      switch (action.type) {
        case "OPEN_ENTITY":
        case "OPEN_POST":
        case "OPEN_PROFILE":
        case "OPEN_PAGE":
        case "OPEN_CHANNEL":
        case "OPEN_VIDEO":
        case "OPEN_COMMENTS": {
          const target = ctx.entityUrl ?? action.target_url;
          if (!target) return done(false, "no target url");
          if (this.opts.stayOnPlatform && !this.adapter.hosts.test(new URL(target).hostname)) return done(false, "external link recorded but not followed (stay_on_platform)");
          // Prefer a real click on the visible link (keeps SPA state and triggers the same runtime calls a user would);
          // fall back to navigation when the element is not clickable.
          const clicked = await this.clickLink(target);
          if (!clicked) await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45_000 });
          await this.settle();
          if (action.type === "OPEN_COMMENTS") await this.scrollBy(900);
          return done(true);
        }
        case "SCROLL_DOWN":
          await this.scrollBy(Math.round(page.viewportSize()?.height ?? 800) * 0.85);
          await this.settle(1200);
          return done(true);
        case "SCROLL_UP":
          await this.scrollBy(-Math.round(page.viewportSize()?.height ?? 800) * 0.85);
          await this.settle(600);
          return done(true);
        case "SEARCH": {
          if (!action.query) return done(false, "no query");
          const typed = await this.typeSearch(action.query);
          if (!typed) await page.goto(this.adapter.searchUrl(action.query), { waitUntil: "domcontentloaded", timeout: 45_000 });
          await this.settle(1500);
          return done(true);
        }
        case "EXPAND": {
          if (!action.target_url) return done(false, "no locator hint");
          const loc = page.locator(action.target_url).first();
          if (!(await loc.isVisible().catch(() => false))) return done(false, "expand control not visible");
          await loc.scrollIntoViewIfNeeded().catch(() => {});
          await loc.click({ timeout: 5000 });
          await this.settle(1000);
          return done(true);
        }
        case "PLAY_VIDEO": {
          const v = page.locator("video").first();
          if (!(await v.count())) return done(false, "no video element");
          // Do not return the play() promise: in headless Chromium it may never settle and would hang evaluate().
          await v.evaluate((el: HTMLVideoElement) => {
            void el.play().catch(() => {});
          });
          await sleep(2500);
          return done(true);
        }
        case "PAUSE_VIDEO": {
          await page.locator("video").first().evaluate((el: HTMLVideoElement) => el.pause()).catch(() => {});
          return done(true);
        }
        case "BACK":
          await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => {});
          await this.settle();
          return done(true);
        case "FORWARD":
          await page.goForward({ waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => {});
          await this.settle();
          return done(true);
        case "RETURN_TO_FEED":
          await page.goto(action.target_url ?? this.adapter.homeUrl, { waitUntil: "domcontentloaded", timeout: 45_000 });
          await this.settle();
          return done(true);
        case "WAIT_FOR_CONTENT":
          await this.settle(2500);
          return done(true);
        case "COLLAPSE":
        case "FILTER":
          return done(false, `${action.type} not implemented in prototype`);
        case "END_SESSION":
          return done(true);
        default:
          return done(false, `unknown action ${(action as SemanticAction).type}`);
      }
    } catch (err) {
      const msg = (err as Error).message.split("\n")[0] ?? "error";
      log.warn("action failed", { action: action.type, err: msg });
      return done(false, msg);
    }
  }

  private async clickLink(url: string): Promise<boolean> {
    const page = this.page;
    try {
      const u = new URL(url);
      const rel = u.pathname + u.search;
      const candidates = [`a[href="${url}"]`, `a[href="${rel}"]`, `a[href^="${rel}"]`, `a[href*="${u.searchParams.get("v") ?? " "}"]`];
      for (const sel of candidates) {
        if (sel.includes(" ")) continue;
        const loc = page.locator(sel).first();
        if (!(await loc.count())) continue;
        if (!(await loc.isVisible().catch(() => false))) continue;
        await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
        await loc.click({ timeout: 5000, noWaitAfter: true });
        await page.waitForURL((now) => now.toString() !== url && now.pathname === u.pathname || now.toString().includes(u.pathname), { timeout: 10_000 }).catch(() => {});
        return true;
      }
    } catch {
      /* fall through */
    }
    return false;
  }

  private async typeSearch(query: string): Promise<boolean> {
    const page = this.page;
    const sel = 'input[type=search], input[name="search_query"], input[name*=search i], [role=searchbox], [role=combobox][aria-label*=search i], input[placeholder*=search i], input[placeholder*=recherch i]';
    const box = page.locator(sel).first();
    if (!(await box.isVisible().catch(() => false))) return false;
    try {
      await box.click({ timeout: 3000 });
      await box.fill("");
      await box.type(query, { delay: 35 });
      await box.press("Enter");
      await page.waitForLoadState("domcontentloaded", { timeout: 15_000 }).catch(() => {});
      return true;
    } catch {
      return false;
    }
  }

  private async scrollBy(dy: number): Promise<void> {
    await this.page.mouse.wheel(0, dy);
  }

  /** Let the SPA settle: network idle-ish + small human pause. */
  private async settle(extraMs = 900): Promise<void> {
    await this.page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => {});
    await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
    await sleep(extraMs + Math.random() * 400);
    await this.adapter.dismissOverlays?.(this.page).catch(() => {});
  }
}
