import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'

const APP_URL = process.env.APP_URL!
const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const DATA_ROOT = process.env.DATA_ROOT!
const TOES_URL = process.env.TOES_URL!

const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')

const TOGGLE_SCRIPT = `
function toggleVisibility(btn) {
  var repo = btn.dataset.repo;
  var current = btn.dataset.visibility;
  var next = current === 'public' ? 'private' : 'public';
  btn.dataset.visibility = next;
  btn.textContent = next;
  btn.classList.toggle('public', next === 'public');
  fetch('/api/visibility/' + encodeURIComponent(repo), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ visibility: next })
  }).catch(function() {
    btn.dataset.visibility = current;
    btn.textContent = current;
    btn.classList.toggle('public', current === 'public');
  });
}
`

const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const Badge = define('Badge', {
  fontSize: '12px',
  padding: '2px 8px',
  borderRadius: theme('radius-md'),
  backgroundColor: theme('colors-bgElement'),
  color: theme('colors-textMuted'),
})

const CodeBlock = define('CodeBlock', {
  base: 'pre',
  backgroundColor: theme('colors-bgElement'),
  border: `1px solid ${theme('colors-border')}`,
  borderRadius: theme('radius-md'),
  padding: theme('spacing-lg'),
  fontFamily: theme('fonts-mono'),
  fontSize: '13px',
  overflowX: 'auto',
  color: theme('colors-text'),
  lineHeight: '1.5',
})

const Container = define('Container', {
  fontFamily: theme('fonts-sans'),
  padding: '20px',
  paddingTop: 0,
  maxWidth: '800px',
  margin: '0 auto',
  color: theme('colors-text'),
})

const Heading = define('Heading', {
  base: 'h3',
  margin: '24px 0 8px',
  color: theme('colors-text'),
})

const HelpText = define('HelpText', {
  color: theme('colors-textMuted'),
  fontSize: '14px',
  lineHeight: '1.6',
  margin: '12px 0',
})

const RepoItem = define('RepoItem', {
  padding: '12px 15px',
  borderBottom: `1px solid ${theme('colors-border')}`,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  states: {
    ':last-child': { borderBottom: 'none' },
    ':hover': { backgroundColor: theme('colors-bgHover') },
  },
})

const RepoList = define('RepoList', {
  listStyle: 'none',
  padding: 0,
  margin: '20px 0',
  border: `1px solid ${theme('colors-border')}`,
  borderRadius: theme('radius-md'),
  overflow: 'hidden',
})

const RepoName = define('RepoName', {
  fontFamily: theme('fonts-mono'),
  fontSize: '15px',
  fontWeight: 'bold',
  color: theme('colors-text'),
})

const Toggle = define('Toggle', {
  base: 'button',
  display: 'inline-flex',
  alignItems: 'center',
  gap: '6px',
  padding: '3px 10px',
  borderRadius: theme('radius-md'),
  border: `1px solid ${theme('colors-border')}`,
  backgroundColor: theme('colors-bgElement'),
  color: theme('colors-textMuted'),
  fontSize: '12px',
  cursor: 'pointer',
  transition: 'all 0.15s ease',
  states: {
    ':hover': { borderColor: theme('colors-textMuted') },
    '.public': {
      backgroundColor: theme('colors-statusRunning'),
      color: 'white',
      borderColor: 'transparent',
    },
  },
})

// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------

interface AppRepoProps {
  appName: string
  baseUrl: string
  branch: string
  exists: boolean
  commits: boolean
}

interface LayoutProps {
  title: string
  children: Child
}

interface RepoListPageProps {
  baseUrl: string
  external: boolean
  repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }>
}

type Visibility = 'public' | 'private'

// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------

const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)

// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
const validRepoName = (name: string) =>
  VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)

async function activateApp(name: string): Promise<string | null> {
  const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
    method: 'POST',
  })
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
    console.error(`Reload failed for ${name}:`, msg)
    return msg
  }
  return null
}

async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
  const bare = repoPath(repoName)

  if (!(await hasCommits(bare))) {
    return { ok: false, error: 'No commits in repository' }
  }

  // Validate in a temp dir before touching the real app dir
  const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
  await rm(tmpDir, { recursive: true, force: true })
  await mkdir(tmpDir, { recursive: true })

  // Extract HEAD into the temp directory
  const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
    stdout: 'pipe',
    stderr: 'pipe',
  })

  const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
    stdin: archive.stdout,
    stdout: 'ignore',
    stderr: 'pipe',
  })

  // Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
  const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
    archive.exited,
    tar.exited,
    new Response(archive.stderr).text(),
    new Response(tar.stderr).text(),
  ])

  if (archiveExit !== 0 || tarExit !== 0) {
    await rm(tmpDir, { recursive: true, force: true })
    return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
  }

  // Verify package.json with scripts.toes exists
  const pkgPath = join(tmpDir, 'package.json')
  if (!(await Bun.file(pkgPath).exists())) {
    await rm(tmpDir, { recursive: true, force: true })
    return { ok: false, error: 'No package.json found in repository' }
  }

  try {
    const pkg = JSON.parse(await Bun.file(pkgPath).text())
    if (!pkg.scripts?.toes) {
      await rm(tmpDir, { recursive: true, force: true })
      return { ok: false, error: 'package.json missing scripts.toes entry' }
    }
  } catch {
    await rm(tmpDir, { recursive: true, force: true })
    return { ok: false, error: 'Invalid package.json' }
  }

  // Stop the app before swapping directories
  await stopIfRunning(repoName)

  // Validation passed — swap directories (reload endpoint handles restart)
  const appDir = join(APPS_DIR, repoName)
  await rm(appDir, { recursive: true, force: true })
  await rename(tmpDir, appDir)

  return { ok: true }
}

// Bun.file().exists() is for files only — it returns false for directories.
// Use stat() to check directory existence instead.
async function dirExists(path: string): Promise<boolean> {
  try {
    return (await stat(path)).isDirectory()
  } catch {
    return false
  }
}

async function ensureBareRepo(name: string): Promise<string> {
  const bare = repoPath(name)
  if (!(await dirExists(bare))) {
    await mkdir(bare, { recursive: true })
    const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
    await run(['git', 'init', '--bare'])
    await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
    await run(['git', 'config', 'http.receivepack', 'true'])
  }
  return bare
}

function findLastFlush(data: Uint8Array): number {
  for (let i = data.length - 4; i >= 0; i--) {
    if (data[i] === 0x30 && data[i + 1] === 0x30 &&
        data[i + 2] === 0x30 && data[i + 3] === 0x30) {
      return i
    }
  }
  return -1
}

async function getVisibility(repo: string): Promise<Visibility> {
  const all = await loadVisibility()
  return all[repo] ?? 'private'
}

async function getDefaultBranch(bare: string): Promise<string> {
  const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
    cwd: bare,
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })
  if ((await proc.exited) === 0) {
    const ref = await new Response(proc.stdout).text()
    return ref.trim().replace('refs/heads/', '')
  }
  return 'main'
}

async function gitRpc(
  repo: string,
  service: string,
  body: Uint8Array | ReadableStream<Uint8Array> | null,
): Promise<Response> {
  const bare = repoPath(repo)

  const proc = Bun.spawn([service, '--stateless-rpc', bare], {
    stdin: body ?? 'ignore',
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })

  return new Response(proc.stdout, {
    headers: {
      'Content-Type': `application/x-${service}-result`,
      'Cache-Control': 'no-cache',
    },
  })
}

async function gitService(repo: string, service: string): Promise<Response | null> {
  const bare = repoPath(repo)
  if (!(await dirExists(bare))) return null

  const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
    stdout: 'pipe',
    // Ignore stderr to avoid filling the pipe buffer and blocking the process
    stderr: 'ignore',
  })
  const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
  await proc.exited

  const header = serviceHeader(service)
  const body = new Uint8Array(header.length + stdout.byteLength)
  body.set(header, 0)
  body.set(stdout, header.length)

  return new Response(body, {
    headers: {
      'Content-Type': `application/x-${service}-advertisement`,
      'Cache-Control': 'no-cache',
    },
  })
}

function gitSidebandMessage(text: string): Uint8Array {
  const encoder = new TextEncoder()
  const lines = text.split('\n').filter(Boolean)
  const parts: Uint8Array[] = []
  for (const line of lines) {
    const msg = `\x02remote: ${line}\n`
    const hex = (4 + msg.length).toString(16).padStart(4, '0')
    parts.push(encoder.encode(hex + msg))
  }
  const total = parts.reduce((sum, p) => sum + p.length, 0)
  const out = new Uint8Array(total)
  let offset = 0
  for (const part of parts) {
    out.set(part, offset)
    offset += part.length
  }
  return out
}

async function hasCommits(bare: string): Promise<boolean> {
  const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
    cwd: bare,
    // Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
    stdout: 'ignore',
    stderr: 'ignore',
  })
  return (await proc.exited) === 0
}

function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
  const pos = findLastFlush(gitBody)
  if (pos === -1) {
    const out = new Uint8Array(gitBody.length + msg.length)
    out.set(gitBody, 0)
    out.set(msg, gitBody.length)
    return out
  }
  const out = new Uint8Array(gitBody.length + msg.length)
  out.set(gitBody.subarray(0, pos), 0)
  out.set(msg, pos)
  out.set(gitBody.subarray(pos), pos + msg.length)
  return out
}

async function loadVisibility(): Promise<Record<string, Visibility>> {
  try {
    const data = await readFile(VISIBILITY_PATH, 'utf-8')
    return JSON.parse(data)
  } catch {
    return {}
  }
}

function Layout({ title, children }: LayoutProps) {
  return (
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <ToolScript />
        <Container>{children}</Container>
      </body>
    </html>
  )
}

async function listRepos(): Promise<string[]> {
  if (!(await dirExists(REPOS_DIR))) return []
  const entries = await readdir(REPOS_DIR, { withFileTypes: true })
  return entries
    .filter(e => e.isDirectory() && e.name.endsWith('.git'))
    .map(e => e.name.replace(/\.git$/, ''))
    .sort()
}

function serviceHeader(service: string): Uint8Array {
  const line = `# service=${service}\n`
  const hex = (4 + line.length).toString(16).padStart(4, '0')
  const header = `${hex}${line}0000`
  return new TextEncoder().encode(header)
}

async function saveVisibility(repo: string, visibility: Visibility): Promise<void> {
  const all = await loadVisibility()
  all[repo] = visibility
  await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
}

async function stopIfRunning(name: string): Promise<void> {
  const res = await fetch(`${TOES_URL}/api/apps/${name}`)
  if (!res.ok) return

  const app = await res.json() as { state: string }
  if (app.state !== 'running' && app.state !== 'starting') return

  await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })

  const maxWait = 10000
  const poll = 100
  let waited = 0
  while (waited < maxWait) {
    await new Promise(r => setTimeout(r, poll))
    waited += poll
    const check = await fetch(`${TOES_URL}/api/apps/${name}`)
    if (!check.ok) break
    const { state } = await check.json() as { state: string }
    if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
  }
}

async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
  const prev = deployLocks.get(repo) ?? Promise.resolve()
  const { promise: lock, resolve: release } = Promise.withResolvers<void>()
  deployLocks.set(repo, lock)
  await prev
  try {
    return await fn()
  } finally {
    release()
    if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
  }
}

function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
  return (
    <Layout title={`Git - ${appName}`}>
      {exists && commits ? (
        <>
          <Heading>Repository</Heading>
          <RepoList>
            <RepoItem>
              <div>
                <RepoName>{appName}</RepoName>
                <HelpText style="margin: 4px 0 0; font-size: 12px">
                  git clone {baseUrl}/{appName}
                </HelpText>
              </div>
              <div style="display: flex; gap: 8px; align-items: center">
                <Badge>{branch}</Badge>
                <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
              </div>
            </RepoItem>
          </RepoList>

          <Heading>Push Changes</Heading>
          <CodeBlock>{[
            `git push toes ${branch}`,
            '',
            '# Or if remote not yet added:',
            `git remote add toes ${baseUrl}/${appName}`,
            `git push toes ${branch}`,
          ].join('\n')}</CodeBlock>
        </>
      ) : exists ? (
        <>
          <Heading>Repository</Heading>
          <RepoList>
            <RepoItem>
              <div>
                <RepoName>{appName}</RepoName>
                <HelpText style="margin: 4px 0 0; font-size: 12px">
                  git clone {baseUrl}/{appName}
                </HelpText>
              </div>
              <Badge>empty</Badge>
            </RepoItem>
          </RepoList>

          <Heading>Push to Deploy</Heading>
          <CodeBlock>{[
            `git remote add toes ${baseUrl}/${appName}`,
            'git push toes main',
          ].join('\n')}</CodeBlock>
        </>
      ) : (
        <>
          <Heading>Push to Deploy</Heading>
          <HelpText>
            No git repository for <strong>{appName}</strong> yet.
            Push to create one and deploy.
          </HelpText>
          <CodeBlock>{[
            `git remote add toes ${baseUrl}/${appName}`,
            'git push toes main',
          ].join('\n')}</CodeBlock>
        </>
      )}
    </Layout>
  )
}

function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
  return (
    <Layout title="Git">
      {!external && (
        <>
          <Heading>Push to Deploy</Heading>
          <HelpText>
            Push a git repository to deploy it as a toes app.
            The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
          </HelpText>

          <CodeBlock>{[
            '# Add this server as a remote and push',
            `git remote add toes ${baseUrl}/<app-name>`,
            'git push toes main',
            '',
            '# Or push an existing repo',
            `git push ${baseUrl}/<app-name> main`,
          ].join('\n')}</CodeBlock>
        </>
      )}

      {repos.length > 0 && (
        <>
          <Heading>Repositories</Heading>
          <RepoList>
            {repos.map(({ name, commits, branch, visibility }) => (
              <RepoItem>
                <div>
                  <RepoName>{name}</RepoName>
                  <HelpText style="margin: 4px 0 0; font-size: 12px">
                    git clone {baseUrl}/{name}
                  </HelpText>
                </div>
                <div style="display: flex; gap: 8px; align-items: center">
                  {!external && (
                    <Toggle
                      class={visibility === 'public' ? 'public' : ''}
                      data-repo={name}
                      data-visibility={visibility}
                      onclick="toggleVisibility(this)"
                    >
                      {visibility === 'public' ? 'public' : 'private'}
                    </Toggle>
                  )}
                  <Badge>{branch}</Badge>
                  {commits
                    ? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
                    : <Badge>empty</Badge>}
                </div>
              </RepoItem>
            ))}
          </RepoList>
          {!external && <script src="/toggle.js" />}
        </>
      )}

      {repos.length === 0 && (
        <HelpText>No repositories yet. Push one to get started.</HelpText>
      )}
    </Layout>
  )
}

// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------

mkdirSync(REPOS_DIR, { recursive: true })

on('app:delete', async ({ app: name }) => {
  const bare = repoPath(name)
  if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })
})

app.get('/ok', c => c.text('ok'))

app.get('/styles.css', c =>
  c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)

app.get('/toggle.js', c =>
  c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
)

// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
  const repoParam = c.req.param('repo').replace(/\.git$/, '')
  const service = c.req.query('service')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
    return c.text('Invalid service', 400)
  }

  if (service === 'git-receive-pack' && c.req.header('x-sneaker')) {
    return c.text('Push access denied over sneaker', 403)
  }

  if (service === 'git-receive-pack') {
    await ensureBareRepo(repoParam)
  }

  const bare = repoPath(repoParam)
  if (!(await dirExists(bare))) {
    return c.text('Repository not found', 404)
  }

  const res = await gitService(repoParam, service)
  return res ?? c.text('Repository not found', 404)
})

// POST /:repo[.git]/git-upload-pack
app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
  const repoParam = c.req.param('repo').replace(/\.git$/, '')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  const bare = repoPath(repoParam)
  if (!(await dirExists(bare))) {
    return c.text('Repository not found', 404)
  }

  return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
})

// POST /:repo[.git]/git-receive-pack
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
  if (c.req.header('x-sneaker')) {
    return c.text('Push access denied over sneaker', 403)
  }

  const repoParam = c.req.param('repo').replace(/\.git$/, '')

  if (!validRepoName(repoParam)) {
    return c.text('Invalid repository name', 400)
  }

  await ensureBareRepo(repoParam)

  // Buffer the request body before passing to git-receive-pack. Piping a live
  // HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
  // the pipe buffer fills, stalling the stream reader, while git-receive-pack
  // can't finish reading stdin to produce stdout — both sides block.
  const body = new Uint8Array(await c.req.raw.arrayBuffer())
  const response = await gitRpc(repoParam, 'git-receive-pack', body)
  // Buffer the full response so we can inject sideband error messages before the
  // final flush-pkt on deploy failure. The receive-pack response is just ref status
  // lines (not pack data), so the buffer is small regardless of push size.
  const gitBody = new Uint8Array(await response.arrayBuffer())

  const deployError = await withDeployLock(repoParam, async () => {
    try {
      const result = await deploy(repoParam)
      if (result.ok) {
        const err = await activateApp(repoParam)
        if (err) {
          console.error(`Reload failed for ${repoParam}: ${err}`)
          return `Deploy succeeded but reload failed: ${err}`
        }
        console.log(`Deployed ${repoParam}`)
        return null
      }
      console.error(`Deploy failed for ${repoParam}: ${result.error}`)
      return `Deploy failed: ${result.error}`
    } catch (e) {
      console.error(`Deploy error for ${repoParam}:`, e)
      return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
    }
  })

  const headers = {
    'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
    'Cache-Control': 'no-cache',
  }

  if (deployError) {
    return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
  }

  return new Response(gitBody, { headers })
})

app.post('/api/visibility/:repo', async c => {
  if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)

  const repo = c.req.param('repo')
  if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)

  const body = await c.req.json<{ visibility: string }>()
  if (body.visibility !== 'public' && body.visibility !== 'private') {
    return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
  }

  await saveVisibility(repo, body.visibility)
  return c.json({ ok: true })
})

app.get('/', async c => {
  const appName = c.req.query('app')
  const baseUrl = APP_URL
  const external = !!c.req.header('x-sneaker')

  // When viewing a specific app, only show that app's repo
  if (appName) {
    const bare = repoPath(appName)
    const exists = await dirExists(bare)
    const [commits, branch] = exists
      ? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
      : [false, 'main']

    return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
  }

  // No app selected — show all repos
  const repos = await listRepos()
  const repoData = await Promise.all(repos.map(async name => {
    const bare = repoPath(name)
    const [commits, branch, visibility] = await Promise.all([
      hasCommits(bare),
      getDefaultBranch(bare),
      getVisibility(name),
    ])
    return { name, commits, branch, visibility }
  }))

  // Hide private repos from external (sneaker) requests
  const filtered = external
    ? repoData.filter(r => r.visibility === 'public')
    : repoData

  return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} />)
})

export default app.defaults
